Repository navigation
Python: fix(workflow): finish in-flight checkpoint saves when a sibling step fails - #9644
ShiYuan Xiong (hehe764) wants to merge 14 commits into
Conversation
…fails A step that completed and emitted executor_completed before a sibling failed could have its per-step checkpoint save cancelled by run teardown. asyncio.gather cancels the completed step's task as soon as the sibling raises, and the failure-path task sweep cancelled the save task itself (the loop task factory tracks every task), so a storage whose save awaits I/O before writing lost the checkpoint. The latest checkpoint then held no trace of the finished step and a resumed run re-ran it and its side effects. Run the save as its own task, shield both awaits so a cancellation of the step task cannot reach it, and have the task sweep wait for in-flight saves instead of cancelling them. A completed step is now restored from the checkpoint whatever storage is used.
|
@microsoft-github-policy-service agree |
Somay kousis (Somay-kousis)
left a comment
There was a problem hiding this comment.
The fix does what it says for the sibling-failure case. It also makes a run impossible to cancel while a step checkpoint save is in flight.
What I ran (PR head c6414f1, merge base edc2427, Python 3.12, macOS)
test_functional_workflow.pyon head: 213 passed.- Same file with only
_functional.pyreverted to the merge base: 3 failed (all threetest_completed_parallel_step_is_checkpointed_when_sibling_failsparams), 210 passed. So the new test catches the bug. - ruff 0.17.0 on both changed files: clean.
Finding: cancelling the run now waits for the save, and a hung save blocks the cancel forever
_functional.py:2182-2184: when the step task is cancelled, it catches the CancelledError and awaits asyncio.shield(save_task) again. _cancel_workflow_tasks (_functional.py:1087) skips save tasks when cancelling and then waits for them with gather (:1099). Neither path tells a cancel that comes from the framework (a sibling failed) apart from a cancel that comes from the caller. So asyncio.wait_for(wf.run(...), timeout) and task cancellation from a server shutting down both get stuck behind storage.save().
Repro: a single-step workflow whose storage save() sleeps before writing. The caller uses asyncio.wait_for(wf.run(0), timeout=0.2).
| save sleeps 3 s | save never returns | |
|---|---|---|
| merge base | TimeoutError after 0.20 s | TimeoutError after 0.22 s |
| this PR | TimeoutError after 3.02 s | still blocked after 5 s guard |
A save that never returns is a realistic case: a DB or blob call with no client timeout. Before this PR, wait_for or task.cancel() was the way out. Now the run can't be stopped from outside.
Suggestion: keep the drain for cancels the framework makes, but let a cancel from the caller reach the save. One option I tried, in the except BaseException branch of _execute (_functional.py:2365), before ctx._cancel_workflow_task(workflow_task):
for save_task in list(ctx._checkpoint_save_tasks):
save_task.cancel()With that change both repros return after about 0.2 s, and the whole test file still passes (213). That's my own attempt, not a reviewed fix. The cost is that a cancel from the caller drops the in-flight checkpoint again, which is how the merge base behaves. If you'd rather keep the save, a bounded wait (for example asyncio.wait_for(asyncio.shield(save_task), timeout) in the drain) would also stop the hang.
A regression test could build asyncio.wait_for(wf.run(0), 0.2) over a storage whose save awaits an asyncio.Event that is never set, and assert that it raises TimeoutError within about 1 s.
What I did not check
- HITL pause settlement (
_settle_workflow_tasks) with a save in flight. I only traced it by reading: the save task becomes pause-owned through its parent step, so settlement should wait for it too. Not run. - Python 3.10/3.11 and other OSes. Live storage backends (Cosmos, Redis, file) were not run.
|
Addressed the caller-cancellation feedback in commit 35dd2c8.
Added 18 regression cases covering inline/task/gather storage, direct cancellation/timeouts, and cancellation during step execution, failure cleanup, and pause settlement. Before the fix, 12 cases failed and 6 existing-behavior controls passed; all 18 now pass. The original three sibling-failure checkpoint/resume cases still pass. Validation on Windows / Python 3.13.16:
The full core suite and live storage backends were not rerun. Storage adapters that suppress cancellation remain outside the cancellation guarantee. This supersedes the earlier description's statement that an in-flight save necessarily prevents caller cancellation. |
|
Follow-up on the latest cancellation review, addressed in The pause-settlement path previously handled every escaping The settlement code now records when the cancellation originated from a settled child task. Only cancellation delivered to the settlement/run task cancels checkpoint saves; child-propagated cancellation continues through framework cleanup while completed-step saves are drained normally. Caller cancellation and A deterministic regression test blocks a completed sibling's checkpoint save while another sibling requests input and a third raises |
Building the checkpoint inside the save coroutine deferred the ctx state, step cache and pending request snapshot until the scheduled task ran. A ready sibling could mutate state in that window, so a completed step's checkpoint captured work that did not complete. Split checkpoint construction into a synchronous _build_checkpoint and schedule only storage.save; add a sibling-mutation regression test.
Copilot's latest review flagged two moderate residual risks in repeated-cancellation and pause-settlement cleanup without inline details. Add regressions asserting that a double caller cancellation and a second cancellation during pause settlement still finish/cancel the in-flight checkpoint save; both pass on the current implementation.
Eduard van Valkenburg (eavanvalkenburg)
left a comment
There was a problem hiding this comment.
ShiYuan Xiong (@hehe764) Please take a look at the two inline checkpoint-lifecycle findings.
|
Eduard van Valkenburg (@eavanvalkenburg) Both checkpoint-lifecycle findings are addressed in 4695bbb; I have also replied to each inline comment with the implementation details.
Before the fix, the six teardown-cancellation cases and two nested-state cases fail; all eight pass after the fix. Local validation on the pushed change:
The full core suite and live storage backends were not rerun. The previous head's hosted CI passed; hosted CI for this new commit is a separate run. |
|
Eduard van Valkenburg (@eavanvalkenburg) I did a further cancellation/resource-lifecycle audit and pushed 99c10d8, covering two additional cleanup gaps:
Both focused regressions fail on 4695bbb and pass after this follow-up. Local verification on the pushed change:
The complete core suite and remote storage services were not rerun. Hosted CI for this new commit remains separate from these local results. |


Motivation & Context
A functional workflow can lose a completed step's in-flight checkpoint when a parallel sibling fails. Resuming then repeats the completed step and its side effects, depending on when the storage adapter awaits I/O. Fixes #9615.
Description & Review Guide
What are the major changes?
asyncio.wait_fortimeouts during step execution, failure cleanup, and pause settlement.What is the impact of these changes?
What do you want reviewers to focus on?
Validation
Recorded local results for the caller-cancellation follow-up
35dd2c8, on Windows / Python 3.13.16:python/packages/core/tests/workflow/suite: 1,300 passed, 6 skipped, 1 xfailed. The xfail is the existingtest_run_request_with_full_history_clears_service_session_id, tracking the executor-layer follow-up to Python: [Bug]: "Duplicate item found" error when using AzureAIClient with tools in workflows #3295; its marker was not added or changed by this PR.Head
d363147includes an upstream merge. A reviewer independently rechecked that head: a 3-second save no longer delays a 0.2-second caller timeout, and a hung save no longer holds the caller. This is focused reproduction evidence, not a claim that the complete suite was rerun on that merged head.The complete core suite and live storage backends were not rerun. The held upstream test workflows still require maintainer approval; no fully green hosted CI result is claimed.
Follow-up on the Copilot snapshot-race review, fixed in
1e920c3: checkpoint construction now happens synchronously at step completion (_build_checkpoint), and onlystorage.saveis scheduled, so a ready sibling cannot mutate state before the snapshot is taken. Regressiontest_step_checkpoint_snapshots_state_before_scheduling_savefails before the fix and passes after. Fulltests/workflow/suite on headb058ca2b: 1,304 passed, 6 skipped, 1 xfailed; Ruff and source Pyright are clean. Copilot's latest summary flagged two moderate residual risks in repeated-cancellation and pause-settlement cleanup without inline details. Addedtest_double_caller_cancellation_finishes_checkpoint_saveandtest_second_cancellation_during_pause_settlement_finishes_save; both pass on the existing implementation, so no code change was required.Related Issue
Fixes #9615.
The issue has an automated reproduction and the proposed implementation approach. Explicit maintainer agreement on the direction, or a
ready-for-implementationlabel, is still pending. The reviewer's cancellation verification is not presented as maintainer implementation approval.AI Assistance
Tool used: OpenAI Codex.
AI assistance covered root-cause analysis, implementation, regression tests, local verification, and drafting the PR description and review replies. The validation section distinguishes recorded local checks, independent reviewer reproductions, and checks that remain unverified.
Contribution Checklist