From 86326ea7f34ecda74abb7262207b5718cdadcd2c Mon Sep 17 00:00:00 2001 From: Anatoli Beliaev Date: Thu, 12 Jun 2025 21:18:58 +0000 Subject: [PATCH 1/8] Add version property to DurableOrchestrationContext class --- .../models/DurableOrchestrationContext.py | 24 ++++ .../orchestration_versioning/.funcignore | 5 + .../orchestration_versioning/.gitignore | 130 ++++++++++++++++++ samples-v2/orchestration_versioning/README.md | 3 + .../orchestration_versioning/function_app.py | 48 +++++++ samples-v2/orchestration_versioning/host.json | 16 +++ .../orchestration_versioning/requirements.txt | 7 + .../test_DurableOrchestrationContext.py | 16 +++ 8 files changed, 249 insertions(+) create mode 100644 samples-v2/orchestration_versioning/.funcignore create mode 100644 samples-v2/orchestration_versioning/.gitignore create mode 100644 samples-v2/orchestration_versioning/README.md create mode 100644 samples-v2/orchestration_versioning/function_app.py create mode 100644 samples-v2/orchestration_versioning/host.json create mode 100644 samples-v2/orchestration_versioning/requirements.txt diff --git a/azure/durable_functions/models/DurableOrchestrationContext.py b/azure/durable_functions/models/DurableOrchestrationContext.py index 78af5a7..3b8be7c 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. @@ -759,3 +761,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) -> str: + """Get the version assigned to the orchestration instance on creation. + + Returns + ------- + str + The version assigned to the orchestration instance on creation. + """ + return self._version + + @staticmethod + def _extract_version_from_history(history_events: List[HistoryEvent]) -> 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 0000000..0678ea2 --- /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 0000000..a10127b --- /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 0000000..48a70b1 --- /dev/null +++ b/samples-v2/orchestration_versioning/README.md @@ -0,0 +1,3 @@ +# Versioning + +This directory contains a Function app that demonstrates how to make changes to an orchestrator function without breaking existing orchestration instances. diff --git a/samples-v2/orchestration_versioning/function_app.py b/samples-v2/orchestration_versioning/function_app.py new file mode 100644 index 0000000..2a5156e --- /dev/null +++ b/samples-v2/orchestration_versioning/function_app.py @@ -0,0 +1,48 @@ +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): + if (context.version == "1.0"): + # Legacy code path + activity_result = yield context.call_activity('say_hello', "v1.0") + else: + # New code path + activity_result = yield context.call_activity('say_hello', "v2.0") + + """ + While the orchestration is waiting for the external event, + stop the app, update the defaultVersion in host.json to "2.0", + then restart the app and send a "Continue" event. + This orchestration instance should continue with the old version. + """ + context.set_custom_status("Waiting for Continue event...") + yield context.wait_for_external_event("Continue") + context.set_custom_status("Continue event received") + + """ + New orchestration instances (including 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(input_name="city") +def say_hello(city: str) -> str: + return f"Hello {city}!" \ 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 0000000..ca18cb0 --- /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 0000000..d2fabc1 --- /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 e2dc9f5..07ae984 100644 --- a/tests/models/test_DurableOrchestrationContext.py +++ b/tests/models/test_DurableOrchestrationContext.py @@ -100,3 +100,19 @@ def test_get_input_json_str(): result = context.get_input() assert 'Seattle' == result['city'] + +def test_version_equals_version_from_first_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") + builder.add_execution_started_event(name="TestOrchestrator", version="2.0") + context = DurableOrchestrationContext.from_json(builder.to_json_string()) + assert context.version == "1.0" + +def test_version_is_none_if_no_execution_started_event(): + builder = ContextBuilder('test_function_context') + builder.history_events = [] + builder.add_orchestrator_started_event() + context = DurableOrchestrationContext.from_json(builder.to_json_string()) + assert context.version is None From abef2bcdc0973ef628c04ca81beaf564c5f5864a Mon Sep 17 00:00:00 2001 From: Anatoli Beliaev Date: Fri, 13 Jun 2025 02:23:13 +0000 Subject: [PATCH 2/8] Fix test app output --- samples-v2/orchestration_versioning/function_app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/samples-v2/orchestration_versioning/function_app.py b/samples-v2/orchestration_versioning/function_app.py index 2a5156e..726370b 100644 --- a/samples-v2/orchestration_versioning/function_app.py +++ b/samples-v2/orchestration_versioning/function_app.py @@ -20,7 +20,7 @@ def my_orchestrator(context: df.DurableOrchestrationContext): activity_result = yield context.call_activity('say_hello', "v1.0") else: # New code path - activity_result = yield context.call_activity('say_hello', "v2.0") + activity_result = yield context.call_activity('say_hello', f"v{context.version}") """ While the orchestration is waiting for the external event, From d1178c3bc372181fd123ee4f5b49083e0f903dce Mon Sep 17 00:00:00 2001 From: Anatoli Beliaev Date: Thu, 12 Jun 2025 19:33:42 -0700 Subject: [PATCH 3/8] Update azure/durable_functions/models/DurableOrchestrationContext.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../durable_functions/models/DurableOrchestrationContext.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/azure/durable_functions/models/DurableOrchestrationContext.py b/azure/durable_functions/models/DurableOrchestrationContext.py index 3b8be7c..42650c1 100644 --- a/azure/durable_functions/models/DurableOrchestrationContext.py +++ b/azure/durable_functions/models/DurableOrchestrationContext.py @@ -763,13 +763,13 @@ def _get_function_name(self, name: FunctionBuilder, raise e @property - def version(self) -> str: + def version(self) -> Optional[str]: """Get the version assigned to the orchestration instance on creation. Returns ------- - str - The version assigned to the orchestration instance on creation. + Optional[str] + The version assigned to the orchestration instance on creation, or None if not found. """ return self._version From 043ce0c578f130f094ba56409c8026b77bd77689 Mon Sep 17 00:00:00 2001 From: Anatoli Beliaev Date: Thu, 12 Jun 2025 19:33:51 -0700 Subject: [PATCH 4/8] Update azure/durable_functions/models/DurableOrchestrationContext.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- azure/durable_functions/models/DurableOrchestrationContext.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/azure/durable_functions/models/DurableOrchestrationContext.py b/azure/durable_functions/models/DurableOrchestrationContext.py index 42650c1..a3872bf 100644 --- a/azure/durable_functions/models/DurableOrchestrationContext.py +++ b/azure/durable_functions/models/DurableOrchestrationContext.py @@ -774,7 +774,7 @@ def version(self) -> Optional[str]: return self._version @staticmethod - def _extract_version_from_history(history_events: List[HistoryEvent]) -> str: + 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. From 5144d59230e21479b53c681e22419bba336dcfb2 Mon Sep 17 00:00:00 2001 From: Anatoli Beliaev Date: Fri, 13 Jun 2025 23:30:53 +0000 Subject: [PATCH 5/8] Fix tests --- tests/models/test_DurableOrchestrationContext.py | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/tests/models/test_DurableOrchestrationContext.py b/tests/models/test_DurableOrchestrationContext.py index 07ae984..3aecae5 100644 --- a/tests/models/test_DurableOrchestrationContext.py +++ b/tests/models/test_DurableOrchestrationContext.py @@ -101,18 +101,10 @@ def test_get_input_json_str(): assert 'Seattle' == result['city'] -def test_version_equals_version_from_first_execution_started_event(): +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") - builder.add_execution_started_event(name="TestOrchestrator", version="2.0") context = DurableOrchestrationContext.from_json(builder.to_json_string()) assert context.version == "1.0" - -def test_version_is_none_if_no_execution_started_event(): - builder = ContextBuilder('test_function_context') - builder.history_events = [] - builder.add_orchestrator_started_event() - context = DurableOrchestrationContext.from_json(builder.to_json_string()) - assert context.version is None From e592351bf90c9e0d45bc33fe4c23599902a89628 Mon Sep 17 00:00:00 2001 From: Anatoli Beliaev Date: Fri, 13 Jun 2025 23:33:35 +0000 Subject: [PATCH 6/8] Fix comments --- .../orchestration_versioning/function_app.py | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/samples-v2/orchestration_versioning/function_app.py b/samples-v2/orchestration_versioning/function_app.py index 726370b..fdbc912 100644 --- a/samples-v2/orchestration_versioning/function_app.py +++ b/samples-v2/orchestration_versioning/function_app.py @@ -22,20 +22,16 @@ def my_orchestrator(context: df.DurableOrchestrationContext): # New code path activity_result = yield context.call_activity('say_hello', f"v{context.version}") - """ - While the orchestration is waiting for the external event, - stop the app, update the defaultVersion in host.json to "2.0", - then restart the app and send a "Continue" event. - This orchestration instance should continue with the old version. - """ + # While the orchestration is waiting for the external event, + # stop the app, update the defaultVersion in host.json to "2.0", + # then restart the app and send a "Continue" event. + # This orchestration instance should continue with the old version. context.set_custom_status("Waiting for Continue event...") yield context.wait_for_external_event("Continue") context.set_custom_status("Continue event received") - """ - New orchestration instances (including sub-orchestrations) - will use the current defaultVersion specified in host.json. - """ + # New orchestration instances (including 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] From f71b40e5100f71d7c3a78709fbb2a16dc12899e5 Mon Sep 17 00:00:00 2001 From: Anatoli Beliaev Date: Wed, 18 Jun 2025 12:17:38 -0700 Subject: [PATCH 7/8] Update sample code and instructions --- samples-v2/orchestration_versioning/README.md | 41 +++++++++++++++++++ .../orchestration_versioning/function_app.py | 24 ++++++----- 2 files changed, 54 insertions(+), 11 deletions(-) diff --git a/samples-v2/orchestration_versioning/README.md b/samples-v2/orchestration_versioning/README.md index 48a70b1..20966a2 100644 --- a/samples-v2/orchestration_versioning/README.md +++ b/samples-v2/orchestration_versioning/README.md @@ -1,3 +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 it was invoked 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 index fdbc912..01d1e82 100644 --- a/samples-v2/orchestration_versioning/function_app.py +++ b/samples-v2/orchestration_versioning/function_app.py @@ -15,23 +15,21 @@ async def http_start(req: func.HttpRequest, client): @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('say_hello', "v1.0") + activity_result = yield context.call_activity('activity_a') else: # New code path - activity_result = yield context.call_activity('say_hello', f"v{context.version}") + activity_result = yield context.call_activity('activity_b') - # While the orchestration is waiting for the external event, - # stop the app, update the defaultVersion in host.json to "2.0", - # then restart the app and send a "Continue" event. - # This orchestration instance should continue with the old version. + # 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 orchestration instances (including sub-orchestrations) - # will use the current defaultVersion specified in host.json. + # 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] @@ -39,6 +37,10 @@ def my_orchestrator(context: df.DurableOrchestrationContext): def my_sub_orchestrator(context: df.DurableOrchestrationContext): return context.version -@myApp.activity_trigger(input_name="city") -def say_hello(city: str) -> str: - return f"Hello {city}!" \ No newline at end of file +@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 From f147cec6df5c2278ea571f0494fd7e38162b00a9 Mon Sep 17 00:00:00 2001 From: Anatoli Beliaev Date: Wed, 18 Jun 2025 19:22:30 -0700 Subject: [PATCH 8/8] Minor fixes in README.md --- samples-v2/orchestration_versioning/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/samples-v2/orchestration_versioning/README.md b/samples-v2/orchestration_versioning/README.md index 20966a2..0a6fb97 100644 --- a/samples-v2/orchestration_versioning/README.md +++ b/samples-v2/orchestration_versioning/README.md @@ -23,7 +23,7 @@ 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: +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. @@ -41,4 +41,4 @@ 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 it was invoked this suborchestration was created *after* the `defaultVersion` change. +However, the suborchestration version is `2.0` because this suborchestration was created *after* the `defaultVersion` change.