deer-flow/backend/docs/checkpoint-retention-contract.md
zeng-bohan a2808e8292
test(checkpoint): retention deletion contract + growth baseline (#4189 item 3) (#5255)
* test(checkpoint): retention deletion contract + growth baseline

Six contract scenarios x memory/sqlite/postgres pin what retention deletions
must never break (branch ancestors, explicit resume targets, pending writes,
duration-only chain links), prove the two safe shapes (leaf sibling branches,
trailing duration leaves), record the full-vs-delta growth baseline in the
normalized bench shape, and add an item 4 probe showing the default
ToolOutputBudgetMiddleware already externalizes oversized tool results.

Refs #4189

* test(checkpoint): make the retention contract load-bearing per review

Review findings from willem-bd and Ricky-7-Yan:

- scenario D pins its own row: before/after stats delta plus a serde
  round-trip of the stored write, instead of an always-true > 0 check
- _delete_checkpoint now performs the joint delete the doc mandates
  (checkpoint row + writes rows + blobs unreachable from surviving
  checkpoints), so E1/E2 exercise the shape they prescribe
- E1 builds the real runtime duration shape via persist_run_durations
  (parent dict clone, fresh id/ts, real metadata), which surfaces the
  shared-version case: the leaf's blobs are the surviving parent's rows
- contract doc: blob reachability must be computed from surviving
  checkpoints in a whole-thread pass; shared-version/duration-only
  hazard called out explicitly; memory data model includes saver.blobs
- _stats counts memory blob rows and returns the full normalized shape
  (logical byte totals included)
- probe: drops the unused middleware/outputs_dir graph parameters and
  discloses the manual-harness scope limit in the module docstring
- E1/E2 assert default head resolution (protected set item 5); unused
  graph_for helper and DURATION_ONLY_METADATA stand-in removed

Signed-off-by: zengbohan1 <310902929+zengbohan1@users.noreply.github.com>

* fix(checkpoint): scope probe cleanup to owned dirs, key report by backend

Second-round review findings on #5255:

- [P1] bench_tool_result_probe.py removed the whole user-supplied
  --outputs-dir (and the shared .probe-tmp) in its finally block, so
  pre-existing files were deleted on success and failure alike. The run
  now writes into (and removes) a fresh owned probe-run-* child beneath
  the requested directory, and SQLite databases live in a unique
  mkdtemp'd temp directory that is removed with the run. Regression
  tests pin that unrelated pre-existing files survive both a successful
  and a simulated failing run.
- [P2] the optional retention report keyed every backend's measurements
  under one shared name, so a multi-backend invocation kept only the
  last backend's numbers. _report() now takes the parameterized backend
  explicitly (saver_env.kind); regression pins that memory and sqlite
  entries coexist in one report file.

Signed-off-by: zengbohan1 <310902929+zengbohan1@users.noreply.github.com>

---------

Signed-off-by: zengbohan1 <310902929+zengbohan1@users.noreply.github.com>
Co-authored-by: zengbohan1 <310902929+zengbohan1@users.noreply.github.com>
2026-09-08 19:21:57 +08:00

5.9 KiB

Checkpoint Retention Contract (DRAFT)

Status: draft — the deletion contract for #4189 item 3. No retention or deletion implementation should land before this contract (or a successor revision of it) is accepted, and every deletion proposal must be validated against backend/tests/test_checkpoint_retention_contract.py.

Why a contract is needed

LangGraph checkpoints form a per-thread parent chain. Gateway features depend on that chain being intact:

  • Branch / regenerate resolves the replay base by walking parent_config links from a checkpoint that contains the target message (app/gateway/checkpoint_lineage.py::find_checkpoint_before_message).
  • Explicit resume replays from a checkpoint_id a client still holds.

Deleting checkpoint rows by recency or table size can therefore break those features silently — a missing ancestor surfaces as CheckpointLineageIntegrityError at branch time, or as a lost resume target, never as an obvious storage bug. The contract below separates deletable rows from protected rows and pins the verification method.

Data model

Backend State rows Writes rows
SQLite checkpoints writes
Postgres checkpoints, checkpoint_blobs checkpoint_writes
Memory saver.storage, saver.blobs saver.writes

(Note: SQLite has no separate blob table; channel values live inside the serialized checkpoint payload. Postgres splits blobs out.)

Measurement shape: per-thread rows + bytes per table, normalized by bench_channels._normalized_storage_stats.

Protected set (MUST NOT delete without the stated compensation)

  1. Explicit resume targets — any checkpoint_id a client may still resume to. Deleting it removes the replay surface (test_deleting_explicit_resume_target_breaks_resume). A retention policy may expire these, but only with an explicit TTL semantic agreed here.
  2. Branch ancestors — every checkpoint on the parent chain from a branchable head back to (and including) the checkpoint before the oldest branchable message. Deleting any node on that walk breaks branch/regenerate with CheckpointLineageIntegrityError (test_deleting_branch_ancestor_breaks_lineage_loudly).
  3. Pending writes — rows in the writes table are uncommitted/in-flight state, not garbage (test_pending_writes_are_retained_state_not_garbage).
  4. Duration-only chain linkspersist_run_durations appends metadata-only checkpoints. A duration-only checkpoint that a later run has forked from is a chain link: the walk steps through it, so deleting it requires grafting the fork onto the grandparent (rewriting the fork's parent_config) in the same change. A bare leaf (below) is safe; a link is not. The link shape can only be produced by the real runtime, so the graft path is specified here and intentionally not covered by a storage-level test.
  5. Latest resumable state per thread — the newest checkpoint must remain addressable so a thread can always continue.

Provably safe forms (validated by tests)

  1. Leaf sibling branches — a checkpoint forked off an older turn that has no children (test_leaf_sibling_branch_deletion_is_safe). Pruning it does not affect the main line's walk, explicit resume, or head.
  2. Trailing duration-only leaves — a duration-only checkpoint no later run has forked from (test_leaf_duration_checkpoint_deletion_is_safe).

New deletion proposals must add their shape as a test here: construct the chain, delete, then verify (a) latest resume, (b) explicit checkpoint_id resume, (c) branch from an older visible turn, and (d) orphan row counts.

Deletion mechanics

  • Deletion must cover the backend's tables jointly and account for orphans, and blob reachability must be computed from the surviving checkpoints in a whole-thread pass: after deleting a checkpoint row, a checkpoint_blobs / checkpoint_writes row is an orphan only if no surviving checkpoint references it. The shared-version case is not hypothetical — the real duration-only checkpoint is a copy of the head checkpoint dict (persist_run_history_metadata replaces only id/ts), so it inherits the parent's channel_versions verbatim, and on Postgres the blob rows reachable from the deleted duration row are the same rows backing its parent. An implementation that deletes blobs keyed by the removed checkpoint's own channel_versions would corrupt the thread's newest surviving state — exactly the failure class this contract exists to prevent. (For the same reason a real duration-only leaf is not payload-free: it materializes the parent's values under {"writes": {"runtime_run_duration": {...}}, "source": "update", "step": ...} metadata, which is what makes reclaiming it worthwhile.)
  • Failure semantics: if a proposed deletion cannot be proven safe against the protected set, it must not ship. Partial deletion that leaves a dangling parent_config converts a cleanup into a thread-level outage (branch and regenerate fail loudly for every later turn).
  • Measurement first: proposals must include before/after numbers from scripts/benchmark/checkpoint/bench_channels.py (per-thread rows/bytes, SQLite and Postgres) plus the contract test suite passing.

Item 4 note (large tool results)

ToolOutputBudgetMiddleware externalizes oversized tool outputs before they reach state (preview + file reference under .tool-results/), so the "50 KB result re-snapshotted every step" scenario from the original report depends on which tools/paths bypass it. The probe (scripts/benchmark/checkpoint/bench_tool_result_probe.py) measures the on-disk checkpoint delta for the wrapped vs unwrapped paths on the lead graph; subagent chains instantiate the same middleware by default. Any PR claiming a residual gap must name the concrete bypassing path and show its probe numbers.