Uh oh!
There was an error while loading. Please reload this page.
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.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
a32ff36 to
fd0a15aCompareEvan Mattson (moonbox3)
commented
Aug 19, 2026
/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
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
…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. Fixesmicrosoft#7748
fd0a15a to
24eb113ComparePython Test Coverage Report •
Python Unit Test Overview
| ||||||||||||||||||||||||||||||
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
…m cancellation Address two post-merge review concerns on FileCheckpointStorage.save(): - The per-event-loop, per-instance asyncio.Lock registry could not serialize two FileCheckpointStorage instances pointed at the same directory, nor a single instance driven from two event loops. Replace it with a process-wide, per-canonical-path threading.Lock registry (lazily populated, bounded by distinct destinations actually written). Because asyncio.to_thread runs the actual file I/O on a worker thread, the threading lock serializes correctly across coroutines, loops, and instances and spans the entire open + write + os.replace window. - Caller-side cancellation previously released the write lock early: a CancelledError delivered inside 'await asyncio.to_thread(...)' exited the 'async with' and ran _release_save_lock_ref while the OS thread kept writing. The next save for the same checkpoint_id could then reach os.replace concurrently, reintroducing the PermissionError race and possibly landing stale data over a newer write. Shield the worker so cancellation propagates only after the in-flight write completes. Also replace the refcount-aware registry test with: - registry-bounded test for the new invariant (one lock per destination) - cross-instance concurrent save test (two storages, same directory) - gated os.replace cancel-race test (deterministically parks A's worker mid-publish, confirms save B cannot reach os.replace until A completes) Fixesmicrosoft#7748
| # Shielding guarantees the in-flight write completes (or fails) before | ||
| # the caller observes a result, so the order seen on disk matches the | ||
| # order callers observed. | ||
| await asyncio.shield(asyncio.to_thread(_write_atomic)) |
There was a problem hiding this comment.
Following up on the earlier cancellation concern: this commit moves serialization into _write_atomic() and protects the asyncio.to_thread() task with asyncio.shield(), but a save cancelled before its worker starts remains queued after its caller receives CancelledError. A later save on another event loop can acquire _get_file_lock(), return successfully, and then be overwritten when the cancelled worker eventually runs. Could the cancellation path either remove a queued write or drain it before propagating cancellation?
| # therefore on the same lock. Holding it across the entire open + write | ||
| # + replace keeps no window where a second writer can briefly see a | ||
| # half-published temp file or reach os.replace concurrently. | ||
| with _get_file_lock(file_path): |
There was a problem hiding this comment.
Following up on the earlier thread-pool comment: could waiting happen before work enters the default executor? This commit moves _get_file_lock() inside _write_atomic(), so every same-path save() now occupies a worker while waiting; a burst can fill the pool and delay unrelated asyncio.to_thread() work, including checkpoint loads and independent saves. A path-keyed queue or dedicated write executor could preserve cross-instance serialization while delegating only the active write.
There was a problem hiding this comment.
Thanks — reading these together, all three land on the same root: _get_file_lock inside the worker, no refcounting, no thought given to where the wait happens. Wanted to put the candidate shapes in front of you before committing, since they're not equivalent:
Verified against the current tree
_get_file_lock(file_path)sits inside_write_atomic, which runs viaasyncio.to_threadon the default executor. A burst of N saves on one ID = N default-pool workers all blocked inthreading.Lock.acquire(), soasyncio.to_threadfor unrelated work (loads, independent saves to other paths) queues until a worker frees. Defaultmax_workers = min(32, cpu + 4), so even ~16 racing saves can saturate on a smaller host. Real._file_locksis unbounded — every distinct checkpoint ID ever saved (and not remove()d) leaves aPath → threading.Lockentry. UUID-keyed IDs make this accumulate in long-running orchestrators. Real.- Cancel-before-start: the shielded inner Task wraps
loop.run_in_executor. If the caller'sTask.cancel()lands before the executor submission completes, the wrapped Task is cancelled and the executor does not run the function (CPython 3.11+:to_threaddoesloop.create_task(coro)whose first step is the submission; cancel before that step → no worker ever picked it up). Empirically hard to hit but present; the post-cancel stale-write concern from the first round is already mitigated by the per-path lock (next save serializes, so final state is correct even if a stale write briefly runs).
Fix shapes on the table
(A) Refcounted registry, lock acquired in the coroutine body — not inside the worker.
entry=await_acquire_path_lock(file_path) # bumps holders under guard; does threading.Lock.acquire() via asyncio.to_thread for the brief acquire onlytry: awaitasyncio.shield(asyncio.to_thread(_write_atomic)) # worker does pure I/O; lock already held by the coroutinefinally: _release_path_lock(file_path, entry) # drops entry when holders hit 0
Addresses (2) by moving the wait out of the long-lived worker (a burst of N same-ID saves spawns N short-lived to_thread acquisitions, not N held workers) and (3) via refcount. (1) still bounded by the CPython window above, but the lock guarantees final-state ordering.
(B) Per-path single-worker ThreadPoolExecutor, refcounted.
w=_writer_for(file_path) # single-thread executor per pathtry: awaitloop.run_in_executor(w.executor, _write_atomic) # serialization from queueing on that executor, no threading.Lock neededfinally: _release_writer(file_path, w) # shutdown wait=True when refcount hits 0
Strongest response to (2): same-path saves queue on their own dedicated thread, never touch the default pool even while waiting; concurrent saves to different IDs get parallel writers. (3) and the cancel-before-start window unchanged. Cost: one OS thread per in-flight path, plus shutdown pacing at the end of each path's lifetime.
(C) Keep the current epoch, accept (2) as a documented limit and (3) via refcounting only. Smallest possible diff (~25 lines):
_PathLockwrapper + refcount, no other restructuring. Doesn't address the pool-starvation concern at all.(D) Push back on (1). The window is a CPython
asyncio.to_threadsubmission race (cancel delivered betweenloop.create_taskand the Task's first__step). Every realistic cancel I can trigger lands mid-run_in_executor(worker already started) or after the worker finished (no-op), not in the narrow pre-submit step. The post-cancel stale-write concern is already moot under the process-wide lock: even if the stale write runs, the next save will re-publish after the lock serializes it, so final state is consistent. A regression test can enforce that.
My lean is (A) — cleanest middle ground, keeps the existing shape, addresses 2+3 directly and explains 1 as a runtime edge. (B) is cleanest architecturally but adds executor lifetime management. Want me to proceed with (A), or would you rather take (B)?
| # instances because asyncio.to_thread runs the actual file write on a worker | ||
| # thread, and threading primitives serialize across those. | ||
| # | ||
| # Locks are created lazily on first save and never removed. The registry grows |
There was a problem hiding this comment.
Following up on the earlier registry-growth discussion: could _file_locks release entries after the final holder or waiter exits? This commit replaces the cleaned-up per-loop entries with a process-wide dictionary that never removes a Path; because WorkflowCheckpoint creates a new UUID by default, normal saves and later delete() calls retain one lock per checkpoint forever. A reference-counted entry would preserve process-wide coordination while tying lock lifetime to active work.
Evan Mattson (moonbox3)
commented
Aug 21, 2026
Manjunath Janardhan (@manjunathshiva) please handle all comments per guidelines. |
Yes, working on it |
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
The fix has two cooperating parts in
_checkpoint.py:Unique temp file per save. Replace the fixed
with_suffix(".json.tmp")path withtempfile.NamedTemporaryFile(dir=<dest-dir>, prefix=<id>., suffix=.json.tmp, delete=False). Creating the temp file in the destination directory keepsos.replaceatomic (same filesystem), and each save now owns its own temp file, eliminating the shared-path collision entirely.Per-ID write serialization + bounded replace retry. Add a per-checkpoint-ID
threading.Lockmap so concurrent saves of the same ID execute theiros.replaceserially. On Windows,os.replacecan still transiently raisePermissionErroreven when fully serialized — a background indexer/AV scan can briefly hold a handle to the just-replaced destination file (I reproduced 19 failures out of 200 purely single-threaded sequentialos.replacecalls on Windows locally). A short bounded retry (5 attempts, 1→2→4→8→16 ms backoff) absorbs that transient.Temp files are cleaned up on failure (
finally+unlink(missing_ok=True)), best-effort.Regression test
test_file_checkpoint_storage_concurrent_saves_same_idruns 50 concurrent saves of a shared checkpoint ID and asserts zero raise internal filesystem exceptions and the final file exists.What are the major changes? Unique temp files + per-ID lock + replace retry in
FileCheckpointStorage.save()(packages/core/_workflows/_checkpoint.py); one regression test, no public API change.What is the impact of these changes? Concurrent saves of the same checkpoint ID no longer fail; each save's last writer wins, which is the documented
FileCheckpointStoragesemantics. Not a breaking change.What do you want reviewers to focus on? Whether the retry constant (5 attempts, exponential backoff up to 16 ms) and the bounded set of retried exceptions (
PermissionErroronly) are the right shape, or whether a broader/narrower policy is preferred.Related Issue
Fixes#7748
Contribution Checklist