Repository navigation
Python: fix(core): preserve checkpoint identity across filesystem aliases - #9466
Yufeng He (he-yufeng) wants to merge 11 commits into
Conversation
…on save On case-insensitive filesystems (default Windows/macOS), Round-A.json and round-a.json are the same file. FileCheckpointStorage.save accepted both spellings, so the second save silently replaced the first checkpoint and reads of the first ID then returned the other workflow's state, with the first workflow's listing left empty. save now fails closed: when the destination exists under a differently-cased spelling, the save raises WorkflowCheckpointException naming the collision instead of replacing a different checkpoint's data. Case-sensitive filesystems skip the scan entirely (the variant path does not exist there), and re-saving the exact same ID stays a normal update everywhere. Tests: a conformance regression pins the rejection, the first checkpoint's survival, and the same-ID update path; red without the guard on a case-insensitive volume.
There was a problem hiding this comment.
🟡 Changes recommended
The collision guard still permits data loss in supported scenarios and can reject valid updates, while the test probe introduces a collection race.
4 open issues
What changed in this PR
This PR addresses #9444 by attempting to prevent case-variant checkpoint IDs from overwriting stored snapshots.
Changes:
- Adds a filename-collision guard before checkpoint encoding and saving.
- Adds regression coverage for collision rejection, snapshot preservation, and same-ID updates.
| File | Description |
|---|---|
| python/packages/core/tests/workflow/test_checkpoint_storage_conformance.py | Adds filesystem detection and collision regression coverage. |
| python/packages/core/agent_framework/_workflows/_checkpoint.py | Checks for case-variant filenames before saving. |
Review effort: Balanced
…ization Address Copilot review on the microsoft#9444 fix: derive the expected filename from the requested checkpoint ID instead of the resolved path (Windows resolve() canonicalizes to on-disk casing, which would accept the colliding save), allow same-ID updates when the exact spelling already exists on case-sensitive volumes, and fail closed when the directory cannot be listed. Move the case-sensitivity probe into the test body on tmp_path so parallel xdist workers cannot race a shared collection-time probe.
Somay kousis (Somay-kousis)
left a comment
There was a problem hiding this comment.
Ran the PR's conformance file on macOS 26.6 APFS (case-insensitive, default volume):
- main 44fe223 + PR test file:
1 failed, 18 passed(test_file_save_rejects_case_variant_checkpoint_idfails) - PR head 183ae7b:
19 passed
So the case-only collision from #9444 is fixed here.
One input still reaches the same overwrite: IDs that differ by Unicode normalization. APFS treats NFC and NFD spellings as one file ("café-1" vs "café-1"), and str.casefold() does not normalize, so _assert_no_case_variant_collision finds no variant and the second save replaces the first.
Script (public API only, same shape as the issue's repro): two saves, then load(first_id).
== main 44fe223d
case: load('Round-A') -> id='round-a' wf=second-workflow state={'marker': 'second'}; first-workflow ids=[]
nfc/nfd: load('café-1') -> id='café-1' wf=second-workflow state={'marker': 'second'}; first-workflow ids=[]
case+nfd: load('Café-1') -> id='café-1' wf=second-workflow state={'marker': 'second'}; first-workflow ids=[]
== PR head 183ae7b7
case: second save rejected: WorkflowCheckpointException
nfc/nfd: load('café-1') -> id='café-1' wf=second-workflow state={'marker': 'second'}; first-workflow ids=[]
case+nfd: load('Café-1') -> id='café-1' wf=second-workflow state={'marker': 'second'}; first-workflow ids=[]
(the printed IDs look identical; the second one is NFD, 7 code points vs 6)
Comparing normalized keys in the variant scan closes it:
def _key(name: str) -> str:
return unicodedata.normalize("NFC", name).casefold()
variant = next(
(name for name in siblings if name != expected_name and _key(name) == _key(expected_name)),
None,
)With that change, all three rows above raise WorkflowCheckpointException, and test_checkpoint_storage_conformance.py plus test_checkpoint.py still pass. Could the new regression be parametrized over ("Round-A", "round-a"), (NFC "café-1", NFD "café-1") and (NFC "Café-1", NFD "café-1")?
Not checked: Windows/NTFS and HFS+.
…lision guard APFS and HFS+ treat NFC and NFD spellings of a name as one file, and str.casefold() does not normalize, so a checkpoint ID differing only by normalization form still reached the silent overwrite from microsoft#9444. Compare siblings under NFC + casefold and reject those variants too. Thanks to Somay-kousis for the on-device report and the fix sketch.
|
Somay kousis (@Somay-kousis) Thanks for the on-device verification and for the normalization catch. Landed in cddc816. The guard now compares siblings under On your "not checked" list: NTFS is normalization-sensitive, so the NFD save finds no existing file, the guard returns early, and both checkpoints coexist without an overwrite, which is safe (if visually confusing); the reject only engages where the filesystem actually folds. Reproduced the hole before the fix on this machine (APFS): both new parametrized cases fail against the previous guard and pass now, and the full checkpoint family (153 tests across the five checkpoint test files) plus pyright stay green. |
Eduard van Valkenburg (eavanvalkenburg)
left a comment
There was a problem hiding this comment.
The sequential save guard is much improved, but the file backend still permits variant-ID data loss and identity aliasing in supported operations.
|
Status note for the reviewer: the red Public API Compatibility check is not from this diff. griffe crashes while resolving the base snapshot's |
|
Sorry for the late reply. Reran on e6f935d: the case-only, NFC/NFD and case+NFD pairs all reject the second save with |
Eduard van Valkenburg (eavanvalkenburg)
left a comment
There was a problem hiding this comment.
Yufeng He (@he-yufeng) One additional material issue remains in python/packages/core/agent_framework/_workflows/_checkpoint.py:661. The new collision guards synchronously enumerate the entire storage directory before the first await in load(), delete(), and existing-ID save(). Single-checkpoint operations now perform O(N) filesystem work on the event-loop thread, which can stall unrelated workflows and cancellation handling on large or slow directories. Please move the directory verification into worker-side/offloaded execution while preserving the collision check under destination ownership, and add an async regression showing the event loop remains responsive during a large scan.
I am not repeating the two existing HFS+ normalization and absent-case-variant concerns; those remain open separately.
# Conflicts: # python/packages/core/agent_framework/_workflows/_checkpoint.py
|
Eduard van Valkenburg (@eavanvalkenburg) The event-loop blocking is addressed in Save no longer performs a pre-enqueue directory scan; its collision check remains worker-side under destination ownership. Load performs existence checking, JSON reading, and identity verification in one A new save/load/delete parameterized regression streams 2,000 directory entries behind a deterministic thread-controlled delay and verifies that an event-loop heartbeat continues during each operation. The old load and delete paths fail this check; the new paths pass. Final checkpoint sweep: 111 passed, 1 skipped. The skip is the real case-sensitive-filesystem delete regression on this APFS host. Ruff, formatting, and Pyright on the changed module all pass. |
|
Yufeng He (@he-yufeng) The collision queue key at |
|
Yufeng He (@he-yufeng) There is a separate load/delete race in Please validate the ID in the already-read JSON regardless of whether a matching directory entry remains, and add an interleaved load/delete regression. |
|
The directory verification already runs off the event loop. The async regression you asked for is already in the branch as If the O(N) scan itself is the concern independent of which thread it runs on, replacing |
The variant-collision guard compared a full checkpoint ID against directory basenames, so a path-bearing ID such as "./Round-A" matched no sibling and returned before the persisted-identity check. On a folding filesystem "./round-a" then silently replaced "./Round-A" (and a bare "Round-A" alias was likewise reachable). The maintainer reproduced the overwrite. Verify the persisted checkpoint ID whenever the resolved destination exists, not only when a basename variant is found: a folded spelling or a path-bearing alias must not replace a different checkpoint, while a same-ID re-save stays a normal update. Add a prefixed-ID regression.
|
Eduard van Valkenburg (@eavanvalkenburg) Both follow-ups are implemented in The destination queue now uses the directory's device and inode plus the folded filename. The key is captured on the ticket at enqueue, so release does not stat a removed or replaced directory. Two storage instances using differently cased aliases of one directory now share ownership, while genuinely distinct directories remain independent. The new probes failed before with no predecessor and with both variant saves succeeding; they now pass with exactly one save rejected. Load also validates the persisted ID from the already-read JSON even if a concurrent delete makes the matching directory entry disappear. The interleaving regression returned the other checkpoint before this change and now raises instead. On the merged head, the checkpoint storage and concurrency files are 116 passed and 1 skipped. Strict source Pyright, test Pyright, Ruff, and the pre-commit hooks pass. The locking guarantee remains process-local; no cross-process exclusion is claimed. |


Motivation & Context
FileCheckpointStoragemust not silently overwrite, read, or delete a checkpoint when filesystem case folding or Unicode normalization makes two requested IDs resolve to one file. The filename returned by the filesystem is not a reliable checkpoint identity: HFS+ can return a decomposed spelling even for a checkpoint originally saved with an NFC ID.Description & Review Guide
Falsefrom delete, and a valid checkpoint remains loadable, updatable, and deletable when the filesystem changes its filename spelling. Directory scans no longer run synchronously on the event-loop thread. There is no public API change.Related Issue
Fixes #9444
AI Assistance
AI assistance details: implementation, regression tests, and this PR description. The author reviewed the final diff and ran the targeted checkpoint tests, Ruff, formatting, and Pyright checks.
Contribution Checklist
breaking changelabel (or add "[BREAKING]" to the title prefix, before or after any language prefix). A workflow keeps the label and title prefix in sync automatically.