Skip to content

Commit 20ceeaa

Browse files
authored
Allow overriding orchestration version when starting orchestrations via APIs (#582)
1 parent 44c259a commit 20ceeaa

File tree

8 files changed

+106
-20
lines changed

8 files changed

+106
-20
lines changed

azure/durable_functions/models/DurableOrchestrationClient.py

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,8 @@ def __init__(self, context: str):
4848
async def start_new(self,
4949
orchestration_function_name: str,
5050
instance_id: Optional[str] = None,
51-
client_input: Optional[Any] = None) -> str:
51+
client_input: Optional[Any] = None,
52+
version: Optional[str] = None) -> str:
5253
"""Start a new instance of the specified orchestrator function.
5354
5455
If an orchestration instance with the specified ID already exists, the
@@ -63,14 +64,19 @@ async def start_new(self,
6364
the Durable Functions extension will generate a random GUID (recommended).
6465
client_input : Optional[Any]
6566
JSON-serializable input value for the orchestrator function.
67+
version : Optional[str]
68+
The version to assign to the orchestration instance. If not specified,
69+
the defaultVersion from host.json will be used.
6670
6771
Returns
6872
-------
6973
str
7074
The ID of the new orchestration instance if successful, None if not.
7175
"""
7276
request_url = self._get_start_new_url(
73-
instance_id=instance_id, orchestration_function_name=orchestration_function_name)
77+
instance_id=instance_id,
78+
orchestration_function_name=orchestration_function_name,
79+
version=version)
7480

7581
trace_parent, trace_state = DurableOrchestrationClient._get_current_activity_context()
7682

@@ -639,10 +645,15 @@ def _parse_purge_instance_history_response(
639645
raise Exception(result)
640646

641647
def _get_start_new_url(
642-
self, instance_id: Optional[str], orchestration_function_name: str) -> str:
648+
self, instance_id: Optional[str], orchestration_function_name: str,
649+
version: Optional[str] = None) -> str:
643650
instance_path = f'/{instance_id}' if instance_id is not None else ''
644651
request_url = f'{self._orchestration_bindings.rpc_base_url}orchestrators/' \
645652
f'{orchestration_function_name}{instance_path}'
653+
654+
if version is not None:
655+
request_url += f'?version={quote(version)}'
656+
646657
return request_url
647658

648659
def _get_raise_event_url(

azure/durable_functions/models/DurableOrchestrationContext.py

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -285,7 +285,8 @@ def call_http(self, method: str, uri: str, content: Optional[str] = None,
285285

286286
def call_sub_orchestrator(self,
287287
name: Union[str, Callable], input_: Optional[Any] = None,
288-
instance_id: Optional[str] = None) -> TaskBase:
288+
instance_id: Optional[str] = None,
289+
version: Optional[str] = None) -> TaskBase:
289290
"""Schedule sub-orchestration function named `name` for execution.
290291
291292
Parameters
@@ -296,6 +297,9 @@ def call_sub_orchestrator(self,
296297
The JSON-serializable input to pass to the orchestrator function.
297298
instance_id: Optional[str]
298299
A unique ID to use for the sub-orchestration instance.
300+
version: Optional[str]
301+
The version to assign to the sub-orchestration instance. If not specified,
302+
the defaultVersion from host.json will be used.
299303
300304
Returns
301305
-------
@@ -313,14 +317,15 @@ def call_sub_orchestrator(self,
313317
if isinstance(name, FunctionBuilder):
314318
name = self._get_function_name(name, OrchestrationTrigger)
315319

316-
action = CallSubOrchestratorAction(name, input_, instance_id)
320+
action = CallSubOrchestratorAction(name, input_, instance_id, version)
317321
task = self._generate_task(action)
318322
return task
319323

320324
def call_sub_orchestrator_with_retry(self,
321325
name: Union[str, Callable], retry_options: RetryOptions,
322326
input_: Optional[Any] = None,
323-
instance_id: Optional[str] = None) -> TaskBase:
327+
instance_id: Optional[str] = None,
328+
version: Optional[str] = None) -> TaskBase:
324329
"""Schedule sub-orchestration function named `name` for execution, with retry-options.
325330
326331
Parameters
@@ -333,6 +338,9 @@ def call_sub_orchestrator_with_retry(self,
333338
The JSON-serializable input to pass to the activity function. Defaults to None.
334339
instance_id: str
335340
The instance ID of the sub-orchestrator to call.
341+
version: Optional[str]
342+
The version to assign to the sub-orchestration instance. If not specified,
343+
the defaultVersion from host.json will be used.
336344
337345
Returns
338346
-------
@@ -350,7 +358,8 @@ def call_sub_orchestrator_with_retry(self,
350358
if isinstance(name, FunctionBuilder):
351359
name = self._get_function_name(name, OrchestrationTrigger)
352360

353-
action = CallSubOrchestratorWithRetryAction(name, retry_options, input_, instance_id)
361+
action = CallSubOrchestratorWithRetryAction(
362+
name, retry_options, input_, instance_id, version)
354363
task = self._generate_task(action, retry_options)
355364
return task
356365

azure/durable_functions/models/actions/CallSubOrchestratorAction.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,11 @@ class CallSubOrchestratorAction(Action):
1111
"""Defines the structure of the Call SubOrchestrator object."""
1212

1313
def __init__(self, function_name: str, _input: Optional[Any] = None,
14-
instance_id: Optional[str] = None):
14+
instance_id: Optional[str] = None, version: Optional[str] = None):
1515
self.function_name: str = function_name
1616
self._input: str = dumps(_input, default=_serialize_custom_object)
1717
self.instance_id: Optional[str] = instance_id
18+
self.version: Optional[str] = version
1819

1920
if not self.function_name:
2021
raise ValueError("function_name cannot be empty")
@@ -37,4 +38,5 @@ def to_json(self) -> Dict[str, Union[str, int]]:
3738
add_attrib(json_dict, self, 'function_name', 'functionName')
3839
add_attrib(json_dict, self, '_input', 'input')
3940
add_attrib(json_dict, self, 'instance_id', 'instanceId')
41+
add_attrib(json_dict, self, 'version', 'version')
4042
return json_dict

azure/durable_functions/models/actions/CallSubOrchestratorWithRetryAction.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,12 @@ class CallSubOrchestratorWithRetryAction(Action):
1313

1414
def __init__(self, function_name: str, retry_options: RetryOptions,
1515
_input: Optional[Any] = None,
16-
instance_id: Optional[str] = None):
16+
instance_id: Optional[str] = None, version: Optional[str] = None):
1717
self.function_name: str = function_name
1818
self._input: str = dumps(_input, default=_serialize_custom_object)
1919
self.retry_options: RetryOptions = retry_options
2020
self.instance_id: Optional[str] = instance_id
21+
self.version: Optional[str] = version
2122

2223
if not self.function_name:
2324
raise ValueError("function_name cannot be empty")
@@ -41,4 +42,5 @@ def to_json(self) -> Dict[str, Union[str, int]]:
4142
add_attrib(json_dict, self, '_input', 'input')
4243
add_json_attrib(json_dict, self, 'retry_options', 'retryOptions')
4344
add_attrib(json_dict, self, 'instance_id', 'instanceId')
45+
add_attrib(json_dict, self, 'version', 'version')
4446
return json_dict

samples-v2/orchestration_versioning/README.md

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ What happens to *existing orchestration instances* that were started *before* th
3333
6. Trigger the external event.
3434
7. Observe that the orchestration output.
3535

36-
```
36+
```text
3737
Orchestration version: 1.0
3838
Suborchestration version: 2.0
3939
Hello from A!
@@ -42,3 +42,33 @@ Hello from A!
4242
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.
4343

4444
However, the suborchestration version is `2.0` because this suborchestration was created *after* the `defaultVersion` change.
45+
46+
## Overriding Version Programmatically
47+
48+
In addition to using `defaultVersion` in `host.json`, you can also specify a version explicitly when starting an orchestration using the `version` parameter of `client.start_new()`:
49+
50+
```python
51+
# Start with a specific version
52+
instance_id = await client.start_new("my_orchestrator", version="1.5")
53+
```
54+
55+
You can also specify a version when calling sub-orchestrators from within an orchestrator function:
56+
57+
```python
58+
# Call sub-orchestrator with specific version
59+
sub_result = yield context.call_sub_orchestrator('my_sub_orchestrator', version="1.5")
60+
61+
# Call sub-orchestrator with retry and specific version
62+
sub_result = yield context.call_sub_orchestrator_with_retry(
63+
'my_sub_orchestrator',
64+
retry_options=retry_options,
65+
version="2.0"
66+
)
67+
```
68+
69+
When a version is explicitly provided, it takes precedence over the `defaultVersion` in `host.json`. This allows you to:
70+
71+
- Test new orchestration versions without changing configuration
72+
- Run multiple versions simultaneously
73+
- Gradually roll out new versions by controlling which requests use which version
74+
- Running A/B tests with different sub-orchestrator implementations

samples-v2/orchestration_versioning/function_app.py

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,17 @@
88
@myApp.durable_client_input(client_name="client")
99
async def http_start(req: func.HttpRequest, client):
1010
function_name = req.route_params.get('functionName')
11-
instance_id = await client.start_new(function_name)
1211

13-
logging.info(f"Started orchestration with ID = '{instance_id}'.")
12+
# Get optional version from query parameter
13+
version = req.params.get('version')
14+
15+
if version:
16+
instance_id = await client.start_new(function_name, version=version)
17+
logging.info(f"Started orchestration with ID = '{instance_id}' and version = '{version}'.")
18+
else:
19+
instance_id = await client.start_new(function_name)
20+
logging.info(f"Started orchestration with ID = '{instance_id}'.")
21+
1422
return client.create_check_status_response(req, instance_id)
1523

1624
@myApp.orchestration_trigger(context_name="context")
@@ -29,18 +37,27 @@ def my_orchestrator(context: df.DurableOrchestrationContext):
2937
yield context.wait_for_external_event("Continue")
3038
context.set_custom_status("Continue event received")
3139

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]
40+
# You can explicitly pass a version to sub-orchestrators
41+
sub_result_with_version = yield context.call_sub_orchestrator('my_sub_orchestrator', version="0.9")
42+
43+
# Without specifying version, the sub-orchestrator will use the current defaultVersion
44+
sub_result_default = yield context.call_sub_orchestrator('my_sub_orchestrator')
45+
46+
return [
47+
f'Orchestration version: {context.version}',
48+
f'Suborchestration version (explicit): {sub_result_with_version}',
49+
f'Suborchestration version (default): {sub_result_default}',
50+
activity_result
51+
]
3552

3653
@myApp.orchestration_trigger(context_name="context")
3754
def my_sub_orchestrator(context: df.DurableOrchestrationContext):
3855
return context.version
3956

40-
@myApp.activity_trigger()
41-
def activity_a() -> str:
57+
@myApp.activity_trigger(input_name="input")
58+
def activity_a(input: str) -> str:
4259
return f"Hello from A!"
4360

44-
@myApp.activity_trigger()
45-
def activity_b() -> str:
46-
return f"Hello from B!"
61+
@myApp.activity_trigger(input_name="input")
62+
def activity_b(input: str) -> str:
63+
return f"Hello from B!"

samples-v2/orchestration_versioning/host.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,5 +12,9 @@
1212
"durableTask": {
1313
"defaultVersion": "1.0"
1414
}
15+
},
16+
"extensionBundle": {
17+
"id": "Microsoft.Azure.Functions.ExtensionBundle",
18+
"version": "[4.26.0, 5.0.0)"
1519
}
1620
}

tests/models/test_DurableOrchestrationClient.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,17 @@ def test_get_start_new_url(binding_string):
8282
assert expected_url == start_new_url
8383

8484

85+
def test_get_start_new_url_with_version(binding_string):
86+
client = DurableOrchestrationClient(binding_string)
87+
instance_id = "2e2568e7-a906-43bd-8364-c81733c5891e"
88+
function_name = "my_function"
89+
version = "2.0"
90+
start_new_url = client._get_start_new_url(instance_id, function_name, version)
91+
expected_url = replace_stand_in_bits(
92+
f"{RPC_BASE_URL}orchestrators/{function_name}/{instance_id}?version={version}")
93+
assert expected_url == start_new_url
94+
95+
8596
def test_get_input_returns_none_when_none_supplied():
8697
result = DurableOrchestrationClient._get_json_input(None)
8798
assert result is None

0 commit comments

Comments
 (0)