Skip to content

Commit 5e236c4

Browse files
andystaplesCopilot
andcommitted
Add cold async orchestration context
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent ed84f44 commit 5e236c4

8 files changed

Lines changed: 943 additions & 75 deletions

File tree

‎.github/workflows/durabletask.yml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ jobs:
3232
- name: Check asyncio, replay, and dispatch cleanup
3333
run: >-
3434
python -m pytest
35+
tests\durabletask\test_async_context.py
3536
tests\durabletask\test_asyncio_orchestrators.py
3637
tests\durabletask\test_coroutine_orchestrators.py
3738
tests\durabletask\test_orchestration_generator_cleanup.py

‎durabletask/async_context.py‎

Lines changed: 291 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,291 @@
1+
# Copyright (c) Microsoft Corporation.
2+
# Licensed under the MIT License.
3+
4+
"""Coroutine-native orchestration context backed by the durable replay engine."""
5+
6+
from __future__ import annotations
7+
8+
import math
9+
import logging
10+
from datetime import datetime, timedelta
11+
from typing import Any, TypeVar, overload
12+
13+
from durabletask.entities import EntityInstanceId, EntityLock
14+
15+
T = TypeVar("T")
16+
TInput = TypeVar("TInput")
17+
TOutput = TypeVar("TOutput")
18+
19+
20+
class AsyncOrchestrationContext:
21+
"""Context supplied automatically to ``async def`` orchestrators.
22+
23+
Durable operations return single-use native coroutines. Constructing one
24+
schedules nothing: validation, input serialization, event consumption, and
25+
relative deadline evaluation occur when it starts. Inputs are not copied
26+
at construction; mutations before execution are observed.
27+
28+
Cancelling activity, sub-orchestration, entity, or lock waiters does not
29+
cancel remote work. Timers and event receives own their private local
30+
registrations and withdraw them when their coroutine exits. This cannot
31+
undo actions already committed to history or external side effects.
32+
"""
33+
34+
def __init__(self, context: task.OrchestrationContext) -> None:
35+
self._context = context
36+
37+
@property
38+
def instance_id(self) -> str:
39+
"""Get the current orchestration instance ID."""
40+
return self._context.instance_id
41+
42+
@property
43+
def parent_instance_id(self) -> str | None:
44+
"""Get the parent orchestration instance ID, if any."""
45+
return self._context.parent_instance_id
46+
47+
@property
48+
def version(self) -> str | None:
49+
"""Get the orchestration instance version, if any."""
50+
return self._context.version
51+
52+
@property
53+
def current_utc_datetime(self) -> datetime:
54+
"""Get the replay-safe current UTC time."""
55+
return self._context.current_utc_datetime
56+
57+
@property
58+
def is_replaying(self) -> bool:
59+
"""Get whether the orchestration is replaying its history."""
60+
return self._context.is_replaying
61+
62+
def set_custom_status(self, custom_status: Any) -> None:
63+
"""Set the orchestration's custom status."""
64+
self._context.set_custom_status(custom_status)
65+
66+
def signal_entity(
67+
self, entity_id: EntityInstanceId, operation_name: str,
68+
input: Any = None, signal_time: datetime | None = None,
69+
) -> None:
70+
"""Send a one-way entity operation."""
71+
self._context.signal_entity(entity_id, operation_name, input, signal_time)
72+
73+
def send_event(self, instance_id: str, event_name: str, *, data: Any | None = None) -> None:
74+
"""Send a one-way event to another orchestration."""
75+
self._context.send_event(instance_id, event_name, data=data)
76+
77+
def continue_as_new(
78+
self, new_input: Any, *, save_events: bool = False, new_version: str | None = None,
79+
) -> None:
80+
"""Continue the orchestration with new input."""
81+
self._context.continue_as_new(new_input, save_events=save_events, new_version=new_version)
82+
83+
def new_uuid(self) -> str:
84+
"""Create a replay-safe UUID."""
85+
return self._context.new_uuid()
86+
87+
def create_replay_safe_logger(self, logger: logging.Logger) -> task.ReplaySafeLogger:
88+
"""Create a logger that suppresses messages during replay."""
89+
return self._context.create_replay_safe_logger(logger)
90+
91+
def as_task_context(self) -> task.OrchestrationContext:
92+
"""Return the eager task-handle context for advanced interoperability.
93+
94+
Its operations schedule immediately and return reusable durable handles.
95+
Await a handle directly or use ``handle.wait()`` for an independent
96+
coroutine waiter whose cancellation does not cancel the underlying work.
97+
"""
98+
return self._context
99+
100+
@overload
101+
async def call_activity(
102+
self, activity: task.Activity[TInput, TOutput] | str, *,
103+
input: TInput | None = ...,
104+
retry_policy: task.RetryPolicy | None = ...,
105+
tags: dict[str, str] | None = ...,
106+
return_type: type[T],
107+
) -> T:
108+
...
109+
110+
@overload
111+
async def call_activity(
112+
self, activity: task.Activity[TInput, TOutput], *,
113+
input: TInput | None = ...,
114+
retry_policy: task.RetryPolicy | None = ...,
115+
tags: dict[str, str] | None = ...,
116+
return_type: None = ...,
117+
) -> TOutput:
118+
...
119+
120+
@overload
121+
async def call_activity(
122+
self, activity: str, *, input: Any = ...,
123+
retry_policy: task.RetryPolicy | None = ...,
124+
tags: dict[str, str] | None = ...,
125+
return_type: None = ...,
126+
) -> Any:
127+
...
128+
129+
async def call_activity(
130+
self, activity: task.Activity[TInput, TOutput] | str, *,
131+
input: TInput | None = None,
132+
retry_policy: task.RetryPolicy | None = None,
133+
tags: dict[str, str] | None = None,
134+
return_type: type | None = None,
135+
) -> Any:
136+
"""Start and await an activity when this coroutine executes."""
137+
return await self._context.call_activity(
138+
activity, input=input, retry_policy=retry_policy, tags=tags,
139+
return_type=return_type)
140+
141+
@overload
142+
async def call_sub_orchestrator(
143+
self, orchestrator: task.Orchestrator[TInput, TOutput] | str, *,
144+
input: TInput | None = ...,
145+
instance_id: str | None = ...,
146+
retry_policy: task.RetryPolicy | None = ...,
147+
version: str | None = ...,
148+
return_type: type[T],
149+
) -> T:
150+
...
151+
152+
@overload
153+
async def call_sub_orchestrator(
154+
self, orchestrator: task.Orchestrator[TInput, TOutput], *,
155+
input: TInput | None = ...,
156+
instance_id: str | None = ...,
157+
retry_policy: task.RetryPolicy | None = ...,
158+
version: str | None = ...,
159+
return_type: None = ...,
160+
) -> TOutput:
161+
...
162+
163+
@overload
164+
async def call_sub_orchestrator(
165+
self, orchestrator: str, *, input: Any = ...,
166+
instance_id: str | None = ...,
167+
retry_policy: task.RetryPolicy | None = ...,
168+
version: str | None = ...,
169+
return_type: None = ...,
170+
) -> Any:
171+
...
172+
173+
async def call_sub_orchestrator(
174+
self, orchestrator: task.Orchestrator[TInput, TOutput] | str, *,
175+
input: TInput | None = None,
176+
instance_id: str | None = None,
177+
retry_policy: task.RetryPolicy | None = None,
178+
version: str | None = None,
179+
return_type: type | None = None,
180+
) -> Any:
181+
"""Start and await a sub-orchestration when this coroutine executes."""
182+
return await self._context.call_sub_orchestrator(
183+
orchestrator, input=input, instance_id=instance_id,
184+
retry_policy=retry_policy, version=version, return_type=return_type)
185+
186+
@overload
187+
async def call_entity(
188+
self, entity: EntityInstanceId, operation: str, input: Any = ..., *,
189+
return_type: type[T],
190+
) -> T:
191+
...
192+
193+
@overload
194+
async def call_entity(
195+
self, entity: EntityInstanceId, operation: str, input: Any = ..., *,
196+
return_type: None = ...,
197+
) -> Any:
198+
...
199+
200+
async def call_entity(
201+
self, entity: EntityInstanceId, operation: str, input: Any = None, *,
202+
return_type: type | None = None,
203+
) -> Any:
204+
"""Start and await an entity operation when this coroutine executes."""
205+
return await self._context.call_entity(
206+
entity, operation, input, return_type=return_type)
207+
208+
async def lock_entities(self, entities: list[EntityInstanceId]) -> EntityLock:
209+
"""Acquire locks, for use as ``with await ctx.lock_entities(ids):``.
210+
211+
The returned synchronous context manager releases the locks. This
212+
preserves the original context's entity locking and replay rules;
213+
cancellation does not withdraw an already-issued lock request. An
214+
abandoned request can still acquire locks, which remain held until
215+
released or the orchestration ends. Retain and shield the acquisition
216+
task if a timeout or another local cancellation might interrupt it,
217+
then await it and exit the returned lock when it is safe to do so.
218+
"""
219+
return await self._context.lock_entities(entities)
220+
221+
async def create_timer(self, fire_at: datetime | timedelta) -> None:
222+
"""Await a private durable timer, cancelling it when this wait exits.
223+
224+
Relative deadlines are evaluated when execution starts, not when this
225+
coroutine is constructed. Cancellation removes uncommitted timer work
226+
where possible; it cannot retract a timer already present in history.
227+
"""
228+
timer = self._context.create_timer(fire_at)
229+
try:
230+
await timer
231+
finally:
232+
timer.cancel()
233+
234+
@overload
235+
async def wait_for_external_event(
236+
self, name: str, *, timeout: timedelta | None = ...,
237+
data_type: type[T],
238+
) -> T:
239+
...
240+
241+
@overload
242+
async def wait_for_external_event(
243+
self, name: str, *, timeout: timedelta | None = ...,
244+
data_type: None = ...,
245+
) -> Any:
246+
...
247+
248+
async def wait_for_external_event(
249+
self, name: str, *, timeout: timedelta | None = None,
250+
data_type: type | None = None,
251+
) -> Any:
252+
"""Receive one event through a private, cancellation-owned subscription.
253+
254+
A finite relative timeout starts when the coroutine executes and raises
255+
``TimeoutError`` if its durable timer wins. Buffered events win
256+
immediately, even for zero/negative timeouts, without creating a timer.
257+
Otherwise nonpositive timeouts fail immediately without a timer.
258+
Pending races follow history order. On every exit, the subscription
259+
and any outstanding timeout timer are withdrawn locally, so a later
260+
receive cannot lose an event to this abandoned subscription.
261+
262+
For a shared subscription use ``as_task_context().wait_for_external_event()``
263+
and borrowed ``Task.wait()`` instead.
264+
"""
265+
from durabletask.task import when_any
266+
267+
if timeout is not None and not math.isfinite(timeout.total_seconds()):
268+
raise ValueError("Event receive timeouts must be finite.")
269+
event: task.CancellableTask[Any] = self._context.wait_for_external_event(name, data_type=data_type)
270+
timer: task.TimerTask | None = None
271+
try:
272+
if event.is_complete:
273+
return event.get_result()
274+
if timeout is None:
275+
return await event
276+
if timeout <= timedelta(0):
277+
raise TimeoutError(f"Timed out waiting for event {name!r}.")
278+
timer = self._context.create_timer(timeout)
279+
winner = await when_any([event, timer])
280+
if winner is timer:
281+
raise TimeoutError(f"Timed out waiting for event {name!r}.")
282+
return event.get_result()
283+
finally:
284+
event.cancel()
285+
if timer is not None:
286+
timer.cancel()
287+
288+
289+
# Resolve public annotations at runtime without importing task before this
290+
# module has defined the context that task re-exports.
291+
from durabletask import task # noqa: E402

‎durabletask/task.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from datetime import UTC, datetime, timedelta
1414
from typing import Any, Generic, TypeAlias, TypeVar, cast, overload
1515

16+
from durabletask.async_context import AsyncOrchestrationContext as AsyncOrchestrationContext
1617
from durabletask.entities import DurableEntity, EntityInstanceId, EntityLock, EntityContext
1718
import durabletask.internal.helpers as pbh
1819
import durabletask.internal.orchestrator_service_pb2 as pb
@@ -986,7 +987,7 @@ def task_id(self) -> int:
986987
]
987988

988989
AsyncOrchestrator: TypeAlias = Callable[
989-
[OrchestrationContext, TInput], Coroutine[Any, Any, TOutput],
990+
[AsyncOrchestrationContext, TInput], Coroutine[Any, Any, TOutput],
990991
]
991992

992993
Orchestrator: TypeAlias = SyncOrchestrator[TInput, TOutput] | AsyncOrchestrator[TInput, TOutput]

‎durabletask/worker.py‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2744,9 +2744,13 @@ def process_event(
27442744
input = self._data_converter.deserialize(
27452745
event.executionStarted.input.value, input_type)
27462746

2747-
# Layer 4 passes async orchestrators a cold context instead.
27482747
is_async = shared.is_async_callable(fn)
2749-
result = fn(ctx, input)
2748+
if is_async:
2749+
async_fn = cast(task.AsyncOrchestrator[Any, Any], fn)
2750+
result = async_fn(task.AsyncOrchestrationContext(ctx), input)
2751+
else:
2752+
sync_fn = cast(task.SyncOrchestrator[Any, Any], fn)
2753+
result = sync_fn(ctx, input)
27502754

27512755
if isinstance(result, CoroutineType) and not is_async:
27522756
result.close()

0 commit comments

Comments
 (0)