Skip to content

Commit 97a0891

Browse files
authored
Add version property to DurableOrchestrationContext class (#557)
Add version property to DurableOrchestrationContext class
1 parent c957019 commit 97a0891

File tree

8 files changed

+280
-0
lines changed

8 files changed

+280
-0
lines changed

azure/durable_functions/models/DurableOrchestrationContext.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,8 @@ def __init__(self,
100100
self.open_tasks = defaultdict(list)
101101
self.deferred_tasks: Dict[Union[int, str], Tuple[HistoryEvent, bool, str]] = {}
102102

103+
self._version: str = self._extract_version_from_history(self._histories)
104+
103105
@classmethod
104106
def from_json(cls, json_string: str):
105107
"""Convert the value passed into a new instance of the class.
@@ -752,3 +754,25 @@ def _get_function_name(self, name: FunctionBuilder,
752754
"https://github.com/Azure/azure-functions-durable-python.\n"\
753755
"Error trace: " + e.message
754756
raise e
757+
758+
@property
759+
def version(self) -> Optional[str]:
760+
"""Get the version assigned to the orchestration instance on creation.
761+
762+
Returns
763+
-------
764+
Optional[str]
765+
The version assigned to the orchestration instance on creation, or None if not found.
766+
"""
767+
return self._version
768+
769+
@staticmethod
770+
def _extract_version_from_history(history_events: List[HistoryEvent]) -> Optional[str]:
771+
"""Extract the version from the execution started event in history.
772+
773+
Returns None if not found.
774+
"""
775+
for event in history_events:
776+
if event.event_type == HistoryEventType.EXECUTION_STARTED:
777+
return event.Version
778+
return None
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
.git*
2+
.vscode
3+
local.settings.json
4+
test
5+
.venv
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
# Byte-compiled / optimized / DLL files
2+
__pycache__/
3+
*.py[cod]
4+
*$py.class
5+
6+
# C extensions
7+
*.so
8+
9+
# Distribution / packaging
10+
.Python
11+
build/
12+
develop-eggs/
13+
dist/
14+
downloads/
15+
eggs/
16+
.eggs/
17+
lib/
18+
lib64/
19+
parts/
20+
sdist/
21+
var/
22+
wheels/
23+
pip-wheel-metadata/
24+
share/python-wheels/
25+
*.egg-info/
26+
.installed.cfg
27+
*.egg
28+
MANIFEST
29+
30+
# PyInstaller
31+
# Usually these files are written by a python script from a template
32+
# before PyInstaller builds the exe, so as to inject date/other infos into it.
33+
*.manifest
34+
*.spec
35+
36+
# Installer logs
37+
pip-log.txt
38+
pip-delete-this-directory.txt
39+
40+
# Unit test / coverage reports
41+
htmlcov/
42+
.tox/
43+
.nox/
44+
.coverage
45+
.coverage.*
46+
.cache
47+
nosetests.xml
48+
coverage.xml
49+
*.cover
50+
.hypothesis/
51+
.pytest_cache/
52+
53+
# Translations
54+
*.mo
55+
*.pot
56+
57+
# Django stuff:
58+
*.log
59+
local_settings.py
60+
db.sqlite3
61+
62+
# Flask stuff:
63+
instance/
64+
.webassets-cache
65+
66+
# Scrapy stuff:
67+
.scrapy
68+
69+
# Sphinx documentation
70+
docs/_build/
71+
72+
# PyBuilder
73+
target/
74+
75+
# Jupyter Notebook
76+
.ipynb_checkpoints
77+
78+
# IPython
79+
profile_default/
80+
ipython_config.py
81+
82+
# pyenv
83+
.python-version
84+
85+
# pipenv
86+
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
87+
# However, in case of collaboration, if having platform-specific dependencies or dependencies
88+
# having no cross-platform support, pipenv may install dependencies that don’t work, or not
89+
# install all needed dependencies.
90+
#Pipfile.lock
91+
92+
# celery beat schedule file
93+
celerybeat-schedule
94+
95+
# SageMath parsed files
96+
*.sage.py
97+
98+
# Environments
99+
.env
100+
.venv
101+
env/
102+
venv/
103+
ENV/
104+
env.bak/
105+
venv.bak/
106+
107+
# Spyder project settings
108+
.spyderproject
109+
.spyproject
110+
111+
# Rope project settings
112+
.ropeproject
113+
114+
# mkdocs documentation
115+
/site
116+
117+
# mypy
118+
.mypy_cache/
119+
.dmypy.json
120+
dmypy.json
121+
122+
# Pyre type checker
123+
.pyre/
124+
125+
# Azure Functions artifacts
126+
bin
127+
obj
128+
appsettings.json
129+
local.settings.json
130+
.python_packages
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
# Versioning
2+
3+
This directory contains a Function app that demonstrates how to make changes to an orchestrator function without breaking existing orchestration instances.
4+
5+
The orchestrator function has two code paths:
6+
7+
1. The old path invoking `activity_a`.
8+
2. The new path invoking `activity_b` instead.
9+
10+
While `defaultVersion` in `host.json` is set to `1.0`, the orchestrator will always follow the first path, producing the following output:
11+
12+
```
13+
Orchestration version: 1.0
14+
Suborchestration version: 1.0
15+
Hello from A!
16+
```
17+
18+
When `defaultVersion` in `host.json` is updated (for example, to `2.0`), *new orchestration instances* will follow the new path, producing the following output:
19+
20+
```
21+
Orchestration version: 2.0
22+
Suborchestration version: 2.0
23+
Hello from B!
24+
```
25+
26+
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:
27+
28+
1. Create a new orchestration by invoking the HTTP trigger (`http_start`).
29+
2. Wait for the orchestration to reach the point where it is waiting for an external event.
30+
3. Stop the app.
31+
4. Change `defaultVersion` in `host.json` to `2.0`.
32+
5. Deploy and start the updated app.
33+
6. Trigger the external event.
34+
7. Observe that the orchestration output.
35+
36+
```
37+
Orchestration version: 1.0
38+
Suborchestration version: 2.0
39+
Hello from A!
40+
```
41+
42+
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.
43+
44+
However, the suborchestration version is `2.0` because this suborchestration was created *after* the `defaultVersion` change.
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import logging
2+
import azure.functions as func
3+
import azure.durable_functions as df
4+
5+
myApp = df.DFApp(http_auth_level=func.AuthLevel.ANONYMOUS)
6+
7+
@myApp.route(route="orchestrators/{functionName}")
8+
@myApp.durable_client_input(client_name="client")
9+
async def http_start(req: func.HttpRequest, client):
10+
function_name = req.route_params.get('functionName')
11+
instance_id = await client.start_new(function_name)
12+
13+
logging.info(f"Started orchestration with ID = '{instance_id}'.")
14+
return client.create_check_status_response(req, instance_id)
15+
16+
@myApp.orchestration_trigger(context_name="context")
17+
def my_orchestrator(context: df.DurableOrchestrationContext):
18+
# context.version contains the value of defaultVersion in host.json
19+
# at the moment when the orchestration was created.
20+
if (context.version == "1.0"):
21+
# Legacy code path
22+
activity_result = yield context.call_activity('activity_a')
23+
else:
24+
# New code path
25+
activity_result = yield context.call_activity('activity_b')
26+
27+
# Provide an opportunity to update and restart the app
28+
context.set_custom_status("Waiting for Continue event...")
29+
yield context.wait_for_external_event("Continue")
30+
context.set_custom_status("Continue event received")
31+
32+
# New sub-orchestrations will use the current defaultVersion specified in host.json
33+
sub_result = yield context.call_sub_orchestrator('my_sub_orchestrator')
34+
return [f'Orchestration version: {context.version}', f'Suborchestration version: {sub_result}', activity_result]
35+
36+
@myApp.orchestration_trigger(context_name="context")
37+
def my_sub_orchestrator(context: df.DurableOrchestrationContext):
38+
return context.version
39+
40+
@myApp.activity_trigger()
41+
def activity_a() -> str:
42+
return f"Hello from A!"
43+
44+
@myApp.activity_trigger()
45+
def activity_b() -> str:
46+
return f"Hello from B!"
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
{
2+
"version": "2.0",
3+
"logging": {
4+
"applicationInsights": {
5+
"samplingSettings": {
6+
"isEnabled": true,
7+
"excludedTypes": "Request"
8+
}
9+
}
10+
},
11+
"extensions": {
12+
"durableTask": {
13+
"defaultVersion": "1.0"
14+
}
15+
}
16+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# DO NOT include azure-functions-worker in this file
2+
# The Python Worker is managed by Azure Functions platform
3+
# Manually managing azure-functions-worker may cause unexpected issues
4+
5+
azure-functions
6+
azure-functions-durable
7+
pytest

tests/models/test_DurableOrchestrationContext.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,3 +100,11 @@ def test_get_input_json_str():
100100
result = context.get_input()
101101

102102
assert 'Seattle' == result['city']
103+
104+
def test_version_equals_version_from_execution_started_event():
105+
builder = ContextBuilder('test_function_context')
106+
builder.history_events = []
107+
builder.add_orchestrator_started_event()
108+
builder.add_execution_started_event(name="TestOrchestrator", version="1.0")
109+
context = DurableOrchestrationContext.from_json(builder.to_json_string())
110+
assert context.version == "1.0"

0 commit comments

Comments
 (0)