Skip to content

Python: FileCheckpointStorage.save crashes on concurrent saves of the same checkpoint id: fixed .tmp filename race #8182

Description

@ptimizeroracle

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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

pythonUsage: [Issues, PRs], Target: PythonreproducedUsage: [Issues], Target: all issues that can be reproduced by the triage workflowworkflowsUsage: [Issues, PRs], Target: Workflows

Type

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions