diff --git a/azure/durable_functions/models/DurableOrchestrationContext.py b/azure/durable_functions/models/DurableOrchestrationContext.py index 35b2c5ba..04fa4daf 100644 --- a/azure/durable_functions/models/DurableOrchestrationContext.py +++ b/azure/durable_functions/models/DurableOrchestrationContext.py @@ -100,6 +100,8 @@ def __init__(self, self.open_tasks = defaultdict(list) self.deferred_tasks: Dict[Union[int, str], Tuple[HistoryEvent, bool, str]] = {} + self._version: str = self._extract_version_from_history(self._histories) + @classmethod def from_json(cls, json_string: str): """Convert the value passed into a new instance of the class. @@ -752,3 +754,25 @@ def _get_function_name(self, name: FunctionBuilder, "https://github.com/Azure/azure-functions-durable-python.\n"\ "Error trace: " + e.message raise e + + @property + def version(self) -> Optional[str]: + """Get the version assigned to the orchestration instance on creation. + + Returns + ------- + Optional[str] + The version assigned to the orchestration instance on creation, or None if not found. + """ + return self._version + + @staticmethod + def _extract_version_from_history(history_events: List[HistoryEvent]) -> Optional[str]: + """Extract the version from the execution started event in history. + + Returns None if not found. + """ + for event in history_events: + if event.event_type == HistoryEventType.EXECUTION_STARTED: + return event.Version + return None diff --git a/samples-v2/orchestration_versioning/.funcignore b/samples-v2/orchestration_versioning/.funcignore new file mode 100644 index 00000000..0678ea2b --- /dev/null +++ b/samples-v2/orchestration_versioning/.funcignore @@ -0,0 +1,5 @@ +.git* +.vscode +local.settings.json +test +.venv \ No newline at end of file diff --git a/samples-v2/orchestration_versioning/.gitignore b/samples-v2/orchestration_versioning/.gitignore new file mode 100644 index 00000000..a10127be --- /dev/null +++ b/samples-v2/orchestration_versioning/.gitignore @@ -0,0 +1,130 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don’t work, or not +# install all needed dependencies. +#Pipfile.lock + +# celery beat schedule file +celerybeat-schedule + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# Azure Functions artifacts +bin +obj +appsettings.json +local.settings.json +.python_packages \ No newline at end of file diff --git a/samples-v2/orchestration_versioning/README.md b/samples-v2/orchestration_versioning/README.md new file mode 100644 index 00000000..0a6fb97b --- /dev/null +++ b/samples-v2/orchestration_versioning/README.md @@ -0,0 +1,44 @@ +# Versioning + +This directory contains a Function app that demonstrates how to make changes to an orchestrator function without breaking existing orchestration instances. + +The orchestrator function has two code paths: + +1. The old path invoking `activity_a`. +2. The new path invoking `activity_b` instead. + +While `defaultVersion` in `host.json` is set to `1.0`, the orchestrator will always follow the first path, producing the following output: + +``` +Orchestration version: 1.0 +Suborchestration version: 1.0 +Hello from A! +``` + +When `defaultVersion` in `host.json` is updated (for example, to `2.0`), *new orchestration instances* will follow the new path, producing the following output: + +``` +Orchestration version: 2.0 +Suborchestration version: 2.0 +Hello from B! +``` + +What happens to *existing orchestration instances* that were started *before* the `defaultVersion` change? Waiting for an external event in the middle of the orchestrator provides a convenient opportunity to emulate a deployment while orchestration instances are still running: + +1. Create a new orchestration by invoking the HTTP trigger (`http_start`). +2. Wait for the orchestration to reach the point where it is waiting for an external event. +3. Stop the app. +4. Change `defaultVersion` in `host.json` to `2.0`. +5. Deploy and start the updated app. +6. Trigger the external event. +7. Observe that the orchestration output. + +``` +Orchestration version: 1.0 +Suborchestration version: 2.0 +Hello from A! +``` + +Note that the value returned by `context.version` is permanently associated with the orchestrator instance and is not impacted by the `defaultVersion` change. As a result, the orchestrator follows the old execution path to guarantee deterministic replay behavior. + +However, the suborchestration version is `2.0` because this suborchestration was created *after* the `defaultVersion` change. diff --git a/samples-v2/orchestration_versioning/function_app.py b/samples-v2/orchestration_versioning/function_app.py new file mode 100644 index 00000000..01d1e82f --- /dev/null +++ b/samples-v2/orchestration_versioning/function_app.py @@ -0,0 +1,46 @@ +import logging +import azure.functions as func +import azure.durable_functions as df + +myApp = df.DFApp(http_auth_level=func.AuthLevel.ANONYMOUS) + +@myApp.route(route="orchestrators/{functionName}") +@myApp.durable_client_input(client_name="client") +async def http_start(req: func.HttpRequest, client): + function_name = req.route_params.get('functionName') + instance_id = await client.start_new(function_name) + + logging.info(f"Started orchestration with ID = '{instance_id}'.") + return client.create_check_status_response(req, instance_id) + +@myApp.orchestration_trigger(context_name="context") +def my_orchestrator(context: df.DurableOrchestrationContext): + # context.version contains the value of defaultVersion in host.json + # at the moment when the orchestration was created. + if (context.version == "1.0"): + # Legacy code path + activity_result = yield context.call_activity('activity_a') + else: + # New code path + activity_result = yield context.call_activity('activity_b') + + # Provide an opportunity to update and restart the app + context.set_custom_status("Waiting for Continue event...") + yield context.wait_for_external_event("Continue") + context.set_custom_status("Continue event received") + + # New sub-orchestrations will use the current defaultVersion specified in host.json + sub_result = yield context.call_sub_orchestrator('my_sub_orchestrator') + return [f'Orchestration version: {context.version}', f'Suborchestration version: {sub_result}', activity_result] + +@myApp.orchestration_trigger(context_name="context") +def my_sub_orchestrator(context: df.DurableOrchestrationContext): + return context.version + +@myApp.activity_trigger() +def activity_a() -> str: + return f"Hello from A!" + +@myApp.activity_trigger() +def activity_b() -> str: + return f"Hello from B!" \ No newline at end of file diff --git a/samples-v2/orchestration_versioning/host.json b/samples-v2/orchestration_versioning/host.json new file mode 100644 index 00000000..ca18cb00 --- /dev/null +++ b/samples-v2/orchestration_versioning/host.json @@ -0,0 +1,16 @@ +{ + "version": "2.0", + "logging": { + "applicationInsights": { + "samplingSettings": { + "isEnabled": true, + "excludedTypes": "Request" + } + } + }, + "extensions": { + "durableTask": { + "defaultVersion": "1.0" + } + } +} \ No newline at end of file diff --git a/samples-v2/orchestration_versioning/requirements.txt b/samples-v2/orchestration_versioning/requirements.txt new file mode 100644 index 00000000..d2fabc19 --- /dev/null +++ b/samples-v2/orchestration_versioning/requirements.txt @@ -0,0 +1,7 @@ +# DO NOT include azure-functions-worker in this file +# The Python Worker is managed by Azure Functions platform +# Manually managing azure-functions-worker may cause unexpected issues + +azure-functions +azure-functions-durable +pytest diff --git a/tests/models/test_DurableOrchestrationContext.py b/tests/models/test_DurableOrchestrationContext.py index e2dc9f59..3aecae5a 100644 --- a/tests/models/test_DurableOrchestrationContext.py +++ b/tests/models/test_DurableOrchestrationContext.py @@ -100,3 +100,11 @@ def test_get_input_json_str(): result = context.get_input() assert 'Seattle' == result['city'] + +def test_version_equals_version_from_execution_started_event(): + builder = ContextBuilder('test_function_context') + builder.history_events = [] + builder.add_orchestrator_started_event() + builder.add_execution_started_event(name="TestOrchestrator", version="1.0") + context = DurableOrchestrationContext.from_json(builder.to_json_string()) + assert context.version == "1.0"