Skip to content

Commit 3a684c6

Browse files
andystaplesCopilot
andcommitted
Add async context parity checks and reject non-finite timer delays
Guard AsyncOrchestrationContext against public API and metadata drift from OrchestrationContext with an explicit allowlist, reject non-finite create_timer delays like event timeouts, and cover the race-winner, completion-check, and result idioms. Document them along with un-awaited operations. Remove the changelog entry for unreleased behavior. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent c01686b commit 3a684c6

5 files changed

Lines changed: 134 additions & 5 deletions

File tree

‎CHANGELOG.md‎

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

1010
FIXED
1111

12-
- Async sub-orchestration calls without an explicit `return_type` now expose
13-
`Any` rather than promising the child function's annotated result type.
14-
Custom result reconstruction still requires `return_type`; runtime decoding is
15-
unchanged.
1612
- Activity and sub-orchestration `retry_timeout` decisions now use replay-safe
1713
orchestration time, preventing retries from expiring solely because history is
1814
replayed later. Retry calculations also accept timezone-aware timestamps.

‎docs/async-orchestrators.md‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,8 @@ allocate action IDs, serialize input, or consume buffered events. Cancelling its
100100
its first step starts no durable operation. Operation inputs are not copied:
101101
mutations made before the coroutine's first step are observed, and relative
102102
timer delays start from that execution point. Keep or copy inputs accordingly.
103+
Dropping an operation without awaiting it or wrapping it in a task schedules
104+
nothing, and Python reports that the coroutine was never awaited.
103105

104106
Cold coroutines are single-use, like ordinary Python coroutines. For an
105107
explicitly started operation with multiple independent waiters, retain its
@@ -118,6 +120,27 @@ Cancelling either borrower does not cancel the underlying operation. Direct
118120
an awaitable durable handle is not a coroutine. Do not retry that expression
119121
assuming the eager operation was rolled back.
120122

123+
To identify the winner of a race, check whether an operation has completed, or
124+
read its result later, use eager handles with `task.when_any`, or wrap cold
125+
operations in `asyncio` tasks:
126+
127+
```python
128+
eager = ctx.as_task_context()
129+
approval = eager.wait_for_external_event("approval", data_type=bool)
130+
deadline = eager.create_timer(timedelta(hours=1))
131+
winner = await task.when_any([approval, deadline]) # The identical handle wins.
132+
if winner is approval:
133+
deadline.cancel()
134+
approved = approval.get_result()
135+
else:
136+
report = asyncio.create_task(ctx.call_activity("report", return_type=str))
137+
finished = report.done() # Completion check without waiting.
138+
summary = await report # Result, or the operation's failure.
139+
```
140+
141+
The losing handle stays inspectable through `is_complete` and, once complete,
142+
`get_result()`.
143+
121144
The worker classifies each orchestrator once, when it is registered:
122145

123146
1. A callable that is itself a coroutine function (including partials, bound

‎durabletask/async_context.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,8 @@ async def create_timer(self, fire_at: datetime | timedelta) -> None:
230230
coroutine is constructed. Cancellation removes uncommitted timer work
231231
where possible; it cannot retract a timer already present in history.
232232
"""
233+
if isinstance(fire_at, timedelta) and not math.isfinite(fire_at.total_seconds()):
234+
raise ValueError("Timer delays must be finite.")
233235
timer = self._context.create_timer(fire_at)
234236
try:
235237
await timer

‎tests/durabletask/test_async_context.py‎

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
from durabletask.internal import helpers
2020
from durabletask.internal import orchestrator_service_pb2 as pb
2121
from tests.durabletask.test_coroutine_orchestrators import (
22+
INSTANCE_ID,
2223
NOW,
2324
_completion, # pyright: ignore[reportPrivateUsage]
2425
_execute, # pyright: ignore[reportPrivateUsage]
@@ -32,6 +33,56 @@ def test_additive_public_api_has_no_new_abstract_requirements() -> None:
3233
assert task.AsyncOrchestrationContext is AsyncOrchestrationContext
3334

3435

36+
# Intentional differences between the contexts; anything else is drift.
37+
_ASYNC_ONLY_MEMBERS = {"as_task_context"}
38+
_EAGER_ONLY_MEMBERS: set[str] = set()
39+
_ASYNC_OPERATIONS = {
40+
"call_activity", "call_sub_orchestrator", "call_entity", "lock_entities",
41+
"create_timer", "wait_for_external_event",
42+
}
43+
_EXTRA_ASYNC_PARAMETERS = {"wait_for_external_event": {"timeout"}}
44+
45+
46+
def _public_members(cls: type) -> set[str]:
47+
return {name for name in dir(cls) if not name.startswith("_")}
48+
49+
50+
def test_async_context_public_api_matches_task_context() -> None:
51+
async_members = _public_members(AsyncOrchestrationContext)
52+
eager_members = _public_members(task.OrchestrationContext)
53+
assert async_members - eager_members == _ASYNC_ONLY_MEMBERS
54+
assert eager_members - async_members == _EAGER_ONLY_MEMBERS
55+
56+
for name in sorted(async_members & eager_members):
57+
async_member = inspect.getattr_static(AsyncOrchestrationContext, name)
58+
eager_member = inspect.getattr_static(task.OrchestrationContext, name)
59+
assert isinstance(async_member, property) == isinstance(eager_member, property), name
60+
if isinstance(async_member, property):
61+
continue
62+
assert inspect.iscoroutinefunction(async_member) == (name in _ASYNC_OPERATIONS), name
63+
async_parameters = set(inspect.signature(async_member).parameters)
64+
eager_parameters = set(inspect.signature(eager_member).parameters)
65+
assert async_parameters - eager_parameters == _EXTRA_ASYNC_PARAMETERS.get(name, set()), name
66+
assert eager_parameters <= async_parameters, name
67+
68+
69+
def test_async_context_metadata_matches_task_context() -> None:
70+
observed: list[tuple[Any, Any]] = []
71+
72+
async def orchestrator(ctx: task.AsyncOrchestrationContext, _: Any) -> None:
73+
eager = ctx.as_task_context()
74+
for name in ("instance_id", "parent_instance_id", "version", "current_utc_datetime", "is_replaying"):
75+
observed.append((getattr(ctx, name), getattr(eager, name)))
76+
await ctx.call_activity("work")
77+
observed.append((ctx.is_replaying, eager.is_replaying))
78+
79+
old = _start() + [helpers.new_task_scheduled_event(1, "work")]
80+
_execute(orchestrator, old, [helpers.new_task_completed_event(1, "null")])
81+
assert observed and all(async_value == eager_value for async_value, eager_value in observed)
82+
assert observed[0][0] == INSTANCE_ID
83+
assert observed[4][0] is True and observed[-1][0] is False
84+
85+
3586
def test_public_async_context_annotations_resolve_at_runtime() -> None:
3687
assert get_type_hints(AsyncOrchestrationContext.call_activity)["return"] is Any
3788
assert get_type_hints(AsyncOrchestrationContext.call_sub_orchestrator)["return"] is Any
@@ -348,6 +399,63 @@ async def orchestrator(ctx: task.AsyncOrchestrationContext, _: Any) -> int:
348399
assert json.loads(_completion(result).result.value) == 42
349400

350401

402+
@pytest.mark.parametrize("seconds", [float("inf"), float("-inf"), float("nan")])
403+
def test_nonfinite_timer_delay_rejected_before_scheduling(seconds: float) -> None:
404+
class InfiniteDelay(timedelta):
405+
def total_seconds(self) -> float:
406+
return seconds
407+
408+
async def orchestrator(ctx: task.AsyncOrchestrationContext, _: Any) -> str:
409+
with pytest.raises(ValueError, match="finite"):
410+
await ctx.create_timer(InfiniteDelay())
411+
return await ctx.call_activity("work", return_type=str)
412+
413+
actions = _execute(orchestrator, [], _start()).actions
414+
assert [(action.id, action.scheduleTask.name) for action in actions] == [(1, "work")]
415+
416+
417+
@pytest.mark.parametrize("approved_first", [True, False])
418+
def test_winner_completion_and_result_idioms(approved_first: bool) -> None:
419+
observed: list[Any] = []
420+
421+
async def orchestrator(ctx: task.AsyncOrchestrationContext, _: Any) -> str:
422+
eager = ctx.as_task_context()
423+
approval = eager.wait_for_external_event("approval", data_type=bool)
424+
deadline = eager.create_timer(timedelta(hours=1))
425+
winner = await task.when_any([approval, deadline])
426+
observed.append((winner is approval, approval.is_complete, deadline.is_complete))
427+
if winner is approval:
428+
deadline.cancel()
429+
return f"approved:{approval.get_result()}"
430+
431+
report = asyncio.create_task(ctx.call_activity("report", return_type=str))
432+
await asyncio.sleep(0)
433+
observed.append(report.done())
434+
return f"timed-out:{await report}"
435+
436+
deadline = NOW + timedelta(hours=1)
437+
old = _start() + [helpers.new_timer_created_event(1, deadline)]
438+
if approved_first:
439+
new = [helpers.new_event_raised_event("approval", "true")]
440+
for _ in range(2):
441+
observed.clear()
442+
complete = _completion(_execute(orchestrator, old, new))
443+
assert json.loads(complete.result.value) == "approved:True"
444+
assert observed == [(True, True, False)]
445+
return
446+
447+
fired = [helpers.new_orchestrator_started_event(deadline), helpers.new_timer_fired_event(1, deadline)]
448+
actions = _execute(orchestrator, old, fired).actions
449+
assert [action.scheduleTask.name for action in actions] == ["report"]
450+
assert observed == [(False, False, True), False]
451+
old += fired + [helpers.new_task_scheduled_event(2, "report")]
452+
for _ in range(2):
453+
observed.clear()
454+
complete = _completion(_execute(orchestrator, old, [helpers.new_task_completed_event(2, '"sent"')]))
455+
assert json.loads(complete.result.value) == "timed-out:sent"
456+
assert observed == [(False, False, True), False]
457+
458+
351459
def test_owned_timer_cancellation_does_not_cancel_borrowed_timer() -> None:
352460
async def orchestrator(ctx: task.AsyncOrchestrationContext, _: Any) -> None:
353461
borrowed = ctx.as_task_context().create_timer(timedelta(seconds=10))

‎tests/durabletask/test_asyncio_orchestrators.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -646,7 +646,7 @@ def test_untracked_task_finalized_after_dispatch_close_is_quiet(monkeypatch: pyt
646646
untracked: list[asyncio.Task[Any]] = []
647647

648648
async def orchestrator(ctx: task.OrchestrationContext, _: Any) -> None:
649-
untracked.append(asyncio.Task(ctx.call_activity("background").wait()))
649+
untracked.append(asyncio.Task(ctx.call_activity("background")))
650650
await ctx.call_activity("work")
651651

652652
_execute(orchestrator, [], _start())

0 commit comments

Comments
 (0)