Skip to content

Commit c164cb6

Browse files
andystaplesCopilot
andcommitted
Bound async-generator cleanup discovery budget
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent de57073 commit c164cb6

5 files changed

Lines changed: 89 additions & 17 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ FIXED
1313
when an intermediate async wrapper decorates a synchronous function, including
1414
callable instances and partials. Cyclic wrapper chains fail instead of hanging
1515
worker dispatch.
16+
- Async dispatch cleanup no longer hangs when async-generator finalizers
17+
continually start successor generators; exceeding the cleanup budget is reported
18+
without changing the snapshotted response.
1619
- Coroutine task-group cancellation now schedules shared-Future callbacks
1720
deterministically across replays, including when a dispatch resumes on another
1821
worker using the same SDK and Python runtime. Previously recorded cancellation

‎docs/async-orchestrators.md‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -242,7 +242,10 @@ dispatch boundary**, before workflow completion. Local cleanup can await ready
242242
work such as `sleep(0)`; it must not wait for new durable or external work.
243243
Scheduling/awaiting durable work during async teardown raises an actionable
244244
error. Teardown must cooperate with cancellation; a non-quiescent cleanup
245-
queue is bounded and reported, not allowed to run indefinitely.
245+
queue is bounded and reported, not allowed to run indefinitely. Each
246+
async-generator sweep has a step budget fixed at sweep entry; tasks or generators
247+
created during that sweep cannot increase it. This bounds cooperative scheduling
248+
steps, not wall-clock time or code that never yields.
246249

247250
Ordinary teardown failures (including `CancelledError`) are logged without
248251
changing the decided response or skipping other cooperative finalizers.

‎durabletask-azuremanaged/CHANGELOG.md‎

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

1010
FIXED
1111

12+
- With the corresponding core SDK update, async dispatch cleanup no longer
13+
hangs when async-generator finalizers continually start successor generators.
14+
Exceeding the cleanup budget is reported without changing the snapshotted response.
1215
- With the corresponding core SDK update, coroutine task-group cancellation
1316
schedules shared-Future callbacks deterministically when replayed on another
1417
worker using the same SDK and Python runtime. See the

‎durabletask/internal/asyncio_loop.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -389,12 +389,13 @@ def _close_asyncgens(self, closed: dict[int, Any]) -> None:
389389
# Closing one generator may first-iterate another; sweep until no new generators appear.
390390
# Closed generators stay referenced so their ids cannot be reused during cleanup.
391391
steps = 0
392+
# Cleanup-created tasks and generators must not replenish the sweep's budget.
393+
limit = max(10000, 4 * (len(self._tasks) + len(self._asyncgens)))
392394
while True:
393395
pending = [
394396
generator for key, generator in self._asyncgens.items() if closed.get(key) is not generator]
395397
if not pending:
396398
return
397-
limit = max(10000, 4 * (len(self._tasks) + len(self._asyncgens)))
398399
if steps >= limit:
399400
self._record_cleanup_error(RuntimeError(
400401
"Async cleanup did not quiesce; cleanup must cooperate with cancellation."))

‎tests/durabletask/test_asyncio_orchestrators.py‎

Lines changed: 77 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1101,32 +1101,91 @@ async def branch(index: int) -> None:
11011101
assert cleaned == [0, 1]
11021102

11031103

1104+
@pytest.mark.parametrize("count", [2, 32, 6000])
11041105
def test_cleanup_closes_async_generators_started_during_generator_cleanup(
1105-
caplog: pytest.LogCaptureFixture) -> None:
1106-
closed: list[str] = []
1106+
count: int, caplog: pytest.LogCaptureFixture) -> None:
1107+
closed: list[int] = []
11071108

11081109
async def orchestrator(ctx: task.OrchestrationContext, _: Any) -> None:
1109-
async def child() -> AsyncIterator[int]:
1110+
async def resource(index: int) -> AsyncIterator[int]:
11101111
try:
11111112
yield 1
11121113
finally:
1114+
if index + 1 < count:
1115+
await anext(resource(index + 1))
11131116
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")
1117+
closed.append(index)
11221118

1123-
await anext(outer())
1119+
await anext(resource(0))
11241120
await ctx.call_activity("wait")
11251121

11261122
actions = _execute(orchestrator, [], _start()).actions
11271123
assert [action.scheduleTask.name for action in actions] == ["wait"]
1128-
assert closed == ["outer", "child"]
1129-
assert "Async" not in caplog.text
1124+
assert closed == list(range(count))
1125+
if count == 6000:
1126+
assert "Async cleanup did not quiesce" in caplog.text
1127+
else:
1128+
assert "Async" not in caplog.text
1129+
1130+
1131+
@pytest.mark.parametrize("control", [None, "system_exit", "keyboard_interrupt", "nested_group"])
1132+
def test_infinite_asyncgen_discovery_has_bounded_cleanup(control: str | None) -> None:
1133+
code = """
1134+
import asyncio
1135+
import logging
1136+
import sys
1137+
from durabletask import task
1138+
from tests.durabletask.test_asyncio_orchestrators import _cleanup_signal
1139+
from tests.durabletask.test_coroutine_orchestrators import _execute, _start
1140+
1141+
control = sys.argv[1]
1142+
exit_error = None if control == "none" else _cleanup_signal(control)
1143+
warnings = []
1144+
1145+
class Capture(logging.Handler):
1146+
def emit(self, record):
1147+
warnings.append(logging.Formatter().format(record))
1148+
1149+
logging.getLogger().addHandler(Capture())
1150+
1151+
async def orchestrator(ctx: task.OrchestrationContext, _):
1152+
async def resource():
1153+
try:
1154+
yield 1
1155+
finally:
1156+
await anext(resource())
1157+
1158+
async def signal_on_forced_close():
1159+
try:
1160+
await ctx.wait_for_external_event("never")
1161+
finally:
1162+
try:
1163+
while True:
1164+
try:
1165+
await asyncio.Future()
1166+
except asyncio.CancelledError:
1167+
pass
1168+
finally:
1169+
raise exit_error
1170+
1171+
if exit_error is not None:
1172+
asyncio.create_task(signal_on_forced_close())
1173+
await anext(resource())
1174+
await ctx.call_activity("wait")
1175+
1176+
try:
1177+
result = _execute(orchestrator, [], _start())
1178+
except BaseException as error:
1179+
assert exit_error is not None and error is exit_error, repr(error)
1180+
else:
1181+
assert exit_error is None
1182+
assert [action.scheduleTask.name for action in result.actions] == ["wait"]
1183+
assert any("Async cleanup did not quiesce" in message for message in warnings), warnings
1184+
"""
1185+
process = subprocess.run(
1186+
[sys.executable, "-c", code, control or "none"],
1187+
capture_output=True, text=True, timeout=30)
1188+
assert process.returncode == 0, process.stdout + process.stderr
11301189

11311190

11321191
def test_exhausted_async_generators_are_not_retained_until_dispatch_end() -> None:
@@ -1146,7 +1205,10 @@ async def values() -> AsyncIterator[int]:
11461205
assert sum(reference() is not None for reference in references) < 100
11471206
await ctx.call_activity("wait")
11481207

1149-
_execute(orchestrator, [], _start())
1208+
actions = _execute(orchestrator, [], _start()).actions
1209+
assert len(actions) == 1
1210+
assert actions[0].HasField("scheduleTask")
1211+
assert actions[0].scheduleTask.name == "wait"
11501212

11511213

11521214
@pytest.mark.parametrize("suspend", ["activity", "entity"])

0 commit comments

Comments
 (0)