Skip to content

Commit 466196e

Browse files
andystaplesCopilot
andcommitted
Harden async dispatch cleanup ownership and signal precedence
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent 0a1d469 commit 466196e

4 files changed

Lines changed: 183 additions & 7 deletions

File tree

‎docs/async-orchestrators.md‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -245,13 +245,20 @@ Unexpected non-cancellation `BaseException`
245245
exits retain the existing terminal-failure/recovery policy; an existing primary
246246
failure is preserved. Teardown status/actions do not replace the response.
247247
Held entity-release obligations are captured before teardown for terminal
248-
payload/failure handling.
248+
payload/failure handling. Exiting an entity critical section during teardown
249+
does not schedule additional release messages, and a `SystemExit` or
250+
`KeyboardInterrupt` raised by any finalizer takes precedence over other
251+
cleanup failures.
249252

250253
Keep workflow cleanup that must be durable in **normal orchestration control
251254
flow**, before return or continue-as-new, not solely in a dispatch finalizer.
252255
No cleanup is guaranteed after process crash or forced termination.
253256
Async-generator *helpers* are closed; async-generator *orchestrators*
254-
(`async def` containing `yield`) are rejected.
257+
(`async def` containing `yield`) are rejected. The dispatch owns every async
258+
generator it starts until teardown, including generators first iterated by
259+
another generator's cleanup. To run a generator's cleanup earlier, close it
260+
explicitly with `await gen.aclose()` or iterate it inside
261+
`contextlib.aclosing(...)`.
255262

256263
## Replay and framework adapters
257264

‎durabletask/internal/asyncio_loop.py‎

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,13 @@
2525
_MICROSECOND = timedelta(microseconds=1)
2626

2727

28+
def _is_process_control(error: BaseException | None) -> bool:
29+
if isinstance(error, BaseExceptionGroup):
30+
group = cast(BaseExceptionGroup[BaseException], error)
31+
return group.subgroup((KeyboardInterrupt, SystemExit)) is not None
32+
return isinstance(error, (KeyboardInterrupt, SystemExit))
33+
34+
2835
class _CompletionObserver(task.CompositeTask[None]):
2936
def __init__(self, durable: task.Task[Any], callback: Callable[[], None]) -> None:
3037
self._callback = callback
@@ -133,6 +140,7 @@ def __init__(
133140
self._completions: deque[Callable[[], None]] = deque()
134141
self._timers: dict[_DurableTimerHandle, _CompletionObserver] = {}
135142
self._asyncgens: dict[int, Any] = {}
143+
self._asyncgen_prune_at = 64
136144
self._closed = False
137145
self._closing = False
138146
self._fatal: BaseException | None = None
@@ -301,7 +309,7 @@ def resume(self) -> None:
301309
def _has_durable_work(self) -> bool:
302310
return any(not durable.is_complete for durable, _ in self._waits.values()) or bool(self._timers)
303311

304-
def _drain(self) -> None:
312+
def _drain(self) -> int:
305313
previous_loop = asyncio._get_running_loop()
306314
previous_task = asyncio.current_task(previous_loop) if previous_loop is not None else None
307315
previous_hooks = sys.get_asyncgen_hooks()
@@ -342,13 +350,19 @@ def _drain(self) -> None:
342350
self._ready.clear()
343351
self._cancel_ready.clear()
344352
break
353+
return steps
345354
finally:
346355
sys.set_asyncgen_hooks(*previous_hooks)
347356
asyncio._set_running_loop(previous_loop)
348357
if previous_task is not None and previous_loop is not None:
349358
asyncio.tasks._enter_task(previous_loop, previous_task)
350359

351360
def _asyncgen_firstiter(self, generator: Any) -> None:
361+
if len(self._asyncgens) >= self._asyncgen_prune_at:
362+
# Exhausted generators have no cleanup left; amortized pruning bounds retention.
363+
self._asyncgens = {
364+
key: value for key, value in self._asyncgens.items() if value.ag_frame is not None}
365+
self._asyncgen_prune_at = max(64, 2 * len(self._asyncgens))
352366
self._asyncgens[id(generator)] = generator
353367

354368
def _asyncgen_finalizer(self, generator: Any) -> None:
@@ -366,9 +380,30 @@ def call_exception_handler(self, context: dict[str, Any]) -> None:
366380
def _record_cleanup_error(self, error: BaseException, source: str = "cleanup callback") -> None:
367381
self._logger.warning("%s: Async %s failed.", self.ctx.instance_id, source, exc_info=error)
368382
if not isinstance(error, (Exception, asyncio.CancelledError)):
383+
if _is_process_control(self._cleanup_failure):
384+
return
369385
if self._cleanup_failure is None or is_control_flow_exception(error):
370386
self._cleanup_failure = error
371387

388+
def _close_asyncgens(self, closed: dict[int, Any]) -> None:
389+
# Closing one generator may first-iterate another; sweep until no new generators appear.
390+
# Closed generators stay referenced so their ids cannot be reused during cleanup.
391+
steps = 0
392+
while True:
393+
pending = [
394+
generator for key, generator in self._asyncgens.items() if closed.get(key) is not generator]
395+
if not pending:
396+
return
397+
limit = max(10000, 4 * (len(self._tasks) + len(self._asyncgens)))
398+
if steps >= limit:
399+
self._record_cleanup_error(RuntimeError(
400+
"Async cleanup did not quiesce; cleanup must cooperate with cancellation."))
401+
return
402+
for generator in pending:
403+
closed[id(generator)] = generator
404+
self.create_task(generator.aclose())
405+
steps += max(1, self._drain())
406+
372407
def close(self) -> None:
373408
self._closing = True
374409
try:
@@ -379,13 +414,13 @@ def close(self) -> None:
379414
if not future.done():
380415
future.cancel()
381416
self._drain()
382-
for generator in tuple(self._asyncgens.values()):
383-
self.create_task(generator.aclose())
384-
self._drain()
417+
closed_asyncgens: dict[int, Any] = {}
418+
self._close_asyncgens(closed_asyncgens)
385419
for future in tuple(self._tasks):
386420
if not future.done():
387421
future.cancel()
388422
self._drain()
423+
self._close_asyncgens(closed_asyncgens)
389424
failure: BaseException | None = self._cleanup_failure
390425
for future in tuple(self._tasks):
391426
if not future.done():

‎durabletask/worker.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2234,6 +2234,10 @@ def _exit_critical_section(self) -> None:
22342234
# _exit_critical_section both from the EntityLock and the continue_as_new logic. We must keep both calls in
22352235
# case the user code crashes after calling continue_as_new but before the EntityLock object is exited.
22362236
return
2237+
if self._async_cleanup:
2238+
# Lock disposal during local teardown is not a durable release; the
2239+
# pre-teardown response already carries any release this dispatch owns.
2240+
return
22372241
for entity_unlock_message in self._entity_context.emit_lock_release_messages():
22382242
task_id = self.next_sequence_number()
22392243
action = pb.OrchestratorAction(id=task_id, sendEntityMessage=entity_unlock_message)

‎tests/durabletask/test_asyncio_orchestrators.py‎

Lines changed: 131 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import os
1010
import subprocess
1111
import sys
12+
from collections.abc import AsyncIterator
1213
from contextlib import asynccontextmanager
1314
from contextvars import ContextVar
1415
from dataclasses import dataclass
@@ -20,7 +21,7 @@
2021

2122
import pytest
2223

23-
from durabletask import task
24+
from durabletask import entities, task
2425
from durabletask.internal import helpers
2526
from durabletask.internal import orchestrator_service_pb2 as pb
2627
from tests.durabletask.test_coroutine_orchestrators import (
@@ -1057,3 +1058,132 @@ async def branch(name: str, error: BaseException | None) -> None:
10571058
_execute(orchestrator, [], _start())
10581059
assert caught.value is exit_error
10591060
assert cleaned == ["custom", "exit", "last"]
1061+
1062+
1063+
def _cleanup_signal(kind: str) -> BaseException:
1064+
if kind == "system_exit":
1065+
return SystemExit("stop")
1066+
if kind == "keyboard_interrupt":
1067+
return KeyboardInterrupt("interrupt")
1068+
if kind == "nested_group":
1069+
return BaseExceptionGroup("outer", [BaseExceptionGroup("inner", [SystemExit("stop")])])
1070+
if kind == "generator_exit":
1071+
return GeneratorExit("closed")
1072+
return BaseExceptionGroup("cancelled", [asyncio.CancelledError()])
1073+
1074+
1075+
@pytest.mark.parametrize("control_first", [True, False])
1076+
@pytest.mark.parametrize("other", ["generator_exit", "cancelled_group"])
1077+
@pytest.mark.parametrize("control", ["system_exit", "keyboard_interrupt", "nested_group"])
1078+
def test_cleanup_keeps_process_control_over_other_control_flow(
1079+
control: str, other: str, control_first: bool) -> None:
1080+
control_error = _cleanup_signal(control)
1081+
errors = [control_error, _cleanup_signal(other)]
1082+
if not control_first:
1083+
errors.reverse()
1084+
cleaned: list[int] = []
1085+
1086+
async def orchestrator(ctx: task.OrchestrationContext, _: Any) -> None:
1087+
async def branch(index: int) -> None:
1088+
try:
1089+
await ctx.wait_for_external_event(f"never-{index}")
1090+
finally:
1091+
cleaned.append(index)
1092+
raise errors[index]
1093+
1094+
asyncio.create_task(branch(0))
1095+
asyncio.create_task(branch(1))
1096+
await ctx.call_activity("wait")
1097+
1098+
with pytest.raises(type(control_error)) as caught:
1099+
_execute(orchestrator, [], _start())
1100+
assert caught.value is control_error
1101+
assert cleaned == [0, 1]
1102+
1103+
1104+
def test_cleanup_closes_async_generators_started_during_generator_cleanup(
1105+
caplog: pytest.LogCaptureFixture) -> None:
1106+
closed: list[str] = []
1107+
1108+
async def orchestrator(ctx: task.OrchestrationContext, _: Any) -> None:
1109+
async def child() -> AsyncIterator[int]:
1110+
try:
1111+
yield 1
1112+
finally:
1113+
await asyncio.sleep(0)
1114+
closed.append("child")
1115+
1116+
async def outer() -> AsyncIterator[int]:
1117+
try:
1118+
yield 1
1119+
finally:
1120+
await anext(child())
1121+
closed.append("outer")
1122+
1123+
await anext(outer())
1124+
await ctx.call_activity("wait")
1125+
1126+
actions = _execute(orchestrator, [], _start()).actions
1127+
assert [action.scheduleTask.name for action in actions] == ["wait"]
1128+
assert closed == ["outer", "child"]
1129+
assert "Async" not in caplog.text
1130+
1131+
1132+
def test_exhausted_async_generators_are_not_retained_until_dispatch_end() -> None:
1133+
references: list[ReferenceType[Any]] = []
1134+
1135+
async def orchestrator(ctx: task.OrchestrationContext, _: Any) -> None:
1136+
async def values() -> AsyncIterator[int]:
1137+
yield 1
1138+
1139+
for _ in range(200):
1140+
generator = values()
1141+
references.append(ref(generator))
1142+
async for _ in generator:
1143+
pass
1144+
del generator
1145+
gc.collect()
1146+
assert sum(reference() is not None for reference in references) < 100
1147+
await ctx.call_activity("wait")
1148+
1149+
_execute(orchestrator, [], _start())
1150+
1151+
1152+
@pytest.mark.parametrize("suspend", ["activity", "entity"])
1153+
def test_entity_lock_disposal_during_teardown_does_not_schedule_releases(
1154+
suspend: str, caplog: pytest.LogCaptureFixture) -> None:
1155+
first = entities.EntityInstanceId("counter", "a")
1156+
second = entities.EntityInstanceId("counter", "b")
1157+
1158+
async def orchestrator(ctx: task.OrchestrationContext, _: Any) -> Any:
1159+
with await ctx.lock_entities([first, second]):
1160+
if suspend == "activity":
1161+
return await ctx.call_activity("inside-lock")
1162+
return await ctx.call_entity(first, "get")
1163+
1164+
old = _start()
1165+
lock = _execute(orchestrator, [], old).actions[0].sendEntityMessage.entityLockRequested
1166+
old += [helpers.new_event_sent_event(1, str(first), json.dumps({"id": lock.criticalSectionId}))]
1167+
new = [helpers.new_event_raised_event(lock.criticalSectionId, None)]
1168+
actions = _execute(orchestrator, old, new).actions
1169+
assert len(actions) == 1
1170+
if suspend == "activity":
1171+
assert actions[0].scheduleTask.name == "inside-lock"
1172+
old += new + [helpers.new_task_scheduled_event(2, "inside-lock")]
1173+
new = [helpers.new_task_completed_event(2, json.dumps(42))]
1174+
else:
1175+
call = actions[0].sendEntityMessage.entityOperationCalled
1176+
assert call.targetInstanceId.value == str(first)
1177+
old += new + [helpers.new_event_sent_event(2, str(first), json.dumps({"id": call.requestId}))]
1178+
new = [helpers.new_event_raised_event(call.requestId, json.dumps({"result": "42"}))]
1179+
assert "cleanup" not in caplog.text
1180+
1181+
result = _execute(orchestrator, old, new)
1182+
actions = result.actions
1183+
releases = [action.sendEntityMessage.entityUnlockSent for action in actions[:-1]]
1184+
# Entity calls inside a critical section keep the legacy shared-list release order.
1185+
expected = [first, second] if suspend == "activity" else [second, first]
1186+
assert [release.targetInstanceId.value for release in releases] == [str(e) for e in expected]
1187+
assert [action.id for action in actions] == [3, 4, 5]
1188+
assert json.loads(_completion(result).result.value) == 42
1189+
assert "cleanup" not in caplog.text

0 commit comments

Comments
 (0)