Skip to content

Commit 4b076ba

Browse files
fix(worker): close orchestrator generators when a dispatch ends (#300)
Give each replay dispatch explicit ownership of its orchestrator generator: close it once in an outer finally after the response is built, so cleanup in finally blocks runs deterministically and cannot alter the returned actions or custom status. Ordinary teardown failures are logged without replacing dispatch results or abandonment. Async bridges such as Pydantic AI otherwise retain AnyIO task scopes, event loops, and orchestration contexts indefinitely. Also relevant to the async/await work in #293 and #294. Fixes #299 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 35371373-86cf-4897-bf7c-460fa3c88d1f
1 parent cb75760 commit 4b076ba

6 files changed

Lines changed: 458 additions & 52 deletions

File tree

‎CHANGELOG.md‎

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

88
## Unreleased
99

10+
FIXED
11+
12+
- Orchestrator generators are now closed when a dispatch ends, so cleanup in
13+
`finally` blocks runs deterministically, including for async bridges that would
14+
otherwise retain tasks, event loops, and orchestration contexts.
15+
1016
## v1.12.0
1117

1218
ADDED

‎azure-functions-durable/CHANGELOG.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,12 @@ CHANGED
1919

2020
- Updated the minimum `azure-functions` dependency to v2.4.0b1.
2121

22+
FIXED
23+
24+
- With the corresponding core `durabletask` SDK fix, orchestrator generators
25+
are closed when a dispatch ends, so cleanup in `finally` blocks runs
26+
deterministically, including for async bridges.
27+
2228
## v2.0.0rc3
2329

2430
ADDED

‎durabletask-azuremanaged/CHANGELOG.md‎

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

88
## Unreleased
99

10+
FIXED
11+
12+
- With the corresponding core `durabletask` SDK fix, orchestrator generators
13+
are closed when a dispatch ends, so cleanup in `finally` blocks runs
14+
deterministically, including for async bridges.
15+
1016
## v1.12.0
1117

1218
ADDED

‎durabletask/worker.py‎

Lines changed: 68 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -2289,64 +2289,80 @@ def execute(
22892289
logger=self._logger,
22902290
)
22912291
try:
2292-
# Rebuild local state by replaying old history into the orchestrator function
2293-
self._logger.debug(
2294-
f"{instance_id}: Rebuilding local state with {len(old_events)} history event..."
2295-
)
2296-
ctx._is_replaying = True # pyright: ignore[reportPrivateUsage]
2297-
for old_event in old_events:
2298-
self.process_event(ctx, old_event)
2299-
2300-
# Get new actions by executing newly received events into the orchestrator function
2301-
if self._logger.level <= logging.DEBUG:
2302-
summary = _get_new_event_summary(new_events)
2292+
try:
2293+
# Rebuild local state by replaying old history into the orchestrator function
23032294
self._logger.debug(
2304-
f"{instance_id}: Processing {len(new_events)} new event(s): {summary}"
2295+
f"{instance_id}: Rebuilding local state with {len(old_events)} history event..."
23052296
)
2306-
ctx._is_replaying = False # pyright: ignore[reportPrivateUsage]
2307-
for new_event in new_events:
2308-
self.process_event(ctx, new_event)
2309-
2310-
except pe.VersionFailureException as ex:
2311-
if self._registry.versioning and self._registry.versioning.failure_strategy == VersionFailureStrategy.FAIL:
2312-
if ex.error_details:
2313-
ctx.set_failed(ex.error_details)
2314-
else:
2315-
ctx.set_failed(ex)
2316-
elif self._registry.versioning and self._registry.versioning.failure_strategy == VersionFailureStrategy.REJECT:
2317-
raise pe.AbandonOrchestrationError
2297+
ctx._is_replaying = True # pyright: ignore[reportPrivateUsage]
2298+
for old_event in old_events:
2299+
self.process_event(ctx, old_event)
2300+
2301+
# Get new actions by executing newly received events into the orchestrator function
2302+
if self._logger.level <= logging.DEBUG:
2303+
summary = _get_new_event_summary(new_events)
2304+
self._logger.debug(
2305+
f"{instance_id}: Processing {len(new_events)} new event(s): {summary}"
2306+
)
2307+
ctx._is_replaying = False # pyright: ignore[reportPrivateUsage]
2308+
for new_event in new_events:
2309+
self.process_event(ctx, new_event)
2310+
2311+
except pe.VersionFailureException as ex:
2312+
if self._registry.versioning and self._registry.versioning.failure_strategy == VersionFailureStrategy.FAIL:
2313+
if ex.error_details:
2314+
ctx.set_failed(ex.error_details)
2315+
else:
2316+
ctx.set_failed(ex)
2317+
elif self._registry.versioning and self._registry.versioning.failure_strategy == VersionFailureStrategy.REJECT:
2318+
raise pe.AbandonOrchestrationError
23182319

2319-
except Exception as ex:
2320-
# Unhandled exceptions fail the orchestration
2321-
self._logger.debug(f"{instance_id}: Orchestration {orchestration_name} failed")
2322-
ctx.set_failed(ex)
2320+
except Exception as ex:
2321+
# Unhandled exceptions fail the orchestration
2322+
self._logger.debug(f"{instance_id}: Orchestration {orchestration_name} failed")
2323+
ctx.set_failed(ex)
23232324

2324-
if not ctx._is_complete: # pyright: ignore[reportPrivateUsage]
2325-
task_count = len(ctx._pending_tasks) # pyright: ignore[reportPrivateUsage]
2326-
event_count = len(ctx._pending_events) # pyright: ignore[reportPrivateUsage]
2327-
self._logger.info(
2328-
f"{instance_id}: Orchestrator {orchestration_name} yielded with {task_count} task(s) "
2329-
f"and {event_count} event(s) outstanding."
2330-
)
2331-
elif (
2332-
ctx._completion_status and ctx._completion_status is not pb.ORCHESTRATION_STATUS_CONTINUED_AS_NEW # pyright: ignore[reportPrivateUsage]
2333-
):
2334-
completion_status_str = ph.get_orchestration_status_str(
2335-
ctx._completion_status # pyright: ignore[reportPrivateUsage]
2336-
)
2337-
self._logger.info(
2338-
f"{instance_id}: Orchestration {orchestration_name} completed with status: {completion_status_str}"
2339-
)
2325+
if not ctx._is_complete: # pyright: ignore[reportPrivateUsage]
2326+
task_count = len(ctx._pending_tasks) # pyright: ignore[reportPrivateUsage]
2327+
event_count = len(ctx._pending_events) # pyright: ignore[reportPrivateUsage]
2328+
self._logger.info(
2329+
f"{instance_id}: Orchestrator {orchestration_name} yielded with {task_count} task(s) "
2330+
f"and {event_count} event(s) outstanding."
2331+
)
2332+
elif (
2333+
ctx._completion_status and ctx._completion_status is not pb.ORCHESTRATION_STATUS_CONTINUED_AS_NEW # pyright: ignore[reportPrivateUsage]
2334+
):
2335+
completion_status_str = ph.get_orchestration_status_str(
2336+
ctx._completion_status # pyright: ignore[reportPrivateUsage]
2337+
)
2338+
self._logger.info(
2339+
f"{instance_id}: Orchestration {orchestration_name} completed with status: {completion_status_str}"
2340+
)
23402341

2341-
actions = ctx.get_actions()
2342-
if self._logger.level <= logging.DEBUG:
2343-
self._logger.debug(
2344-
f"{instance_id}: Returning {len(actions)} action(s): {_get_action_summary(actions)}"
2342+
actions = ctx.get_actions()
2343+
if self._logger.level <= logging.DEBUG:
2344+
self._logger.debug(
2345+
f"{instance_id}: Returning {len(actions)} action(s): {_get_action_summary(actions)}"
2346+
)
2347+
return ExecutionResults(
2348+
actions=actions, encoded_custom_status=ctx._encoded_custom_status, # pyright: ignore[reportPrivateUsage]
2349+
orchestration_trace_context=ctx._orchestration_trace_context, # pyright: ignore[reportPrivateUsage]
23452350
)
2346-
return ExecutionResults(
2347-
actions=actions, encoded_custom_status=ctx._encoded_custom_status, # pyright: ignore[reportPrivateUsage]
2348-
orchestration_trace_context=ctx._orchestration_trace_context, # pyright: ignore[reportPrivateUsage]
2349-
)
2351+
finally:
2352+
# Close after building the response, so the orchestrator's finally
2353+
# blocks do not change the actions or custom status we return.
2354+
if ctx._generator is not None: # pyright: ignore[reportPrivateUsage]
2355+
try:
2356+
ctx._generator.close() # pyright: ignore[reportPrivateUsage]
2357+
except Exception:
2358+
cleanup_name = next(
2359+
(e.executionStarted.name for e in new_events if e.HasField("executionStarted")),
2360+
orchestration_name,
2361+
)
2362+
self._logger.warning(
2363+
f"{instance_id}: Failed to close orchestrator {cleanup_name} generator.",
2364+
exc_info=True,
2365+
)
23502366

23512367
def _build_rewind_result(
23522368
self,

‎requirements.txt‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ grpcio>=1.60.0 # 1.60.0 is the version introducing protobuf 1.25.X support, newe
33
protobuf
44
pytest
55
pytest-asyncio
6+
anyio
67
pytest-cov
78
azure-identity
89
azure-functions

0 commit comments

Comments
 (0)