Restructure DeltaChannel reconstruction so the hydration path matches
pregel's storage axes (blobs + writes) without leaking internal DTOs
into the public checkpoint contract.
Key changes:
* Deleted `DeltaChannelWrites` dataclass and `SEED_UNSET` sentinel.
Reconstruction data no longer flows through `Checkpoint.channel_values`
as a wrapped DTO — that field now carries a value or `DELTA_SENTINEL`,
never a reconstruction shape.
* Added private `_ChannelWritesHistory(seed: Any, writes: list[PendingWrite])`
NamedTuple as the return type for the new storage-level query.
* Added private, experimental `_get_channel_writes_history` /
`_aget_channel_writes_history` on `BaseCheckpointSaver` — reference
impl via `get_tuple` + `parent_config` walk, overridden on
`InMemorySaver` / `PostgresSaver` / `AsyncPostgresSaver` for perf.
Fixes a latent migration bug in the base fallback (now inspects
ancestor `channel_values` for pre-delta seed).
* `DeltaChannel.from_checkpoint(seed)` simplified to two cases
(sentinel/MISSING → empty, else → seed). New `replay_writes` method
folds `list[PendingWrite]` through the reducer.
* Delta hydration consolidated inside `channels_from_checkpoint` via
optional `saver` + `config` kwargs (+ async mirror
`achannels_from_checkpoint`). All six pregel call sites updated.
`get_tuple` no longer patches `channel_values` — removed
`_resolve_delta_channels` (memory) and per-tuple reconstruction from
`_load_checkpoint_tuple` (postgres sync + async).
* Hydration short-circuits on the target's own blob: if
`channel_values[k]` is a real value (pre-migration tip, `update_state`
result), use it directly. Only walks ancestors when the target holds
sentinel or is missing. Fixes a correctness bug where migration-tip
and `update_state` values would be lost.
* New test_delta_channel_migration.py: 10 scenarios covering
BinaryOperatorAggregate → DeltaChannel migration (basic + async,
time-travel, fork, `update_state`, tip-of-pre-migration, base-saver
fallback parity, cross-thread isolation).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
snapshot_every was a knob for bounding reconstruction cost on deep threads.
Benchmarks (notes/add_messages_replay_problem.md + scratch work on
sr/add-messages-replay-bench) showed the add_messages fast-path
(optimize/add-messages-fast-path) closes the quadratic replay cost for
threads under ~1000 turns, where the crossover to snapshots makes sense.
For deeper threads we'll ship a first-class compaction primitive instead.
Removals:
* DeltaChannel: snapshot_every ctor param, _writes_since_snapshot counter,
should_snapshot() / snapshot_write() methods, counter threading through
_apply_write / update / from_checkpoint / copy.
* Pregel loop: post-checkpoint snapshot-injection block and
SNAPSHOT_TASK_ID import + constant.
* Checkpoint base: _overwrite_types() helper and the ancestor-walk
short-circuit on user-emitted Overwrite in sync + async
get_channel_writes.
* InMemory + Postgres savers: same walk-terminator shortcut. The
pre-delta blob terminator (seed-from-ancestor-blob) stays — it's
required for migration correctness, not a snapshot optimization.
* Tests for all of the above.
Preserved:
* Channel-level Overwrite semantics in DeltaChannel / BinOpAggregate:
Overwrite still resets the value at reducer level; same-super-step
dedup and InvalidUpdateError on multiple Overwrites still enforced.
* Pre-delta migration seeding.
Four fixes from an independent review of the reconstruction pipeline, plus
a structural cleanup:
1. Ancestor walk excludes the target checkpoint itself (matches pregel:
writes stored under checkpoint_id=T are pending for the NEXT step and
applied separately via apply_writes). Memory saver previously included
them, diverging from Postgres and causing pending writes to be folded
into the reconstructed snapshot — visible via get_state during
interrupts and time-travel into a non-leaf checkpoint.
2. Pre-delta blob terminator. When the walk hits an ancestor whose blob
for the channel is a real value (not DELTA_SENTINEL), bind that blob
as DeltaChannelWrites.seed and stop. Without this, threads migrated
from pre-delta storage would replay ancestor writes to the root
forever AND lose any value that lived only in the old blob
(e.g. from update_state). Per-ancestor, the blob is checked BEFORE
its writes — a pre-delta blob subsumes writes at the same checkpoint,
so including them would double-count.
3. Base-fallback get_channel_writes follows parent_checkpoint_id instead
of list(before=...). The previous form returned every tuple with
id<target, including sibling branches on forked threads.
4. seed replaces the Overwrite-wrapping hack for pre-delta values.
DeltaChannelWrites(writes, seed=SEED_UNSET) makes the saver's
reconstruction terminator semantically explicit; drops the lazy
_make_overwrite import dance. User-emitted Overwrite still reset the
chain via _apply_write as before.
Postgres: recursive CTE enumerates on-path ancestors and joins once
against checkpoint_writes and once against checkpoint_blobs for every
delta channel in the get_tuple — one roundtrip instead of the previous
3 queries × N channels.
Tests added:
- Pre-delta blob seeding (seed binding, no double-counting of ancestor
writes at the terminator, pending-at-target excluded).
- Root checkpoint returns empty writes.
- Seed-based from_checkpoint replay (three scenarios: with writes,
seed-only, seed=None distinct from SEED_UNSET).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
DeltaChannel.checkpoint() now returns a zero-byte DeltaChannelSentinel
instead of duplicating delta data in checkpoint_blobs. Reconstruction
walks the parent checkpoint chain via checkpoint_writes (which already
holds per-step writes) and replays them through the operator.
In-memory benchmark (100 turns, ~20K tokens):
storage: 10.2 MB → 40.5 KB (251x reduction)
read: 0.6ms → 7.9ms (reconstruction cost, amortized by storage savings)
InMemorySaver and PostgresSaver override get_channel_writes() with
efficient implementations (Python dict walk and recursive CTE respectively).
The base class fallback uses self.list() with a thread-local recursion guard.
- Fix InMemorySaver.get_channel_blob: use correct storage[thread_id][ns]
nesting and deserialize the checkpoint before extracting channel_versions.
- Pass checkpoint_id to after_checkpoint() in channels_from_checkpoint so
DeltaChannel seeds _last_checkpoint_id correctly on load; without this
every turn broke the chain at its boundary.
- Wire _assemble_delta_channels into _prepare_state_snapshot and
_aprepare_state_snapshot (get_state / get_state_history paths) and into
perform_superstep / aperform_superstep (update_state paths) — previously
only the loop __enter__ path did assembly.
- Fix test_get_channel_blob to use the correct storage structure.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Leave it up to each checkpointer implementation to decide whether to merge in configurable/metadata (previously PregelLoop would do some of this always)
- Never copy over internal langgraph keys into checkpoint.metadata (these are redundant/misleading to include)
Prepare langgraph-checkpoint for 0.5
- Given we have no upper bound on langgraph-checkpoint dep need to undo all changes in langgraph-checkpoint that might break previous versions of langgraph
- This has been superseded by saving the individual writes of each task through put_writes()
- Removing this speeds up checkpoint operations as it was duplicating data saved elsewhere already
- Instead store sends in a Topic channel, removing the need to fetch sends as writes against the parent checkpoint
- Remove deprecated/unused functions in langgraph-checkpoint (will require bumping min range for langgraph-checkpoint in langgraph lib)
- Implement migration of old pending sends in langgraph-checkpoint-postgres
- Ensure parent config of `checkpoint_during=False` checkpoints always points to checkpoints that were also saved
- This makes our checkpoint benchmarks more closely resemble the behavior of our prod checkpointers
- Also found and fixed a bug w multiple subgraphs in same node accidentally sharing checkpoints
(Keep MemorySaver around for backwards compatibility)
"MemorySaver" is ambiguous: is it saving memories? Where is it saving
memories to?
InMemorySaver aligns naming InMemoryStore as well as similar LangChain
objects (InMemoryVectorStore, etc.)