Skip to content

Commit 1df84a9

Browse files
andystaplesCopilot
andcommitted
Document coroutine orchestrators and add Managed compatibility tests
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent 4ebbc8b commit 1df84a9

7 files changed

Lines changed: 221 additions & 1 deletion

File tree

‎CHANGELOG.md‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,23 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

88
## Unreleased
99

10+
ADDED
11+
12+
- Added `async def` orchestrators with an automatically supplied
13+
`AsyncOrchestrationContext`, cold coroutine operations, durable asyncio
14+
timers, and supported task-group and gather composition. Generator
15+
orchestrators retain their eager durable handles. `as_task_context()` and
16+
`Task.wait()` enable explicit interoperability with those handles.
17+
- Async event waits support owned cancellation and timeouts, withdrawing
18+
abandoned subscriptions so they cannot consume later events.
19+
20+
> [!WARNING]
21+
> Async cleanup can run at every dispatch, including suspension; it is not
22+
> durable workflow compensation. Converting running orchestrations or changing
23+
> Python versions may alter replay-visible action order. See the
24+
> [async contract](docs/async-orchestrators.md) for supported operations and
25+
> replay and upgrade guidance.
26+
1027
FIXED
1128

1229
- Activity and sub-orchestration `retry_timeout` decisions now use replay-safe

‎README.md‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,53 @@ The Azure Functions Durable provider requires Python 3.13 or later.
3232

3333
## Optional Features
3434

35+
### Async Orchestrators
36+
37+
All three packages support `async def` orchestrators and nested async helpers.
38+
Existing generator orchestrators continue to work.
39+
40+
```python
41+
import asyncio
42+
43+
from durabletask import task
44+
45+
46+
async def sequence(ctx: task.AsyncOrchestrationContext, value: int) -> list[int]:
47+
first = await ctx.call_activity("plus_one", input=value, return_type=int)
48+
async with asyncio.TaskGroup() as group:
49+
left = group.create_task(ctx.call_activity("plus_one", input=first, return_type=int))
50+
right = group.create_task(ctx.call_activity("plus_one", input=value, return_type=int))
51+
return [left.result(), right.result()]
52+
```
53+
54+
Register the function with `TaskHubGrpcWorker`, `DurableTaskSchedulerWorker`,
55+
or the Azure Functions `orchestration_trigger` decorator as usual. Functions
56+
also supports single-argument `async def` orchestrators using the compatibility
57+
context and `await context.task_all(...)`. Async orchestrators automatically
58+
receive cold coroutine operations accepted by `create_task` and task groups.
59+
Generator orchestrators keep their eager, reusable durable handles. For
60+
interoperability, `ctx.as_task_context()` exposes the eager context and
61+
`handle.wait()` creates an independently cancellable borrower.
62+
63+
> [!WARNING]
64+
> This is replay-driven await syntax, not general-purpose asyncio execution.
65+
> Compose durable work with `asyncio.gather`, local tasks/task groups, and
66+
> durable `asyncio.sleep` / timeouts within the
67+
> [supported contract](docs/async-orchestrators.md).
68+
> Perform network, storage, and other nondeterministic I/O in activities.
69+
> Completed foreign awaitables and synchronous side effects cannot reliably be
70+
> detected by the replay engine.
71+
> Python `finally` blocks and async context-manager exits can run at ordinary
72+
> dispatch suspension; they are not workflow-lifetime compensation scopes.
73+
74+
An async helper is not a durable sub-orchestration: use
75+
`await ctx.call_sub_orchestrator(...)` for a separate orchestration instance.
76+
An `async def` function containing `yield` is an async generator, not a supported
77+
orchestrator. Converting an existing orchestrator must preserve durable action
78+
order and payloads for replay compatibility.
79+
See the contract for cancellation, per-dispatch async cleanup, unsupported
80+
ordering-sensitive APIs and framework adapters.
81+
3582
### Task Hub Query Filters
3683

3784
Set `OrchestrationQuery.task_hub_names` to filter orchestration queries by one or

‎azure-functions-durable/CHANGELOG.md‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
ADDED
1111

12+
- With the corresponding core SDK, native and v1-style `async def`
13+
orchestrators automatically receive async contexts with cold durable operations
14+
and supported asyncio composition. The v1-style context preserves compatibility
15+
argument names and provides async `task_all` and `task_any`. Generator
16+
orchestrators retain their eager behavior. See the
17+
[async contract](../docs/async-orchestrators.md) for dispatch cleanup and
18+
replay/upgrade precautions.
19+
- The `orchestrator_generator_wrapper` test helper accepts direct-await
20+
coroutines. Tests involving asyncio scheduling still require a replay executor
21+
or backend.
1222
- Added opt-in `HttpRetryOptions` on `context.call_http()` for durable initial
1323
request retries with configurable timing and status selection. Empty or omitted
1424
status lists select all non-2xx responses; exhausted retries fail the call.
@@ -59,6 +69,7 @@ readers before enabling new writers; existing headerless blobs still require
5969
their original compression setting. See the
6070
[core compatibility guidance](../docs/features.md#cross-sdk-compatibility).
6171

72+
6273
## v2.0.0rc4
6374

6475
ADDED

‎azure-functions-durable/MIGRATION_GUIDE.md‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,10 +102,18 @@ The single-argument orchestrator receives the compatibility
102102
Use this state to validate the infrastructure upgrade before changing
103103
application APIs.
104104

105+
In 2.x, a single-argument `async def` orchestrator instead receives
106+
`AsyncDurableOrchestrationContext`, with the same v1-style argument names and
107+
awaitable durable operations. Generator orchestrators retain their existing
108+
eager task-handle behavior. See the
109+
[async authoring contract](../docs/async-orchestrators.md) before converting
110+
running orchestrations.
111+
105112
## Migrate to native orchestration APIs
106113

107114
Native orchestrators accept the durabletask context and input as separate
108-
arguments:
115+
arguments. Generator functions receive `task.OrchestrationContext`;
116+
`async def` functions receive `task.AsyncOrchestrationContext`:
109117

110118
```python
111119
from typing import Any

‎azure-functions-durable/README.md‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,30 @@ their existing page sizes.
4848
so they can be used as dictionary keys or set elements. Construct a new ID
4949
instead of assigning or deleting attributes on an existing ID.
5050

51+
## Async orchestrators
52+
53+
Both authoring styles accept `async def` and `await` without an adapter loop.
54+
Await durable operations and compose branches with `asyncio.gather` or task
55+
groups; `asyncio.sleep` and timeouts use durable timers. Synchronous generator
56+
orchestrators remain supported, including mixed-style sub-orchestrations.
57+
58+
Async orchestrators automatically receive cold coroutine operations accepted by
59+
`create_task` and task groups. Native orchestrators receive
60+
`task.AsyncOrchestrationContext`; single-argument orchestrators receive
61+
`df.AsyncDurableOrchestrationContext`, retaining v1-style argument names and
62+
`await context.task_all(...)` / `await context.task_any(...)`.
63+
`wait_for_external_event()` owns its registration and optional durable timeout,
64+
so an abandoned receive cannot consume the next event. Generator contexts
65+
retain eager, reusable durable handles; async code can access those through
66+
`context.as_task_context()` and use `handle.wait()` to create a borrowed waiter.
67+
68+
The [shared async contract](../docs/async-orchestrators.md) defines the supported
69+
API subset, cancellation, per-dispatch `finally`/async-generator cleanup,
70+
replay/migration rules, and framework adapter responsibilities. Arbitrary I/O,
71+
threads, subprocesses, `asyncio.wait`/`as_completed`, and automatic recording of
72+
ordinary async helpers are not supported. Put nondeterministic work in ordinary
73+
activities, not orchestrators.
74+
5175
## Durable HTTP failure retries
5276

5377
Opt in to durable retries for the **initial** `context.call_http()` request with
@@ -93,6 +117,7 @@ rather than returning the last selected response. Transport and other activity
93117
failures also use the policy regardless of the status list.
94118

95119
The policy and status list are snapshotted when the request is scheduled.
120+
For async orchestrators, scheduling occurs when the coroutine starts.
96121
Omitting `retry_options` preserves the existing request payload and behavior.
97122
Automatic 202/Location polling remains separate: subsequent GET polls inherit
98123
neither the failure retry policy nor its status classification. Poll HTTP

‎durabletask-azuremanaged/CHANGELOG.md‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,16 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

88
## Unreleased
99

10+
ADDED
11+
12+
- With the corresponding core SDK, `DurableTaskSchedulerWorker` accepts
13+
`async def` orchestrators with an automatically supplied async context, cold
14+
durable operations, and supported asyncio composition. Generator orchestrators
15+
retain their existing behavior. Event waits support owned cancellation and
16+
timeouts; remote operations are not cancelled by local waiter cancellation.
17+
See the [async contract](../docs/async-orchestrators.md) for dispatch cleanup
18+
and replay/upgrade precautions.
19+
1020
FIXED
1121

1222
- With the corresponding core SDK update, activity and sub-orchestration
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
# Copyright (c) Microsoft Corporation.
2+
# Licensed under the MIT License.
3+
4+
"""Exercise both workers over gRPC and optionally against the DTS emulator."""
5+
6+
import json
7+
import asyncio
8+
import os
9+
import socket
10+
from collections.abc import Generator
11+
from datetime import timedelta
12+
from typing import Any
13+
14+
import pytest
15+
16+
from durabletask import client, task, worker
17+
from durabletask.azuremanaged.client import DurableTaskSchedulerClient
18+
from durabletask.azuremanaged.worker import DurableTaskSchedulerWorker
19+
from durabletask.testing import create_test_backend
20+
21+
22+
@pytest.mark.parametrize("transport", [
23+
"core",
24+
"azuremanaged",
25+
pytest.param("dts", marks=pytest.mark.dts),
26+
])
27+
@pytest.mark.parametrize("coroutine_parent", [False, True], ids=["generator-parent", "coroutine-parent"])
28+
@pytest.mark.parametrize("borrowed", [False, True], ids=["coroutines", "borrowed-handles"])
29+
def test_mixed_orchestrators_over_grpc(transport: str, coroutine_parent: bool, borrowed: bool) -> None:
30+
def increment(ctx: task.ActivityContext, value: int) -> int:
31+
return value + 1
32+
33+
async def coroutine_child(ctx: task.AsyncOrchestrationContext, value: int) -> int:
34+
if not borrowed:
35+
await ctx.create_timer(timedelta(milliseconds=10))
36+
return await ctx.call_activity(increment, input=value, return_type=int)
37+
await asyncio.sleep(0)
38+
await asyncio.sleep(0.01)
39+
return await ctx.as_task_context().call_activity(increment, input=value, return_type=int)
40+
41+
def generator_child(ctx: task.OrchestrationContext, value: int) -> Generator[task.Task[Any], Any, int]:
42+
return (yield ctx.call_activity(increment, input=value))
43+
44+
async def coroutine(ctx: task.AsyncOrchestrationContext, value: int) -> list[Any]:
45+
if not borrowed:
46+
async with asyncio.TaskGroup() as group:
47+
first = group.create_task(ctx.call_sub_orchestrator(coroutine_child, input=value))
48+
second = group.create_task(ctx.call_sub_orchestrator(generator_child, input=value + 1))
49+
return [first.result(), second.result(), await ctx.wait_for_external_event("go")]
50+
handles = ctx.as_task_context()
51+
values = list(await asyncio.gather(
52+
handles.call_sub_orchestrator(coroutine_child, input=value),
53+
handles.call_sub_orchestrator(generator_child, input=value + 1),
54+
))
55+
event = await ctx.wait_for_external_event("go")
56+
return values + [event]
57+
58+
def generator(ctx: task.OrchestrationContext, value: int) -> Generator[task.Task[Any], Any, list[Any]]:
59+
values = yield task.when_all([
60+
ctx.call_sub_orchestrator(coroutine_child, input=value),
61+
ctx.call_sub_orchestrator(generator_child, input=value + 1),
62+
])
63+
event = yield ctx.wait_for_external_event("go")
64+
return values + [event]
65+
66+
if transport == "dts":
67+
backend = None
68+
host = os.environ["ENDPOINT"]
69+
taskhub = os.environ["TASKHUB"]
70+
else:
71+
with socket.socket() as listener:
72+
listener.bind(("127.0.0.1", 0))
73+
port = listener.getsockname()[1]
74+
backend = create_test_backend(port=port)
75+
host = f"localhost:{port}"
76+
taskhub = "test"
77+
if transport != "core":
78+
w = DurableTaskSchedulerWorker(
79+
host_address=host, secure_channel=False, taskhub=taskhub, token_credential=None)
80+
c = DurableTaskSchedulerClient(
81+
host_address=host, secure_channel=False, taskhub=taskhub, token_credential=None)
82+
else:
83+
w = worker.TaskHubGrpcWorker(host_address=host)
84+
c = client.TaskHubGrpcClient(host_address=host)
85+
try:
86+
with w, c:
87+
parent = coroutine if coroutine_parent else generator
88+
for fn in (parent, coroutine_child, generator_child):
89+
w.add_orchestrator(fn)
90+
w.add_activity(increment)
91+
w.start()
92+
instance_id = c.schedule_new_orchestration(parent, input=3)
93+
c.raise_orchestration_event(instance_id, "go", data="approved")
94+
state = c.wait_for_orchestration_completion(instance_id, timeout=15)
95+
assert state is not None
96+
assert state.runtime_status == client.OrchestrationStatus.COMPLETED, state.failure_details
97+
assert state.serialized_output is not None
98+
assert json.loads(state.serialized_output) == [4, 5, "approved"]
99+
finally:
100+
if backend is not None:
101+
backend.stop()
102+
backend.reset()

0 commit comments

Comments
 (0)