Python: fix: make concurrent FileCheckpointStorage saves not race on a shared temp path - #7757
Conversation
There was a problem hiding this comment.
Pull request overview
Note
Copilot was unable to run its full agentic suite in this review.
This PR addresses a concurrency race in FileCheckpointStorage.save() when multiple saves target the same checkpoint ID, and adds a regression test to ensure concurrent saves don’t fail.
Changes:
- Added a concurrency regression test covering concurrent saves with the same checkpoint ID.
- Updated
FileCheckpointStorage.save()to use unique temp files and added per-checkpoint-ID serialization plus a retry loop aroundos.replace()for Windows. - Implemented best-effort cleanup for temp files when failures occur.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
| python/packages/core/tests/workflow/test_checkpoint.py | Adds a regression test that exercises concurrent save() calls for the same checkpoint ID. |
| python/packages/core/agent_framework/_workflows/_checkpoint.py | Makes save() more robust under concurrency by changing temp-file strategy, adding per-ID locking, and retrying replace on Windows. |
💡 Add a code-review agent skill for context-aware, tailored reviews. Learn more in the docs.
a32ff36 to
fd0a15a
Compare
|
/review |
There was a problem hiding this comment.
MAF Automated Review — Iteration 1
Result: Findings reported
Scope: full PR (1 commit(s)): fd0a15ab45a6
Model: gpt-5.6-sol
Overview
The change removes the shared temporary-path race by giving each save a unique same-directory file, publishing it atomically, and serializing same-ID saves at the coroutine level. The regression test verifies concurrent saves complete and leave a parseable checkpoint. Residual compatibility and lifecycle issues remain around active-lock eviction, maximum-length checkpoint IDs, event-loop retention, and changed file permissions.
Reviewed the supplied pull-request change set across correctness, security/reliability, architecture, and failure behavior.
4 verified findings remained after source verification (4 medium) across 1 file. Details are attached to the affected lines below.
Affected areas: python/packages/core/agent_framework/_workflows/_checkpoint.py
…a shared temp path FileCheckpointStorage.save() wrote to and then renamed a fixed "<checkpoint-id>.json.tmp" path, so concurrent saves of the same checkpoint ID raced over the shared temp file. Whichever save renamed first removed the temp file still being written by another save, which then failed with FileNotFoundError / PermissionError in os.replace. Create a unique temp file per save in the destination directory (so os.replace remains atomic), serialize same-ID writes with a per-ID lock, and retry the atomic move briefly to absorb the transient Windows background-handle PermissionError that surfaces even for fully serialized replaces. Fixes microsoft#7748
fd0a15a to
24eb113
Compare
There was a problem hiding this comment.
🔵 Needs a closer look
Cross-loop synchronization, cancellation, and executor-shutdown behavior warrant final human review.
Review details
Suppressed comments (1)
python/packages/core/tests/workflow/test_checkpoint.py:2209
- This says the cross-loop signal is awaited with
asyncio.wrap_future, but_wait_for_signaldeliberately avoids that API because cancellation propagates into the wrapped future. Please describe the per-loop waiter callback actually under test.
together. The queue hands ownership over a `concurrent.futures.Future`, which any
loop can await through `asyncio.wrap_future`, so a single chain orders both.
- Files reviewed: 2/2 changed files
- Comments generated: 1
- Review effort level: Balanced
…ed design Copilot review on microsoft#7757. Both described machinery this PR replaced, which would point the next reader at the wrong design. The cross-instance test said the fix "switched to a process-wide, per-destination threading.Lock keyed by canonical path". Ownership comes from a process-wide queue keyed by the canonical destination path; the lock is what it replaced. The cross-loop test was worse: it said the hand-off signal is one "any loop can await through `asyncio.wrap_future`". That is the mechanism this PR deliberately does not use -- it chains a waiter's cancellation into the shared signal an earlier writer still has to resolve, which is the defect the review caught earlier on this PR. It now names `_wait_for_signal` and says why `wrap_future` is excluded. Checked the file's other references: the remaining `threading.Lock` and `wrap_future` mentions are either locks used by the tests themselves or past-tense descriptions of what was replaced, which are accurate as written.
|
Please fix the failing unit tests. |
…ng GC decide `test_file_checkpoint_storage_abandonment_mid_chain_keeps_the_queue_ordered` crashed a Windows CI worker. Not an assertion failure -- the worker process died, with no Python traceback. The cause is an unreferenced pending task. The test did `abandoned_loop.create_task(storage.save(...))` without keeping the result, so the task was collectable the moment the call returned, and the loop it belongs to is deliberately closed while the task is still pending. That left the task's finalizer -- which touches the closed loop -- to run at whatever point the garbage collector chose, including during worker teardown. That also explains the shape of the failure. It is not a version boundary: 3.11, 3.12 and 3.14 failed on Windows while 3.10 and 3.13 passed, and every Linux job passed. Non-monotonic scatter like that is a timing flake, and GC timing is the thing that varies. It did not reproduce locally in ten runs on 3.12 -- the test alone, the file under xdist, the workflow directory, and the full core suite under xdist six times. Three sibling tests had the same unreferenced-task pattern, all of them in tests that abandon or shut down a loop on purpose, so all four now hold their tasks for the duration of the test. The sibling at `test_file_checkpoint_storage_abandoned_loop_while_queued_releases_the_destination` already did this correctly, which is what made the inconsistency visible. Holding the reference does not weaken what these tests prove: the abandonment being tested is of the loop, not of the task object, and the task stays pending either way. It only moves the finalizer to a deterministic point.
…7748' into python-checkpoint-concurrent-save-7748
|
Fixed in The failure was not an assertion. The worker process died, with no Python traceback: Cause: an unreferenced pending task. That test did The shape of the failure supports that. It is not a version boundary: 3.11, 3.12 and 3.14 failed on Three sibling tests had the same unreferenced-task pattern, all of them in tests that abandon or One caveat I would rather state than gloss over: I could not reproduce the crash locally, so CI The Python workflows on this head are sitting at |
microsoft#8214 landed and rewrote `save()`, which this branch also rewrites. Resolved in favour of this branch's machinery for the conflicting hunk -- their `_write_atomic` is the pre-rewrite version -- while deliberately carrying their two changes forward rather than letting "keep ours" discard them: * `encoding="utf-8"` on the write. Their fix landed inside the conflicting hunk, so resolving in our favour dropped it; it is restored on this branch's `os.fdopen`. This is a real bug, not a style change: `json.dump(..., ensure_ascii=False)` writes non-ASCII raw, and the platform default is cp1252 on Windows, so a checkpoint containing CJK text or emoji raised `UnicodeEncodeError`. Both read sites came through the automatic merge already UTF-8; fixing only one side would have traded a loud write-time error for silent read-time corruption. Verified by round-tripping CJK, accented and emoji codepoints. * Their save-time encode/decode validation, which the automatic merge kept. It raises before `_enqueue_write`, so a rejected checkpoint never takes a destination ticket, and it sits outside the critical section so it does not extend how long a destination is held. Checked rather than assumed: no test was lost on either side (71 here + main's, 74 after), and all 25 tests this branch adds still fail against the new `main`, so the description's teeth claim still holds. The two newly uncovered lines in the file are microsoft#8214's own exception branch, not this branch's.
|
Thanks for the update. The review thread from the previous request is now resolved, but the current head is still not ready: |
CI failed with `found 0 tasks` on Linux 3.13. Not the code -- the test's timing assumption. It created the save task, yielded once with `await asyncio.sleep(0)`, then asserted exactly one pending task to prove the write is not itself a task. But a single yield drains every ready callback, so on a fast tmpfs the executor write could finish and resolve the shield inside that same batch; the save completed and `asyncio.all_tasks()` came back empty. The write is now held at its first syscall, so the save is deterministically suspended when the count is taken. Three things about the gate matter: * It only gates this save's own temp file. `checkpoint_module.os` is the real `os` module, so the patch is process-wide, and blocking every `os.open` would stall pytest's own I/O and coverage writes on the same event and deadlock the run. * The assertions are split. An empty list now reports that the gate did not hold, which is a problem with the test; two tasks reports the write being a task, which is the regression. One message could not honestly explain both, and `0` was indistinguishable from the flake it replaced. * A failing assertion releases the worker before propagating, so the real error surfaces immediately instead of after the gate's timeout. Verified rather than assumed: reverting the source to `ensure_future(asyncio.to_thread(...))` still fails the test, now with `found 2 tasks` and the pending `to_thread()` task in the output. Stable over ten runs of the test and five shuffled xdist runs of the whole file.
Two conflicts, both in imports, both resolved by keeping what this branch needs: * `_checkpoint.py` -- `main` dropped `import time`; this branch still needs it for `_replace_with_retry`'s backoff, which `main` does not have. Verified `time.sleep` is still referenced rather than assuming. * `test_checkpoint.py` -- `main` added a module-level `import threading` while this branch added `import time` and the `ConcurrentFuture` alias. All three kept, and the function-local `import threading` added earlier today is now redundant so it is removed in favour of the module-level one. Checked: no test lost on either side (74 here + main's, 75 after), and both this branch's machinery and microsoft#8214's `encoding="utf-8"` and save-time validation are still present.
|
Fixed in The failure was The write is now held at its first syscall, so the save is deterministically suspended when the
Verified rather than asserted: reverting the source to The merge had two import conflicts, both resolved keeping what this branch needs -- Validation: core 5354 passing, The workflows on this head are queued at |
Motivation & Context
Concurrent calls to
FileCheckpointStorage.save()for the same checkpoint ID raced over a shared temporary file (<checkpoint-id>.json.tmp). Each save wrote to and thenos.replaced that fixed path, so whichever save renamed first removed the temp file still being written by a competing save, which then failed —FileNotFoundErroron POSIX (per the issue) andPermissionError [WinError 5]on Windows (where the concurrent-replace also trips the destination-lock check). In the issue's 100-concurrent-save repro, between 49 and 71 saves failed.Description & Review Guide
Three cooperating parts in
_checkpoint.py, the last two reshaped by review.Unique temp file per save. Each save writes to its own
.maf-ckpt-<uuid>.tmpin thedestination directory, created with
os.open(O_CREAT | O_EXCL | O_WRONLY, 0o666)so the filekeeps the process umask exactly as the previous
open(..., "w")did. Same directory keepsos.replaceatomic, and per-save temp names remove the shared-path collision that Python: [Bug]: Concurrent FileCheckpointStorage saves fail due to shared temporary path #7748 reported.Ownership taken before the write is submitted. A process-wide queue keyed by the canonical
destination path hands out ownership; only then is the write submitted to the executor. Waiters
suspend on a
concurrent.futures.Futuresignal, awaited through a per-wait future fed by adone-callback rather than through
asyncio.wrap_future, which chains cancellation into the futureit wraps.
Two properties follow from that choice. A queued same-path save occupies no executor worker, where
previously each one held a worker purely to block on
threading.Lock.acquire()— a burst couldfill the default pool, stall unrelated
to_threadwork including checkpoint loads, and deadlockonce the write that had to finish first was queued behind those waiters. And because a
concurrent.futures.Futureis not bound to a loop, one chain orders writers enqueued fromdifferent event loops, which a per-path
asyncioprimitive cannot do.Ownership is released as the write's last act on the worker thread, with the coroutine's
finallyas a backstop for the case where nothing was submitted. Releasing only from the coroutine would be
a regression against the
threading.Lockit replaces: a lock is released by the worker thread,which outlives the loop that submitted it, whereas a coroutine whose loop is closed while its task
is pending never releases -- leaving the destination owned for the life of the process.
Registry entries are reference-counted by queued-or-running operations and dropped by the last
release. The previous dict never removed an entry, and since
WorkflowCheckpointgenerates a freshUUID by default, ordinary saves each retained one for the life of the process.
Cancellation, handled explicitly in both directions. Cancelled while still waiting for the
destination: the write is never submitted, so there is nothing left to land later — and ownership is
still handed on, or every later save for that path would wait forever. Cancelled once the write is
running: the worker is drained before the cancellation propagates and before ownership is released,
since releasing first would let the next save begin while this
os.replaceis in flight.The drain absorbs re-delivered cancellations. A single
await asyncio.shield(worker)is notenough: once a task has a cancellation pending its next await raises immediately, so the shield
returns with the worker still running.
Ownership survives the loop that took it. A submitted write is released by its worker thread,
which outlives the loop that submitted it. A ticket still waiting for its predecessor has no
worker to fall back on, so the predecessor's callback releases it directly when the waiter's loop
has closed — on the thread that resolved the predecessor, rather than on a loop that will never
run again. Without that, a queued save whose loop went away left its hand-off signal unresolved
and every later save for that destination waited forever.
Two windows this cannot close: a loop that closes after the wake-up was queued but before it runs,
and one abandoned without being closed at all. Both need a loop closed with tasks still pending,
which asyncio already reports as an error. A graceful shutdown is unaffected, because
asyncio.runcancels pending tasks first and that drives a queued save through its cancellationpath — pinned by its own test, since that is the path callers actually depend on.
Bounded replace retry. Retained from the original fix. On Windows
os.replacecan transientlyraise
PermissionErroreven fully serialized, because a background indexer or AV scan brieflyholds a handle to the destination (19 failures in 200 purely sequential replaces locally). Five
attempts with 1→2→4→8→16 ms backoff absorb it.
What are the major changes? Destination ownership moved out of the worker thread and in front of
submission, reference-counted registry entries, and an explicit cancellation contract. No public
API change.
What is the impact of these changes? Concurrent saves of one checkpoint ID no longer fail, and
last-writer-wins now follows the order callers observed rather than the order the executor happened
to schedule. Queued saves no longer consume executor capacity, so a save burst cannot starve
checkpoint loads.
One observable behaviour change: a cancelled
save()returns after its own write finishes, wherebefore it returned promptly and the write continued behind it. That is the fix — a cancelled write
landing later could overwrite a newer checkpoint — but it does mean a caller that cancels no longer
proceeds immediately.
Deliberately out of scope:
delete()does not join the queue, so a delete racing a save to thesame path remains unserialized. Python: [Bug]: Concurrent FileCheckpointStorage saves fail due to shared temporary path #7748 is about concurrent saves; happy to add it if wanted.
What do you want reviewers to focus on? The cancellation contract, since it is the part with a
caller-visible consequence, and whether draining should be labelled a breaking change.
Also the two loop-abandonment windows described above. I chose to state them rather than engineer
around them: closing them means tracking each ticket's loop and reaping, which is real complexity
in this path for a failure only reachable through a loop closed with tasks still pending. Say if
you would rather have the machinery.
Second, the ownership hand-off. The signal is a
concurrent.futures.Futureso any loop can awaitit, and nothing asyncio-owned may mark it done — cancelling a waiter must not reach it, and the
submitted write must not be a Task, or loop shutdown can cancel it before it reaches the executor.
An earlier revision of this description claimed
wrap_futurewas safe here on the strength of acheck that read the wrapped future one loop iteration too early; it is not, and the mechanism has
been replaced.
Validation: core 5354 passing with
_checkpoint.pyat 98% line coverage,poe syntaxandpoe typingclean across all five checkers, and ag-ui, declarative and foundry_hosting green asthe downstream users of checkpoint storage. The three lines still uncovered in the file all
predate this PR, apart from two that arrived with Python: FileCheckpointStorage save/load symmetry (#8181) #8214's own save-time validation when it
merged into this branch.
Python: FileCheckpointStorage save/load symmetry (#8181) #8214 merged while this was open and rewrote
save()too. Resolved keeping itsencoding="utf-8"and its save-time validation rather than letting "keep ours" discard them -- the encoding fix had landed inside the conflicting hunk, so it needed restoring by hand. That is a real bug:ensure_ascii=Falseinto a cp1252 stream raisedUnicodeEncodeErroron a checkpoint containing CJK text or emoji, and fixing only the write side would have traded a loud write error for silent read corruption. Its validation raises before_enqueue_write, so a rejected checkpoint never takes a destination ticket.This PR adds or rewrites 25 tests. I re-ran all 25 against
main's_checkpoint.pyrather thanrestating an earlier claim, and all 25 fail there. An earlier revision of this description said
"ten", which had been true several rounds ago and was never updated.
End to end, the issue's own repro -- 100 concurrent
save()calls for one checkpoint ID -- fails59/100 then 85/100 with
PermissionErroronmain(matching the 49-71 reported) and 0/100 hereacross three runs. The surviving checkpoint is also deterministic now:
mainleaves a differentwinner each run because the last
os.replaceto land is whichever the executor scheduled last,where this branch leaves the last save enqueued, every run.
Related Issue
Fixes #7748
Contribution Checklist