Skip to content

Commit d769d68

Browse files
andystaplesberndverst
authored andcommitted
fix(worker): retain pending releases for unexpected terminal failures
Reuse the existing failed-result conversion after the full pre-close snapshot; preserve historical normal timeout actions and original failure details. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 974213a5-9101-41b4-9bde-aa86a72f25e1
1 parent fbeb42b commit d769d68

2 files changed

Lines changed: 57 additions & 0 deletions

File tree

‎durabletask/worker.py‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2396,6 +2396,7 @@ def execute(
23962396
)
23972397
result: ExecutionResults | None = None
23982398
dispatch_exited = False
2399+
unexpected_failure = False
23992400
try:
24002401
try:
24012402
# Rebuild local state by replaying old history into the orchestrator function
@@ -2437,6 +2438,7 @@ def execute(
24372438
f"{instance_id}: Orchestrator {orchestration_name} exited unexpectedly.",
24382439
exc_info=True,
24392440
)
2441+
unexpected_failure = not ctx._is_complete # pyright: ignore[reportPrivateUsage]
24402442
ctx.set_failed(ex)
24412443

24422444
if not ctx._is_complete: # pyright: ignore[reportPrivateUsage]
@@ -2467,6 +2469,13 @@ def execute(
24672469
# Snapshot before generator teardown can exit a critical section.
24682470
entity_lock_releases=list(ctx._entity_context.get_lock_release_messages()), # pyright: ignore[reportPrivateUsage]
24692471
)
2472+
if unexpected_failure and result.entity_lock_releases:
2473+
failure_details = next(
2474+
action.completeOrchestration.failureDetails for action in result.actions
2475+
if action.HasField("completeOrchestration")
2476+
and action.completeOrchestration.orchestrationStatus == pb.ORCHESTRATION_STATUS_FAILED
2477+
)
2478+
result = result.as_failed(failure_details)
24702479
except BaseException:
24712480
dispatch_exited = True
24722481
raise

‎tests/durabletask/test_orchestration_dispatch_recovery.py‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,54 @@ def orchestrator(ctx: task.OrchestrationContext, _: Any) -> Generator[task.Task[
190190
assert completion.failureDetails.errorMessage == str(error)
191191

192192

193+
@pytest.mark.parametrize("fail_during_teardown", [False, True])
194+
def test_terminal_failure_after_pending_call_timeout_preserves_missing_release(fail_during_teardown: bool):
195+
entity_a = EntityInstanceId("Counter", "a")
196+
entity_b = EntityInstanceId("Counter", "b")
197+
error = TeardownExit("terminal failure after pending call timeout")
198+
199+
def orchestrator(ctx: task.OrchestrationContext, _: Any) -> Generator[task.Task[Any], Any, Any]:
200+
after_timeout = False
201+
try:
202+
with (yield ctx.lock_entities([entity_a, entity_b])):
203+
call = ctx.call_entity(entity_a, "get")
204+
yield task.when_any([call, ctx.wait_for_external_event("timeout")])
205+
after_timeout = True
206+
yield ctx.call_activity("after_timeout")
207+
if fail_during_teardown:
208+
yield ctx.wait_for_external_event("never")
209+
return _FailingOutput(error)
210+
finally:
211+
if fail_during_teardown and after_timeout:
212+
raise error
213+
214+
executor = _executor(orchestrator)
215+
started = list(_request().newEvents)
216+
initial = executor.execute(INSTANCE_ID, [], started)
217+
lock_id = initial.actions[0].sendEntityMessage.entityLockRequested.criticalSectionId
218+
granted = [
219+
helpers.new_event_sent_event(1, str(entity_a), json.dumps({"id": lock_id})),
220+
helpers.new_event_raised_event(lock_id, None),
221+
]
222+
calling = executor.execute(INSTANCE_ID, started, granted)
223+
call_id = calling.actions[0].sendEntityMessage.entityOperationCalled.requestId
224+
history = started + granted + [
225+
helpers.new_event_sent_event(2, str(entity_a), json.dumps({"id": call_id})),
226+
helpers.new_event_raised_event("timeout", None),
227+
helpers.new_event_sent_event(3, str(entity_b), None),
228+
helpers.new_task_scheduled_event(4, "after_timeout"),
229+
]
230+
result = executor.execute(INSTANCE_ID, history, [helpers.new_task_completed_event(4, '"done"')])
231+
232+
assert len(result.actions) == 2
233+
release = result.actions[0].sendEntityMessage.entityUnlockSent
234+
assert release.targetInstanceId.value == str(entity_a)
235+
assert release.criticalSectionId == lock_id
236+
completion = result.actions[1].completeOrchestration
237+
assert completion.orchestrationStatus == pb.ORCHESTRATION_STATUS_FAILED
238+
assert completion.failureDetails.errorMessage == str(error)
239+
240+
193241
@pytest.mark.parametrize("during_serialization", [False, True])
194242
def test_unexpected_exit_commits_failure_and_allows_next_dispatch(during_serialization: bool):
195243
def orchestrator(ctx: task.OrchestrationContext, _: Any) -> Generator[task.Task[Any], Any, None]:

0 commit comments

Comments
 (0)