From 4cf12f950086a7e075813996fa66df6b04dee0b1 Mon Sep 17 00:00:00 2001 From: Quanzheng Long Date: Thu, 7 May 2026 12:13:35 -0700 Subject: [PATCH] rm --- docs/delta-channel-checkpointer-guide.md | 356 ----------------------- 1 file changed, 356 deletions(-) delete mode 100644 docs/delta-channel-checkpointer-guide.md diff --git a/docs/delta-channel-checkpointer-guide.md b/docs/delta-channel-checkpointer-guide.md deleted file mode 100644 index 7c820119e..000000000 --- a/docs/delta-channel-checkpointer-guide.md +++ /dev/null @@ -1,356 +0,0 @@ -# DeltaChannel Checkpointer Guide - -> **Beta** — `DeltaChannel` and all APIs described here (`_DeltaSnapshot`, -> `get_delta_channel_history`, `get_delta_channel_keepset`, -> `delta_updates_since_snapshot` metadata) are in beta. The on-disk -> representation and method signatures may change in future releases. - -This guide is for third-party `BaseCheckpointSaver` authors who need to -support graphs using `DeltaChannel`. It covers: - -1. How `DeltaChannel` state is stored across checkpoint tables. -2. Which saver methods interact with delta channels and what the OSS - implementations look like (working references you can copy). -3. How to correctly implement cleanup methods (`prune`, `delete_for_runs`, - `copy_thread`) without silently corrupting delta history. - ---- - -## Part I — Background - -### How DeltaChannel is stored - -Unlike regular channels, `DeltaChannel` does **not** write its accumulated -value into every checkpoint. Instead: - -- `DeltaChannel.checkpoint()` always returns `MISSING` - (`libs/langgraph/langgraph/channels/delta.py`). The channel's live value - is never placed in `channel_values` by default. - -- A full **snapshot** (`_DeltaSnapshot(value)`) is written into - `channel_values[k]` only when the channel's update count reaches - `snapshot_frequency` (default 1000). This decision is made by - `delta_channels_to_snapshot()` in `libs/langgraph/langgraph/pregel/_checkpoint.py`. - -- Between snapshots, the channel's writes are stored as ordinary - `checkpoint_writes` rows (Postgres) or `writes` rows (SQLite), keyed by - `(thread_id, checkpoint_ns, checkpoint_id, task_id, idx)`. - -- Reconstruction at read time walks the parent chain backward from the - target checkpoint, collecting writes, until it finds an ancestor whose - `channel_values[ch]` is populated (the "seed"). It then replays all - collected writes through the channel's reducer on top of that seed. - -#### Storage layout per backend - -| Backend | Snapshot blob | Writes | Key structure | -|---------|--------------|--------|---------------| -| **Postgres** | `checkpoint_blobs` table; checkpoint JSON has `True` sentinel | `checkpoint_writes` table | `(thread_id, checkpoint_ns, channel, version)` for blobs | -| **SQLite** | Inline in checkpoint blob | `writes` table | `(thread_id, checkpoint_ns, checkpoint_id, task_id, idx)` | -| **InMemory** | `self.blobs` dict | `self.writes` dict | Same logical key structure | - -#### The metadata hint - -`CheckpointMetadata.delta_updates_since_snapshot` (`dict[str, int]`) tracks -how many supersteps have written to each delta channel since the last -snapshot. It resets to 0 when a snapshot fires. This is a **hint** for the -pregel loop — the source of truth for "is there a snapshot here?" is whether -`channel_values[ch]` is populated in the checkpoint. - -### The reconstruction walk - -```mermaid -flowchart LR - subgraph parentChain [Parent Chain] - direction RL - HEAD["HEAD checkpoint
(target)"] - P1["Parent 1
writes: [w4]"] - P2["Parent 2
writes: [w3]"] - P3["Parent 3
_DeltaSnapshot(seed)
writes: [w2]"] - P4["Parent 4
writes: [w1]"] - end - HEAD --> P1 - P1 --> P2 - P2 --> P3 - P3 --> P4 - - subgraph result [History result] - SEED["seed = _DeltaSnapshot(...)"] - WRITES["writes = [w3, w4]
(oldest → newest)"] - end - P3 -.-> SEED - P2 -.-> WRITES - P1 -.-> WRITES -``` - -The walk: -1. Starts at `target.parent_config` (target's own `pending_writes` are - excluded — they belong to the *next* super-step). -2. At each ancestor, collects `pending_writes` for the requested channels. -3. Stops per-channel when `channel_values[ch]` is non-empty (= seed found). -4. Returns `{"writes": [...], "seed": ...}` per channel. If no seed is - found (walk reaches root), `"seed"` is omitted — the consumer treats - this as "start empty." - -**Default implementation:** -`BaseCheckpointSaver.aget_delta_channel_history` at -`libs/checkpoint/langgraph/checkpoint/base/__init__.py` — one `aget_tuple` -call per ancestor. Correct but O(chain_length) round-trips. - ---- - -## Part II — Read & Write Surface - -These are the saver methods that interact with `DeltaChannel`. The base -class provides correct defaults; overrides exist only as performance -optimizations. - -### `aput` — accepting `_DeltaSnapshot` in `channel_values` - -Snapshot blobs reach `aput` as `_DeltaSnapshot(value)` instances inside -`checkpoint["channel_values"]`. Round-tripping them through -`JsonPlusSerializer` is sufficient (msgpack ext code `EXT_DELTA_SNAPSHOT`). -No special-casing is required for correctness. - -**Optimization (Postgres):** hoist non-primitive values into a side blob -table (`checkpoint_blobs`) and replace the JSON value with `True`. - -**References:** -- `InMemorySaver.put` — simplest, at `libs/checkpoint/langgraph/checkpoint/memory/__init__.py` -- `PostgresSaver.put` — sentinel + side-blob, at `libs/checkpoint-postgres/langgraph/checkpoint/postgres/__init__.py` -- `SqliteSaver.put` — inline, at `libs/checkpoint-sqlite/langgraph/checkpoint/sqlite/__init__.py` - -### `aput_writes` — appending pending writes - -One row per `(channel, idx)` keyed by -`(thread_id, checkpoint_ns, checkpoint_id, task_id, idx)`. No -DeltaChannel-specific logic needed — these rows are what the parent-chain -walk reads. - -### `aget_tuple` and `alist` — joining writes - -Must return `pending_writes` populated (oldest-first per task) so the -default `aget_delta_channel_history` walk can collect them from each -ancestor. - -### `aget_delta_channel_history` — performance optimization - -The base implementation works out of the box but does one `aget_tuple` per -ancestor. For long chains (up to `snapshot_frequency` = 1000 steps), this -becomes expensive. Override for performance. - -**Working references (progressively richer):** -- **InMemory** — walks dict keys directly: - `libs/checkpoint/langgraph/checkpoint/memory/__init__.py` -- **SQLite** — paged DESC scan + per-channel UNION ALL writes fetch: - `libs/checkpoint-sqlite/langgraph/checkpoint/sqlite/_delta.py` and - `libs/checkpoint-sqlite/langgraph/checkpoint/sqlite/__init__.py` -- **Postgres** — JSONB-aware two-stage SQL (stage 1: paged `checkpoint_id` - DESC with parallel `channel_values->key IS NOT NULL` checks; stage 2: - per-channel UNION ALL writes + seed blob fetch): - `libs/checkpoint-postgres/langgraph/checkpoint/postgres/base.py` and - `libs/checkpoint-postgres/langgraph/checkpoint/postgres/__init__.py` - -### `adelete_thread` — safe cleanup - -Wholesale delete of all rows for a `thread_id`. No DeltaChannel risk -because nothing survives. Copy the pattern from any OSS saver. - ---- - -## Part III — Cleanup Surface - -These optional methods (`prune`, `delete_for_runs`, `copy_thread`) have -**no OSS implementation** yet. The docstrings on `BaseCheckpointSaver` flag -the danger; this section provides concrete recipes. - -### The challenge - -- The "latest" checkpoint is rarely a snapshot point (`snapshot_frequency` - defaults to 1000). -- A naive `keep_latest` that drops intermediate ancestors and their - `checkpoint_writes` severs the parent chain. -- The surviving head's reconstruction walk hits `parent_config = None` - early, finds no seed, and `from_checkpoint(MISSING)` produces an empty - value. **Silent data loss — no exception raised.** -- The same trap applies to `delete_for_runs` (a run's writes may be - ancestors of a live head) and `copy_thread` (head-only copy strands the - target). - -### The `get_delta_channel_keepset` helper - -`BaseCheckpointSaver` provides a helper that returns the minimum set of -`checkpoint_id`s that must survive deletion: - -```python -keep = await saver.aget_delta_channel_keepset( - config=head_config, channels=["messages", "events"], -) -# `keep` contains the head + every ancestor back to the nearest -# _DeltaSnapshot for each listed channel. Delete everything else. -``` - -Pass `channels=[]` for graphs without `DeltaChannel` — returns -`{head_id}` only. - -### Implementing `prune` - -Three strategies: - -#### Strategy A — Compact-and-prune (requires graph access) - -1. Resolve current value via - `saver.aget_delta_channel_history(config=head, channels=delta_channels)`. -2. Fold writes through each channel's `reducer` to get the live value. -3. Rewrite `channel_values[ch] = _DeltaSnapshot(value)` on the kept head - (update the checkpoint row + blob table). -4. Now `aget_delta_channel_keepset(channels=[])` returns `{head_id}` — - delete everything else. - -This is the most space-efficient but requires access to the graph's -reducers, so it must be done at the application layer (not purely -saver-side). - -#### Strategy B — Walk-to-boundary - -```python -async def aprune(self, thread_ids, *, strategy="keep_latest"): - for tid in thread_ids: - heads = await self._list_heads(tid) # your backend code - keep: set[str] = set() - for h in heads: - keep |= await self.aget_delta_channel_keepset( - config=h, channels=DELTA_CHANNELS, - ) - all_ids = await self._list_all_ids(tid) # your backend code - to_delete = all_ids - keep - await self._delete_checkpoint_rows(tid, to_delete) - await self._delete_writes(tid, to_delete) -``` - -No rewrite needed. Preserves more rows than Strategy A but is correct and -simple. - -**Postgres SQL sketch** (alternative to the Python helper — pure SQL -recursive CTE): - -```sql -WITH RECURSIVE ancestors AS ( - SELECT checkpoint_id, parent_checkpoint_id, - (checkpoint -> 'channel_values' -> 'messages') IS NOT NULL AS has_snap - FROM checkpoints - WHERE thread_id = $1 AND checkpoint_ns = $2 AND checkpoint_id = $3 - - UNION ALL - - SELECT c.checkpoint_id, c.parent_checkpoint_id, - (c.checkpoint -> 'channel_values' -> 'messages') IS NOT NULL - FROM checkpoints c - JOIN ancestors a ON c.checkpoint_id = a.parent_checkpoint_id - WHERE NOT a.has_snap -) -SELECT checkpoint_id FROM ancestors; --- This returns the keep-set for channel 'messages'. -``` - -### Implementing `copy_thread` - -**Default safe approach:** copy *all* rows for -`(source_thread_id, *)` to `(target_thread_id, *)` across all three -tables. Single transaction, simple `INSERT ... SELECT`. No -DeltaChannel-specific logic needed — the full chain is preserved. - -```sql --- Postgres example -INSERT INTO checkpoints (thread_id, checkpoint_ns, checkpoint_id, ...) -SELECT $target, checkpoint_ns, checkpoint_id, ... -FROM checkpoints WHERE thread_id = $source; - -INSERT INTO checkpoint_blobs (thread_id, checkpoint_ns, channel, version, ...) -SELECT $target, checkpoint_ns, channel, version, ... -FROM checkpoint_blobs WHERE thread_id = $source; - -INSERT INTO checkpoint_writes (thread_id, checkpoint_ns, checkpoint_id, ...) -SELECT $target, checkpoint_ns, checkpoint_id, ... -FROM checkpoint_writes WHERE thread_id = $source; -``` - -**Optimization:** if you must copy only a sub-range, apply Strategy A -(compact a snapshot onto the chosen head first) then copy that single -checkpoint. - -### Implementing `delete_for_runs` - -`run_id` lives in `metadata` JSON, not as a column. Implementations need a -JSON predicate: - -```sql --- Postgres -SELECT checkpoint_id FROM checkpoints -WHERE thread_id = $1 AND metadata->>'run_id' = ANY($2); -``` - -**Risk:** deleting a run's checkpoints/writes can strand surviving heads in -the same thread whose reconstruction walk would have traversed those rows. - -**Recipe:** -1. Identify all `(thread_id, checkpoint_id)` belonging to the run. -2. For every still-live head in those threads, compute the keep-set: - `keep |= await saver.aget_delta_channel_keepset(config=head, channels=...)` -3. Only delete run rows that fall **outside** the union of all keep-sets. -4. If a row is both "belongs to run" and "in keep-set," it must survive. - ---- - -## Part IV — Validation - -### Conformance suite - -Run the existing conformance tests for any methods you implement: - -```python -from langgraph.checkpoint.conformance import validate - -report = await validate(my_checkpointer, capabilities={ - "delta_channel_history", - "delta_channel_keepset", - "delta_channel_reconstruction", -}) -report.print_report() -assert report.passed_all_base() -``` - -The three delta capabilities test: -- **`delta_channel_history`** — walk contract (writes oldest→newest, seed - is nearest populated `channel_values[ch]`, target's writes excluded). -- **`delta_channel_keepset`** — keep-set contract (empty channels → - `{target}`, snapshot N-back → full chain, multi-channel → union). -- **`delta_channel_reconstruction`** — end-to-end round-trip: write - `_DeltaSnapshot` + writes via `aput` / `aput_writes`, read via - `aget_delta_channel_history`, reconstruct via - `DeltaChannel.from_checkpoint(seed) + replay_writes(writes)`, assert - value equality. - -### Recommended additional test - -Build a graph with `DeltaChannel(snapshot_frequency=10)`, drive it for -~25 supersteps, run your `prune` / `delete_for_runs` / `copy_thread`, then -assert reconstructed value equals the pre-cleanup value. This is the single -test that catches the silent-corruption mode (conformance alone is -necessary but not sufficient for cleanup methods). - -See existing patterns in: -- `libs/langgraph/tests/test_delta_channel_migration.py` -- `libs/langgraph/tests/test_delta_channel_exit_mode.py` - ---- - -## Cross-references - -- Docstring warnings on `BaseCheckpointSaver`: `prune`, `aprune`, - `delete_for_runs`, `adelete_for_runs`, `copy_thread`, `acopy_thread` - (`libs/checkpoint/langgraph/checkpoint/base/__init__.py`). -- `DeltaChannel` Beta warning - (`libs/langgraph/langgraph/channels/delta.py`). -- Conformance suite - (`libs/checkpoint-conformance/langgraph/checkpoint/conformance/`).