description
FileCheckpointStorage.save uses one fixed temporary filename per checkpoint id: file_path.with_suffix(".json.tmp") in _checkpoint.py (the _write_atomic helper). when two saves of the same checkpoint id run concurrently, both write the same .tmp path and both call os.replace(tmp, final). whichever replace runs first consumes the tmp file, and the other save then dies with a raw FileNotFoundError from os.replace, which is neither WorkflowCheckpointException nor any documented failure of save.
this matters because file storage is the cross-process durability story. two processes resuming the same workflow after a failover or a duplicate delivery can both checkpoint the same id; the storage should serialize or tolerate that, not crash. i could not find any test or doc that pins concurrent-save behavior (checked test_checkpoint.py, the storage conformance suite, and the tracker), so as far as i can tell this surface is simply unpinned.
for the record, the decode side is solid: i also probed the restricted unpickler with a class whose __reduce__ lies about its own reconstruction, and it correctly rejects it with a type-mismatch error. the save path is the weak end.
found via a differential/concurrency probe of the checkpoint storages. ai assistance disclosed. repro is standalone and offline.
reproduction steps
import asyncio, sys, tempfile, uuid
sys.path.insert(0, "python/packages/core")
from agent_framework._workflows._checkpoint import FileCheckpointStorage, WorkflowCheckpoint
async def main():
with tempfile.TemporaryDirectory() as d:
fcs = FileCheckpointStorage(d)
cid = str(uuid.uuid4())
cps = [
WorkflowCheckpoint(
workflow_name="conc", graph_signature_hash="s",
checkpoint_id=cid, state={"i": i},
)
for i in range(20)
]
results = await asyncio.gather(*[fcs.save(cp) for cp in cps], return_exceptions=True)
n_err = sum(1 for r in results if isinstance(r, Exception))
print(f"{n_err}/20 concurrent same-id saves failed")
for r in results:
if isinstance(r, Exception):
print(type(r).__name__, str(r)[:90])
break
asyncio.run(main())
typical output: several of the 20 saves raise FileNotFoundError: [Errno 2] No such file or directory: '.../<id>.json.tmp' -> '.../<id>.json' from os.replace.
suggested direction: give each writer a unique tmp name (pid + uuid suffix or tempfile.mkstemp in the storage dir) and optionally wrap replace failures in WorkflowCheckpointException. happy to implement if that direction works.
environment
- agent-framework-core @ main 8e803b9 (editable, python/packages/core)
- python 3.12, macOS, offline repro
description
FileCheckpointStorage.saveuses one fixed temporary filename per checkpoint id:file_path.with_suffix(".json.tmp")in_checkpoint.py(the_write_atomichelper). when two saves of the same checkpoint id run concurrently, both write the same.tmppath and both callos.replace(tmp, final). whichever replace runs first consumes the tmp file, and the other save then dies with a rawFileNotFoundErrorfromos.replace, which is neitherWorkflowCheckpointExceptionnor any documented failure ofsave.this matters because file storage is the cross-process durability story. two processes resuming the same workflow after a failover or a duplicate delivery can both checkpoint the same id; the storage should serialize or tolerate that, not crash. i could not find any test or doc that pins concurrent-save behavior (checked
test_checkpoint.py, the storage conformance suite, and the tracker), so as far as i can tell this surface is simply unpinned.for the record, the decode side is solid: i also probed the restricted unpickler with a class whose
__reduce__lies about its own reconstruction, and it correctly rejects it with a type-mismatch error. the save path is the weak end.found via a differential/concurrency probe of the checkpoint storages. ai assistance disclosed. repro is standalone and offline.
reproduction steps
typical output: several of the 20 saves raise
FileNotFoundError: [Errno 2] No such file or directory: '.../<id>.json.tmp' -> '.../<id>.json'fromos.replace.suggested direction: give each writer a unique tmp name (pid + uuid suffix or
tempfile.mkstempin the storage dir) and optionally wrap replace failures inWorkflowCheckpointException. happy to implement if that direction works.environment