mirror of
https://github.com/langchain-ai/langgraph.git
synced 2026-09-22 01:25:06 +02:00
# DeltaChannel: sentinel-based checkpoint blobs + write-replay
reconstruction
## Summary
`DeltaChannel` is a new fold-reducer channel that stores only a
zero-byte sentinel in checkpoint blobs instead of the full accumulated
value. On restore, the runtime replays ancestor writes through the
reducer to reconstruct state. For long-running threads with large
accumulating state (e.g. message histories), this delivers dramatically
smaller checkpoint blobs with configurable read-depth bounds.
```python
from typing import Annotated
from typing_extensions import TypedDict
from langgraph.channels.delta import DeltaChannel
from langgraph.graph.message import _messages_delta_reducer
class State(TypedDict):
# blob per step: ~60 bytes (sentinel) instead of growing full list
messages: Annotated[list, DeltaChannel(_messages_delta_reducer)]
# bound read depth to 10 steps via periodic snapshots
messages_bounded: Annotated[list, DeltaChannel(_messages_delta_reducer, snapshot_frequency=10)]
```
---
## Storage benchmarks (InMemory, ~400 char/msg)
**Messages blob storage** (`checkpoint_blobs` bytes for the messages
channel):
| turns | add\_messages | delta(inf) | delta(freq=50) | delta(freq=10) |
delta(freq=5) |
|------:|-------------:|-----------:|---------------:|---------------:|--------------:|
| 10 | 91.0 KB | 60 B (1517x) | 60 B (1517x) | 14.4 KB (6x) | 32.6 KB
(3x) |
| 50 | 2.20 MB | 300 B (7347x) | 67.1 KB (33x) | 423 KB (5x) | 864 KB
(3x) |
| 100 | 8.78 MB | 600 B (14636x) | 310 KB (28x) | 1.72 MB (5x) | 3.48 MB
(3x) |
| 250 | 54.80 MB | 1.5 KB (36536x) | 2.09 MB (26x) | 10.87 MB (5x) |
21.84 MB (3x) |
| 500 | 219.19 MB | 3.0 KB (73063x) | 8.56 MB (26x) | 43.67 MB (5x) |
87.50 MB (3x) |
**Total checkpoint storage** (blobs + writes + metadata):
| turns | add\_messages | delta(inf) | delta(freq=50) | delta(freq=10) |
delta(freq=5) |
|------:|-------------:|-----------:|---------------:|---------------:|--------------:|
| 10 | 129.7 KB | 38.7 KB (3.4x) | 38.7 KB (3.4x) | 53.1 KB (2.4x) |
71.2 KB (1.8x) |
| 50 | 2.40 MB | 196 KB (12x) | 263 KB (9x) | 620 KB (3.9x) | 1.06 MB
(2.3x) |
| 100 | 9.18 MB | 394 KB (23x) | 703 KB (13x) | 2.12 MB (4.3x) | 3.87 MB
(2.4x) |
| 250 | 55.79 MB | 987 KB (57x) | 3.07 MB (18x) | 11.86 MB (4.7x) |
22.82 MB (2.4x) |
| 500 | 221.16 MB | 1.98 MB (112x) | 10.53 MB (21x) | 45.64 MB (4.9x) |
89.48 MB (2.5x) |
**Write-phase peak heap**:
| turns | add\_messages | delta(inf) | delta(freq=50) | delta(freq=10) |
delta(freq=5) |
|------:|-------------:|-----------:|---------------:|---------------:|--------------:|
| 10 | 456 KB | 199 KB (2.3x) | 199 KB (2.3x) | 212 KB (2.2x) | 232 KB
(2.0x) |
| 50 | 3.04 MB | 742 KB (4.1x) | 805 KB (3.8x) | 1.21 MB (2.5x) | 1.67
MB (1.8x) |
| 100 | 10.70 MB | 1.41 MB (7.6x) | 1.82 MB (5.9x) | 3.42 MB (3.1x) |
5.25 MB (2.0x) |
| 250 | 60.44 MB | 3.36 MB (18x) | 5.67 MB (11x) | 14.87 MB (4.1x) |
26.31 MB (2.3x) |
**Read-phase avg `get_state` latency** (5 calls, InMemory):
| turns | add\_messages | delta(inf) | delta(freq=50) | delta(freq=10) |
delta(freq=5) |
|------:|-------------:|-----------:|---------------:|---------------:|--------------:|
| 10 | 0.7 ms | 1.1 ms (0.6x) | 1.1 ms (0.6x) | 0.8 ms (0.9x) | 0.6 ms
(1.1x) |
| 50 | 2.7 ms | 5.3 ms (0.5x) | 3.5 ms (0.8x) | 2.7 ms (1.0x) | 2.7 ms
(1.0x) |
| 100 | 5.5 ms | 11.1 ms (0.5x) | 6.0 ms (0.9x) | 5.2 ms (1.1x) | 5.4 ms
(1.0x) |
| 250 | 12.9 ms | 27.2 ms (0.5x) | 13.6 ms (0.9x) | 12.9 ms (1.0x) |
13.0 ms (1.0x) |
**Postgres `get_tuple` read latency** (~100 tok/msg per step):
| steps | full-list | delta(inf) | delta(freq=50) | delta(freq=10) |
delta(freq=5) |
|------:|----------:|-----------:|---------------:|---------------:|--------------:|
| 10 | 0.29 ms | 0.21 ms (1.4x) | 0.19 ms (1.6x) | 0.19 ms (1.5x) | 0.19
ms (1.6x) |
| 50 | 0.19 ms | 0.15 ms (1.3x) | 0.19 ms (1.0x) | 0.22 ms (0.8x) | 0.29
ms (0.7x) |
| 100 | 0.27 ms | 0.17 ms (1.6x) | 0.22 ms (1.2x) | 0.23 ms (1.2x) |
0.21 ms (1.3x) |
| 500 | 0.60 ms | 0.30 ms (2.0x) | 0.66 ms (0.9x) | 0.56 ms (1.1x) |
0.69 ms (0.9x) |
**Takeaway:** `snapshot_frequency=10` matches full-list read latency
while still saving 5x on blob storage and ~4x on total storage.
---
## How it works
### Checkpoint blobs
`checkpoint()` always returns `DELTA_SENTINEL` (a zero-byte msgpack ext
marker) instead of the accumulated value. On restore, the saver's
`_get_channel_writes_history` walks the ancestor chain collecting
`checkpoint_writes` entries and replays them through the reducer:
```python
# blob stored per step: ~1 byte (sentinel)
# vs. full list growing O(N) every step with BinaryOperatorAggregate
```
### Reducer interface
`DeltaChannel` takes a **batch reducer** `(state, list[writes]) ->
state` — all writes for a step arrive in one call, enabling single-pass
implementations:
```python
# ❌ Don't use add_messages directly — it's a binary operator, not a batch reducer
messages: Annotated[list, DeltaChannel(add_messages)] # wrong
# ✅ Use _messages_delta_reducer — single pass, dedup by ID, RemoveMessage support
messages: Annotated[list, DeltaChannel(_messages_delta_reducer)]
# ✅ Or write your own batch reducer for custom types
def my_dict_reducer(state: dict, writes: list[dict]) -> dict:
result = dict(state)
for w in writes:
result.update(w)
return result
files: Annotated[dict, DeltaChannel(my_dict_reducer)]
```
### Snapshot frequency
`snapshot_frequency=N` writes a full `_DeltaSnapshot` blob every N
pregel steps, bounding replay depth regardless of thread length.
Snapshots are eager — written even if the channel had no update that
step, so the depth bound always holds:
```python
# Replay walks at most 10 ancestors before hitting a snapshot
messages: Annotated[list, DeltaChannel(_messages_delta_reducer, snapshot_frequency=10)]
```
### Migration from `BinaryOperatorAggregate`
Pre-existing threads written under `BinaryOperatorAggregate` work
transparently after swapping the annotation — the saver detects a
plain-value ancestor blob and uses it as the reconstruction seed:
```python
# Before: BinaryOperatorAggregate stores full list every step
items: Annotated[list, add_messages]
# After: DeltaChannel — existing checkpoints still readable, new steps use sentinel
items: Annotated[list, DeltaChannel(_messages_delta_reducer)]
```
### Async write-ordering safety
In `durability="async"` mode (default), `put_writes` calls are
fire-and-forget. `AsyncPregelLoop` tracks in-flight `aput_writes`
futures for DeltaChannel channels in `_delta_write_futs` and drains them
via `await asyncio.gather()` in `_checkpointer_put_after_previous`
before `aput()` — ensuring `checkpoint_writes` are durable before the
sentinel blob is committed.
---
## What's in scope
- **`libs/langgraph/langgraph/channels/delta.py`** — `DeltaChannel`
implementation
- **`libs/langgraph/langgraph/graph/message.py`** —
`_messages_delta_reducer` (experimental)
- **`libs/checkpoint/`** — `_get_channel_writes_history` ancestor-walk
API on `BaseCheckpointSaver`, `InMemorySaver` optimized override
- **`libs/checkpoint-postgres/`** — `PostgresSaver` /
`AsyncPostgresSaver` single-roundtrip UNION ALL override
- **`libs/langgraph/langgraph/pregel/`** — `channels_from_checkpoint` /
`create_checkpoint` wiring, async write-ordering safety
---
## Follow-ups
- **Batch reconstruction**: each DeltaChannel field issues its own
`_get_channel_writes_history` call; a single walk collecting all
sentinel channels would reduce roundtrips proportionally to the number
of DeltaChannel fields.
- **Sync write ordering**: `BackgroundExecutor.__exit__` guarantees
completion before `invoke()` returns, but within a run there's no
explicit ordering between `put_writes` and `put`. Two-phase commit for
sync would close this gap.
- **`ShallowPostgresSaver` compatibility**: shallow savers keep only the
latest checkpoint and have no parent chain to walk; DeltaChannel is
currently incompatible and should raise or warn at compile time.
- Updating the writes table w/ delta epoch ids for more efficient reads
- follow up w/ LSD checkpointer implementations to support delta
channel! and update prune
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: ccurme <chester.curme@gmail.com>
Co-authored-by: Will Fu-Hinthorn <will@langchain.dev>
614 lines
24 KiB
Python
614 lines
24 KiB
Python
"""Tests for the BinaryOperatorAggregate -> DeltaChannel migration path.
|
|
|
|
A thread written under `BinaryOperatorAggregate(...)` must keep working
|
|
after its annotation is swapped to `DeltaChannel(...)` on the same
|
|
checkpointer — pre-migration state visible at each *settled* ancestor
|
|
checkpoint is preserved, and post-migration writes fold on top through
|
|
the reducer.
|
|
|
|
Mechanism under test: the saver's `_get_channel_writes_history(config,
|
|
channel)` walks the parent chain; when it encounters an ancestor whose
|
|
`channel_values[channel]` is a real value (not `DELTA_SENTINEL`), it
|
|
returns that as the `seed`. `DeltaChannel.from_checkpoint(seed)` uses
|
|
it as the base value, and `replay_writes(writes)` folds on-path deltas.
|
|
|
|
Scenarios covered:
|
|
|
|
1. **Basic migration (sync + async)**: build pre-migration state with
|
|
`BinaryOperatorAggregate`, swap the annotation to `DeltaChannel` on
|
|
the same checkpointer, and verify that every settled pre-migration
|
|
super-step boundary (`next=('__start__',)`) round-trips exactly
|
|
under the delta-channel view.
|
|
2. **Time travel into a pre-migration checkpoint** after migration —
|
|
`graph.get_state(pre_migration_config)` at a settled ancestor
|
|
returns the same state as under the binop channel.
|
|
3. **Continuing a migrated thread**: driving one more super-step after
|
|
migration produces a state that includes the pre-migration settled
|
|
prefix plus the new delta write — proving `from_checkpoint(seed)` +
|
|
`replay_writes` correctly fold post-migration deltas onto the
|
|
pre-migration seed.
|
|
4. **Base-saver fallback path**: a third-party-style subclass that
|
|
removes the optimized `InMemorySaver` override and falls back to
|
|
`BaseCheckpointSaver._get_channel_writes_history` must produce the
|
|
same result as the optimized path.
|
|
5. **Channel-type isolation across threads**: two threads on the same
|
|
checkpointer under the delta-channel graph — one freshly-started,
|
|
one migrated from pre-migration state — don't cross-contaminate.
|
|
The parent-chain walk is scoped to the thread.
|
|
|
|
TODO: add postgres variants in the existing `libs/checkpoint-postgres`
|
|
test files (different fixture setup; not this file).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import operator
|
|
from typing import Annotated, Any
|
|
|
|
import pytest
|
|
from langchain_core.messages import AIMessage, HumanMessage
|
|
from langgraph.checkpoint.memory import InMemorySaver
|
|
from typing_extensions import TypedDict
|
|
|
|
from langgraph.channels.binop import BinaryOperatorAggregate
|
|
from langgraph.channels.delta import DeltaChannel
|
|
from langgraph.graph import END, START, StateGraph
|
|
from langgraph.graph.message import _messages_delta_reducer, add_messages
|
|
|
|
pytestmark = pytest.mark.anyio
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Graph factories
|
|
#
|
|
# A minimal reducer (`operator.add` on lists of str) with a noop node keeps
|
|
# state change localized to the HumanMessage-like payload passed through
|
|
# `invoke`. That isolates the pre/post-migration parity assertions to
|
|
# channel-hydration semantics.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _noop(_state: Any) -> dict:
|
|
return {}
|
|
|
|
|
|
def _list_concat(state: list, writes: list) -> list:
|
|
result = list(state)
|
|
for w in writes:
|
|
result.extend(w if isinstance(w, list) else [w])
|
|
return result
|
|
|
|
|
|
def _binop_graph(checkpointer: Any) -> Any:
|
|
class BinopState(TypedDict):
|
|
items: Annotated[list, BinaryOperatorAggregate(list, operator.add)]
|
|
|
|
return (
|
|
StateGraph(BinopState)
|
|
.add_node("noop", _noop)
|
|
.add_edge(START, "noop")
|
|
.add_edge("noop", END)
|
|
.compile(checkpointer=checkpointer)
|
|
)
|
|
|
|
|
|
def _delta_graph(checkpointer: Any) -> Any:
|
|
class DeltaState(TypedDict):
|
|
items: Annotated[list, DeltaChannel(_list_concat)]
|
|
|
|
return (
|
|
StateGraph(DeltaState)
|
|
.add_node("noop", _noop)
|
|
.add_edge(START, "noop")
|
|
.add_edge("noop", END)
|
|
.compile(checkpointer=checkpointer)
|
|
)
|
|
|
|
|
|
def _drive(graph: Any, config: dict, tag: str, n: int) -> None:
|
|
for i in range(n):
|
|
graph.invoke({"items": [f"{tag}{i}"]}, config)
|
|
|
|
|
|
async def _adrive(graph: Any, config: dict, tag: str, n: int) -> None:
|
|
for i in range(n):
|
|
await graph.ainvoke({"items": [f"{tag}{i}"]}, config)
|
|
|
|
|
|
def _settled_boundaries(history: list) -> list[tuple[dict, list]]:
|
|
"""Return `[(config, items), ...]` for every checkpoint in `history`
|
|
whose `next == ('__start__',)` — the stable boundaries between invokes.
|
|
"""
|
|
return [
|
|
(s.config, list(s.values.get("items", [])))
|
|
for s in history
|
|
if s.next == ("__start__",)
|
|
]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 1. Basic migration (sync + async)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_basic_migration_preserves_pre_migration_state() -> None:
|
|
"""Build state under `BinaryOperatorAggregate`, migrate to
|
|
`DeltaChannel` on the same checkpointer, and verify that every
|
|
settled pre-migration super-step boundary round-trips exactly.
|
|
|
|
Settled boundaries (`next=('__start__',)`) are the stable hydration
|
|
targets for the migration path: writes that produced the NEXT
|
|
super-step are kept as `pending_writes` on the ancestor, so walking
|
|
from a descendant finds the ancestor's blob as the seed and
|
|
reconstructs the correct state.
|
|
"""
|
|
|
|
checkpointer = InMemorySaver()
|
|
config = {"configurable": {"thread_id": "basic-sync"}}
|
|
|
|
# Pre-migration: accumulate items across 3 invokes.
|
|
binop = _binop_graph(checkpointer)
|
|
_drive(binop, config, "u", 3)
|
|
|
|
pre_boundaries = _settled_boundaries(list(binop.get_state_history(config)))
|
|
assert len(pre_boundaries) >= 2, "expected multiple settled boundaries"
|
|
|
|
# Migrate: swap the annotation on the same checkpointer.
|
|
delta = _delta_graph(checkpointer)
|
|
|
|
for cfg, items in pre_boundaries:
|
|
snap = delta.get_state(cfg)
|
|
assert list(snap.values.get("items", [])) == items, (
|
|
f"snapshot mismatch at {cfg['configurable']['checkpoint_id']}: "
|
|
f"expected {items}, got {snap.values.get('items', [])}"
|
|
)
|
|
|
|
|
|
async def test_basic_migration_preserves_pre_migration_state_async() -> None:
|
|
"""Async variant of the basic migration scenario."""
|
|
|
|
checkpointer = InMemorySaver()
|
|
config = {"configurable": {"thread_id": "basic-async"}}
|
|
|
|
binop = _binop_graph(checkpointer)
|
|
await _adrive(binop, config, "u", 3)
|
|
|
|
pre_history = [s async for s in binop.aget_state_history(config)]
|
|
pre_boundaries = _settled_boundaries(pre_history)
|
|
assert len(pre_boundaries) >= 2
|
|
|
|
delta = _delta_graph(checkpointer)
|
|
|
|
for cfg, items in pre_boundaries:
|
|
snap = await delta.aget_state(cfg)
|
|
assert list(snap.values.get("items", [])) == items, (
|
|
f"async snapshot mismatch at {cfg['configurable']['checkpoint_id']}"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 2. Time travel into a pre-migration checkpoint after migration
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_time_travel_into_pre_migration_checkpoint() -> None:
|
|
"""After migration, `graph.get_state(pre_migration_config)` at a
|
|
settled ancestor returns the state as stored at that point."""
|
|
|
|
checkpointer = InMemorySaver()
|
|
config = {"configurable": {"thread_id": "time-travel"}}
|
|
|
|
binop = _binop_graph(checkpointer)
|
|
_drive(binop, config, "u", 3)
|
|
|
|
pre_boundaries = _settled_boundaries(list(binop.get_state_history(config)))
|
|
assert pre_boundaries, "no settled ancestors to time-travel to"
|
|
|
|
delta = _delta_graph(checkpointer)
|
|
|
|
# Pick the oldest non-empty boundary — a long distance to walk back.
|
|
non_empty = [(cfg, items) for cfg, items in pre_boundaries if items]
|
|
assert non_empty, "expected at least one non-empty boundary"
|
|
target_cfg, expected_items = non_empty[-1]
|
|
|
|
snap = delta.get_state(target_cfg)
|
|
assert list(snap.values.get("items", [])) == expected_items
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 3. Continuing a migrated thread: deltas fold onto pre-migration seed
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_continuing_migrated_thread_folds_deltas_on_seed() -> None:
|
|
"""Resume a pre-migration settled ancestor via `invoke(None, cfg)`
|
|
under the delta-channel graph. Since the pre-migration checkpoint
|
|
has an existing `pending_writes` entry (the input for the NEXT
|
|
super-step), re-running from that ancestor reproduces the same
|
|
post-ancestor state as the original binop run.
|
|
|
|
This proves the seed-terminator + write-replay pipeline works
|
|
end-to-end across the migration boundary.
|
|
"""
|
|
|
|
checkpointer = InMemorySaver()
|
|
config = {"configurable": {"thread_id": "continue"}}
|
|
|
|
binop = _binop_graph(checkpointer)
|
|
_drive(binop, config, "u", 2)
|
|
|
|
# Pick the oldest settled boundary with non-empty state.
|
|
pre_boundaries = _settled_boundaries(list(binop.get_state_history(config)))
|
|
target_cfg, seed_items = next(
|
|
(cfg, items) for cfg, items in reversed(pre_boundaries) if items
|
|
)
|
|
assert seed_items, "need a non-empty seed boundary"
|
|
|
|
# Migrate and resume from the pre-migration ancestor. `invoke(None,
|
|
# cfg)` replays the pending writes staged at `cfg` under the new
|
|
# channel; the reducer folds those deltas onto the seed.
|
|
delta = _delta_graph(checkpointer)
|
|
result = delta.invoke(None, target_cfg)
|
|
|
|
# The resumed state must include the pre-migration seed items in order.
|
|
result_items = list(result.get("items", []))
|
|
for idx, prefix_item in enumerate(seed_items):
|
|
assert result_items[idx] == prefix_item, (
|
|
f"pre-migration seed item at {idx} not preserved: "
|
|
f"got {result_items[: idx + 1]}, expected {seed_items}"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 4. Base-saver fallback path
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class _ThirdPartyStyleSaver(InMemorySaver):
|
|
"""Simulates a third-party saver that inherits the reference
|
|
`_get_channel_writes_history` implementation from
|
|
`BaseCheckpointSaver` rather than overriding it.
|
|
|
|
We rebind the two methods to the base-class versions (via MRO) so
|
|
the fallback path is exercised even though the storage layer is
|
|
still the in-memory one.
|
|
"""
|
|
|
|
# MRO: [_ThirdPartyStyleSaver, InMemorySaver, BaseCheckpointSaver, ...]
|
|
_get_channel_writes_history = ( # type: ignore[assignment]
|
|
InMemorySaver.__mro__[1]._get_channel_writes_history # type: ignore[attr-defined]
|
|
)
|
|
_aget_channel_writes_history = ( # type: ignore[assignment]
|
|
InMemorySaver.__mro__[1]._aget_channel_writes_history # type: ignore[attr-defined]
|
|
)
|
|
|
|
|
|
def test_base_saver_fallback_matches_optimized_override() -> None:
|
|
"""The reference `BaseCheckpointSaver` implementation must produce
|
|
the same migration behavior as the optimized `InMemorySaver`
|
|
override. We drive the same migration scenario through both savers
|
|
and assert per-snapshot parity in the delta-channel view."""
|
|
|
|
# Fast path: optimized InMemorySaver override.
|
|
fast_saver = InMemorySaver()
|
|
fast_config = {"configurable": {"thread_id": "fast"}}
|
|
fast_binop = _binop_graph(fast_saver)
|
|
_drive(fast_binop, fast_config, "u", 3)
|
|
fast_delta = _delta_graph(fast_saver)
|
|
fast_history = [
|
|
(s.next, list(s.values.get("items", [])))
|
|
for s in fast_delta.get_state_history(fast_config)
|
|
]
|
|
|
|
# Slow path: base-class fallback.
|
|
slow_saver = _ThirdPartyStyleSaver()
|
|
slow_config = {"configurable": {"thread_id": "slow"}}
|
|
slow_binop = _binop_graph(slow_saver)
|
|
_drive(slow_binop, slow_config, "u", 3)
|
|
slow_delta = _delta_graph(slow_saver)
|
|
slow_history = [
|
|
(s.next, list(s.values.get("items", [])))
|
|
for s in slow_delta.get_state_history(slow_config)
|
|
]
|
|
|
|
assert slow_history == fast_history, (
|
|
"base-saver fallback should match optimized-override behavior; "
|
|
f"fast={fast_history}, slow={slow_history}"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 5. Thread isolation under mixed-generation storage
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_delta_and_migrated_threads_do_not_cross_contaminate() -> None:
|
|
"""Two threads sharing a checkpointer — one migrated from
|
|
pre-migration state, one freshly-started under DeltaChannel — must
|
|
maintain independent state. The parent-chain walk in
|
|
`_get_channel_writes_history` must be scoped to the target thread.
|
|
"""
|
|
|
|
checkpointer = InMemorySaver()
|
|
migrated_cfg = {"configurable": {"thread_id": "migrated"}}
|
|
fresh_cfg = {"configurable": {"thread_id": "fresh"}}
|
|
|
|
# Thread A: pre-migration build-up.
|
|
binop = _binop_graph(checkpointer)
|
|
_drive(binop, migrated_cfg, "m", 2)
|
|
|
|
# Thread B: fresh delta-channel run.
|
|
delta = _delta_graph(checkpointer)
|
|
_drive(delta, fresh_cfg, "f", 2)
|
|
|
|
# Thread A: migrate and confirm its state is anchored in its own
|
|
# thread's pre-migration history (tag 'm'), never mixing in tag 'f'.
|
|
migrated_boundaries = _settled_boundaries(
|
|
list(delta.get_state_history(migrated_cfg))
|
|
)
|
|
assert migrated_boundaries, "migrated thread has no settled boundaries"
|
|
for _, items in migrated_boundaries:
|
|
for it in items:
|
|
assert it.startswith("m"), (
|
|
f"migrated thread leaked item from other thread: {it}"
|
|
)
|
|
|
|
# Thread B: settled boundaries must only contain 'f' tags.
|
|
fresh_boundaries = _settled_boundaries(list(delta.get_state_history(fresh_cfg)))
|
|
assert fresh_boundaries, "fresh thread has no settled boundaries"
|
|
for _, items in fresh_boundaries:
|
|
for it in items:
|
|
assert it.startswith("f"), (
|
|
f"fresh thread leaked item from migrated thread: {it}"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 6. Tip-of-pre-migration hydration: the latest checkpoint from a binop-run
|
|
# thread has a real accumulated value in its own `channel_values["items"]`.
|
|
# When hydrated under the delta-channel graph via `get_state(config)` with no
|
|
# `checkpoint_id`, the short-circuit must use that value directly instead of
|
|
# walking ancestors (which would skip the tip's own blob).
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_tip_of_pre_migration_hydrates_directly() -> None:
|
|
"""`graph.get_state(config)` at the latest (pre-migration) checkpoint
|
|
returns the full accumulated list stored in that checkpoint's own
|
|
`channel_values`. The hydration must not walk ancestors past it."""
|
|
|
|
checkpointer = InMemorySaver()
|
|
config = {"configurable": {"thread_id": "tip-sync"}}
|
|
|
|
binop = _binop_graph(checkpointer)
|
|
_drive(binop, config, "u", 3)
|
|
|
|
binop_tip = binop.get_state(config)
|
|
expected_items = list(binop_tip.values.get("items", []))
|
|
assert expected_items == ["u0", "u1", "u2"], (
|
|
f"sanity: pre-migration tip should accumulate all 3 items, got {expected_items}"
|
|
)
|
|
|
|
delta = _delta_graph(checkpointer)
|
|
|
|
snap = delta.get_state(config)
|
|
assert list(snap.values.get("items", [])) == expected_items, (
|
|
f"tip hydration mismatch: expected {expected_items}, "
|
|
f"got {snap.values.get('items', [])}"
|
|
)
|
|
|
|
|
|
async def test_tip_of_pre_migration_hydrates_directly_async() -> None:
|
|
"""Async variant of the tip-of-pre-migration hydration scenario."""
|
|
|
|
checkpointer = InMemorySaver()
|
|
config = {"configurable": {"thread_id": "tip-async"}}
|
|
|
|
binop = _binop_graph(checkpointer)
|
|
await _adrive(binop, config, "u", 3)
|
|
|
|
binop_tip = await binop.aget_state(config)
|
|
expected_items = list(binop_tip.values.get("items", []))
|
|
assert expected_items == ["u0", "u1", "u2"]
|
|
|
|
delta = _delta_graph(checkpointer)
|
|
|
|
snap = await delta.aget_state(config)
|
|
assert list(snap.values.get("items", [])) == expected_items, (
|
|
f"async tip hydration mismatch: expected {expected_items}, "
|
|
f"got {snap.values.get('items', [])}"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 7. `update_state` after migration writes a real value to the new
|
|
# checkpoint's `channel_values` (not a sentinel). Hydration must use it
|
|
# directly — the ancestor walk would skip this blob and return stale state.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_update_state_after_migration_uses_written_value() -> None:
|
|
"""After migrating and running at least one post-migration super-step
|
|
(so the thread's tip has a `DELTA_SENTINEL`), `update_state` writes a
|
|
concrete value to a new checkpoint's `channel_values`. `get_state`
|
|
must reflect that concrete value."""
|
|
|
|
checkpointer = InMemorySaver()
|
|
config = {"configurable": {"thread_id": "update-state"}}
|
|
|
|
# Pre-migration: accumulate a little state.
|
|
binop = _binop_graph(checkpointer)
|
|
_drive(binop, config, "u", 2)
|
|
|
|
# Migrate and run one more super-step so the tip is a post-migration
|
|
# checkpoint with `DELTA_SENTINEL` in its own `channel_values`.
|
|
delta = _delta_graph(checkpointer)
|
|
delta.invoke({"items": ["post"]}, config)
|
|
|
|
# `update_state` writes a concrete value into a new checkpoint's blob
|
|
# via the reducer against the hydrated prior state.
|
|
delta.update_state(config, {"items": ["x", "y"]})
|
|
|
|
snap = delta.get_state(config)
|
|
updated_items = list(snap.values.get("items", []))
|
|
# Must include the "x","y" update; without the hydration fix, the
|
|
# update_state-written blob would be skipped in favor of an ancestor
|
|
# walk, and the update values would disappear.
|
|
assert "x" in updated_items and "y" in updated_items, (
|
|
f"update_state values missing from snapshot: {updated_items}"
|
|
)
|
|
# The "x","y" items should be folded onto the prior accumulated state,
|
|
# not stand alone. This verifies the update-written blob is used
|
|
# directly by `get_state` (no ancestor walk past it).
|
|
assert len(updated_items) >= 4, (
|
|
f"update_state snapshot should preserve pre-update state, got {updated_items}"
|
|
)
|
|
assert updated_items[-2:] == ["x", "y"], (
|
|
f"update_state deltas should be at the tail, got {updated_items}"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 8. Fork from an `update_state` checkpoint: a new run branched off the
|
|
# update_state-produced checkpoint must see that checkpoint's concrete
|
|
# `channel_values` as its base, with new deltas folded on top.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_fork_from_update_state_checkpoint() -> None:
|
|
"""Branching a new run from the checkpoint produced by `update_state`
|
|
must use that checkpoint's concrete blob as the base. Additional
|
|
deltas from the forked run fold onto it through the reducer."""
|
|
|
|
checkpointer = InMemorySaver()
|
|
config = {"configurable": {"thread_id": "fork"}}
|
|
|
|
# Pre-migration build-up, then migrate and add one post-migration step.
|
|
binop = _binop_graph(checkpointer)
|
|
_drive(binop, config, "u", 2)
|
|
delta = _delta_graph(checkpointer)
|
|
delta.invoke({"items": ["post"]}, config)
|
|
|
|
# Apply `update_state` and capture the returned config (references
|
|
# the new checkpoint produced by the update).
|
|
update_cfg = delta.update_state(config, {"items": ["x", "y"]})
|
|
|
|
update_snap = delta.get_state(update_cfg)
|
|
base_items = list(update_snap.values.get("items", []))
|
|
assert "x" in base_items and "y" in base_items, (
|
|
f"update_state values missing from snapshot: {base_items}"
|
|
)
|
|
assert base_items[-2:] == ["x", "y"], (
|
|
f"sanity: update_state deltas should be at the tail, got {base_items}"
|
|
)
|
|
|
|
# Fork: invoke from the update_state checkpoint with a new delta.
|
|
forked = delta.invoke({"items": ["fork0"]}, update_cfg)
|
|
forked_items = list(forked.get("items", []))
|
|
# The fork must see the update_state-written blob as its base (not
|
|
# walk past it), and the new delta must fold on top of it.
|
|
assert forked_items[: len(base_items)] == base_items, (
|
|
f"fork lost update_state base: base={base_items}, forked={forked_items}"
|
|
)
|
|
assert forked_items[-1] == "fork0", f"fork delta not appended: {forked_items}"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 9. Migration from `add_messages` → `DeltaChannel(_messages_delta_reducer)`
|
|
#
|
|
# `add_messages` is the primary real-world use case: it creates a
|
|
# BinaryOperatorAggregate with dedup-by-ID and RemoveMessage semantics.
|
|
# After swapping the annotation to DeltaChannel, pre-migration blobs
|
|
# (plain lists of Message objects) must be used directly as the seed.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _add_messages_graph(checkpointer: Any) -> Any:
|
|
class MessagesState(TypedDict):
|
|
messages: Annotated[list, add_messages]
|
|
|
|
return (
|
|
StateGraph(MessagesState)
|
|
.add_node("noop", _noop)
|
|
.add_edge(START, "noop")
|
|
.add_edge("noop", END)
|
|
.compile(checkpointer=checkpointer)
|
|
)
|
|
|
|
|
|
def _delta_messages_graph(checkpointer: Any) -> Any:
|
|
class DeltaMessagesState(TypedDict):
|
|
messages: Annotated[list, DeltaChannel(_messages_delta_reducer)]
|
|
|
|
return (
|
|
StateGraph(DeltaMessagesState)
|
|
.add_node("noop", _noop)
|
|
.add_edge(START, "noop")
|
|
.add_edge("noop", END)
|
|
.compile(checkpointer=checkpointer)
|
|
)
|
|
|
|
|
|
def test_add_messages_to_delta_migration_preserves_message_history() -> None:
|
|
"""Migration from `add_messages` to `DeltaChannel(_messages_delta_reducer)`
|
|
preserves message ordering and IDs at both the tip and settled ancestor
|
|
boundaries.
|
|
|
|
The pre-migration blob is a plain list of Message objects; DeltaChannel
|
|
must use it directly as the seed without walking ancestors past it.
|
|
"""
|
|
checkpointer = InMemorySaver()
|
|
config = {"configurable": {"thread_id": "add-messages-migration"}}
|
|
|
|
pre_graph = _add_messages_graph(checkpointer)
|
|
pre_graph.invoke({"messages": [HumanMessage(content="hello", id="h1")]}, config)
|
|
pre_graph.invoke({"messages": [AIMessage(content="hi", id="a1")]}, config)
|
|
pre_graph.invoke({"messages": [HumanMessage(content="thanks", id="h2")]}, config)
|
|
|
|
pre_tip = pre_graph.get_state(config)
|
|
assert [m.id for m in pre_tip.values["messages"]] == ["h1", "a1", "h2"]
|
|
|
|
delta_graph = _delta_messages_graph(checkpointer)
|
|
|
|
# Tip: latest checkpoint has a full list blob — must use it directly.
|
|
snap = delta_graph.get_state(config)
|
|
assert [m.id for m in snap.values["messages"]] == ["h1", "a1", "h2"], (
|
|
f"tip hydration mismatch: got {[m.id for m in snap.values['messages']]}"
|
|
)
|
|
|
|
# Settled ancestor boundaries must also match.
|
|
pre_settled = [
|
|
[m.id for m in s.values.get("messages", [])]
|
|
for s in pre_graph.get_state_history(config)
|
|
if s.next == ("__start__",)
|
|
]
|
|
delta_settled = [
|
|
[m.id for m in s.values.get("messages", [])]
|
|
for s in delta_graph.get_state_history(config)
|
|
if s.next == ("__start__",)
|
|
]
|
|
assert delta_settled == pre_settled, (
|
|
f"settled boundary mismatch after migration: "
|
|
f"pre={pre_settled}, delta={delta_settled}"
|
|
)
|
|
|
|
|
|
async def test_add_messages_to_delta_migration_preserves_message_history_async() -> (
|
|
None
|
|
):
|
|
"""Async variant of the add_messages migration test."""
|
|
checkpointer = InMemorySaver()
|
|
config = {"configurable": {"thread_id": "add-messages-migration-async"}}
|
|
|
|
pre_graph = _add_messages_graph(checkpointer)
|
|
await pre_graph.ainvoke(
|
|
{"messages": [HumanMessage(content="hello", id="h1")]}, config
|
|
)
|
|
await pre_graph.ainvoke({"messages": [AIMessage(content="hi", id="a1")]}, config)
|
|
|
|
delta_graph = _delta_messages_graph(checkpointer)
|
|
snap = await delta_graph.aget_state(config)
|
|
assert [m.id for m in snap.values["messages"]] == ["h1", "a1"], (
|
|
f"async tip hydration mismatch: got {[m.id for m in snap.values['messages']]}"
|
|
)
|