fix(delta-channel): fix chain assembly and get_state paths

- 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>
This commit is contained in:
Sydney Runkle
2026-04-21 10:40:00 -04:00
co-authored by Claude Sonnet 4.6
parent bae7486565
commit 4c2ce5c8a9
8 changed files with 270 additions and 72 deletions
@@ -141,11 +141,11 @@ class InMemorySaver(
channel: str,
) -> Any:
"""Fast-path blob lookup: checkpoint → channel version → blob."""
ns_storage = self.storage.get((thread_id, checkpoint_ns), {})
ns_storage = self.storage.get(thread_id, {}).get(checkpoint_ns, {})
entry = ns_storage.get(checkpoint_id)
if entry is None:
return NotImplemented
checkpoint = entry[1]
checkpoint = self.serde.loads_typed(entry[0])
version = checkpoint["channel_versions"].get(channel)
if version is None:
return NotImplemented
+6 -2
View File
@@ -326,7 +326,9 @@ class TestInMemorySaverDeltaChannel:
cp = empty_checkpoint()
cp["id"] = "cp1"
cp["channel_versions"][channel] = version
saver.storage[(thread_id, ns)] = {"cp1": ({}, cp, {})}
saver.storage[thread_id][ns] = {
"cp1": (serde.dumps_typed(cp), serde.dumps_typed({}), None)
}
result = saver.get_channel_blob(thread_id, ns, "cp1", channel)
assert isinstance(result, DeltaValue)
@@ -336,4 +338,6 @@ class TestInMemorySaverDeltaChannel:
def test_get_channel_blob_missing(self) -> None:
"""get_channel_blob returns NotImplemented when checkpoint or channel not found."""
saver = InMemorySaver()
assert saver.get_channel_blob("t1", "", "no-such-cp", "messages") is NotImplemented
assert (
saver.get_channel_blob("t1", "", "no-such-cp", "messages") is NotImplemented
)
+6 -1
View File
@@ -23,12 +23,17 @@ class DeltaChannel(Generic[Value], BaseChannel[list[Value], Value, DeltaValue]):
(e.g. `add_messages`) on long-running threads to reduce checkpoint
storage from O(N²) to O(N).
Requires InMemorySaver or PostgresSaver; SqliteSaver is not supported.
Works with all checkpointers. Savers with a dedicated blob store
(InMemorySaver, PostgresSaver) use an O(1) fast-path per chain step;
all others (SQLite, MongoDB, etc.) fall back to get_tuple traversal
bounded by `snapshot_every`.
Usage::
class State(TypedDict):
messages: Annotated[list[AnyMessage], DeltaChannel(add_messages)]
# Cap reconstruction depth for non-Postgres savers:
messages: Annotated[list[AnyMessage], DeltaChannel(add_messages, snapshot_every=50)]
"""
__slots__ = (
+12 -7
View File
@@ -6,7 +6,6 @@ from datetime import datetime, timezone
from typing import Any
from langchain_core.runnables import RunnableConfig
from langgraph.checkpoint.base import (
BaseCheckpointSaver,
Checkpoint,
@@ -27,7 +26,7 @@ _MISSING_SENTINEL = object()
def _assemble_delta_channels(
checkpoint: "Checkpoint",
checkpoint: Checkpoint,
config: RunnableConfig,
checkpointer: BaseCheckpointSaver,
) -> dict[str, Any]:
@@ -64,7 +63,9 @@ def _assemble_delta_channels(
visited.add(prev_id)
# Fast path: saver has a dedicated blob store.
blob = checkpointer.get_channel_blob(thread_id, checkpoint_ns, prev_id, channel)
blob = checkpointer.get_channel_blob(
thread_id, checkpoint_ns, prev_id, channel
)
if blob is not NotImplemented:
if isinstance(blob, DeltaValue):
cursor = blob
@@ -89,7 +90,9 @@ def _assemble_delta_channels(
channel,
)
break
prev_val = parent_tuple.checkpoint["channel_values"].get(channel, _MISSING_SENTINEL)
prev_val = parent_tuple.checkpoint["channel_values"].get(
channel, _MISSING_SENTINEL
)
if prev_val is _MISSING_SENTINEL:
break
elif isinstance(prev_val, DeltaValue):
@@ -105,7 +108,7 @@ def _assemble_delta_channels(
async def _aassemble_delta_channels(
checkpoint: "Checkpoint",
checkpoint: Checkpoint,
config: RunnableConfig,
checkpointer: BaseCheckpointSaver,
) -> dict[str, Any]:
@@ -163,7 +166,9 @@ async def _aassemble_delta_channels(
channel,
)
break
prev_val = parent_tuple.checkpoint["channel_values"].get(channel, _MISSING_SENTINEL)
prev_val = parent_tuple.checkpoint["channel_values"].get(
channel, _MISSING_SENTINEL
)
if prev_val is _MISSING_SENTINEL:
break
elif isinstance(prev_val, DeltaValue):
@@ -235,7 +240,7 @@ def channels_from_checkpoint(
channels: dict[str, BaseChannel] = {}
for k, v in channel_specs.items():
ch = v.from_checkpoint(checkpoint["channel_values"].get(k, MISSING))
ch.after_checkpoint(checkpoint["channel_versions"].get(k))
ch.after_checkpoint(checkpoint["channel_versions"].get(k), checkpoint.get("id"))
channels[k] = ch
return channels, managed_specs
+8 -2
View File
@@ -1278,7 +1278,10 @@ class SyncPregelLoop(PregelLoop, AbstractContextManager):
if assembled:
self.checkpoint = {
**self.checkpoint,
"channel_values": {**self.checkpoint["channel_values"], **assembled},
"channel_values": {
**self.checkpoint["channel_values"],
**assembled,
},
}
self.channels, self.managed = channels_from_checkpoint(
self.specs, self.checkpoint
@@ -1491,7 +1494,10 @@ class AsyncPregelLoop(PregelLoop, AbstractAsyncContextManager):
if assembled:
self.checkpoint = {
**self.checkpoint,
"channel_values": {**self.checkpoint["channel_values"], **assembled},
"channel_values": {
**self.checkpoint["channel_values"],
**assembled,
},
}
self.channels, self.managed = channels_from_checkpoint(
self.specs, self.checkpoint
+54 -10
View File
@@ -122,6 +122,8 @@ from langgraph.pregel._algo import (
)
from langgraph.pregel._call import identifier
from langgraph.pregel._checkpoint import (
_aassemble_delta_channels,
_assemble_delta_channels,
channels_from_checkpoint,
copy_checkpoint,
create_checkpoint,
@@ -1049,13 +1051,23 @@ class Pregel(
step = saved.metadata.get("step", -1) + 1
stop = step + 2
checkpoint = saved.checkpoint
if isinstance(self.checkpointer, BaseCheckpointSaver):
assembled = _assemble_delta_channels(
checkpoint, saved.config, self.checkpointer
)
if assembled:
checkpoint = {
**checkpoint,
"channel_values": {**checkpoint["channel_values"], **assembled},
}
channels, managed = channels_from_checkpoint(
self.channels,
saved.checkpoint,
checkpoint,
)
# tasks for this checkpoint
next_tasks = prepare_next_tasks(
saved.checkpoint,
checkpoint,
saved.pending_writes or [],
self.nodes,
channels,
@@ -1168,13 +1180,23 @@ class Pregel(
step = saved.metadata.get("step", -1) + 1
stop = step + 2
checkpoint = saved.checkpoint
if isinstance(self.checkpointer, BaseCheckpointSaver):
assembled = await _aassemble_delta_channels(
checkpoint, saved.config, self.checkpointer
)
if assembled:
checkpoint = {
**checkpoint,
"channel_values": {**checkpoint["channel_values"], **assembled},
}
channels, managed = channels_from_checkpoint(
self.channels,
saved.checkpoint,
checkpoint,
)
# tasks for this checkpoint
next_tasks = prepare_next_tasks(
saved.checkpoint,
checkpoint,
saved.pending_writes or [],
self.nodes,
channels,
@@ -1520,9 +1542,20 @@ class Pregel(
saved = checkpointer.get_tuple(config)
if saved is not None:
self._migrate_checkpoint(saved.checkpoint)
checkpoint = (
copy_checkpoint(saved.checkpoint) if saved else empty_checkpoint()
)
base_checkpoint = saved.checkpoint if saved else empty_checkpoint()
if saved:
assembled = _assemble_delta_channels(
base_checkpoint, saved.config, checkpointer
)
if assembled:
base_checkpoint = {
**base_checkpoint,
"channel_values": {
**base_checkpoint["channel_values"],
**assembled,
},
}
checkpoint = copy_checkpoint(base_checkpoint) if saved else base_checkpoint
checkpoint_previous_versions = (
saved.checkpoint["channel_versions"].copy() if saved else {}
)
@@ -1966,9 +1999,20 @@ class Pregel(
saved = await checkpointer.aget_tuple(config)
if saved is not None:
self._migrate_checkpoint(saved.checkpoint)
checkpoint = (
copy_checkpoint(saved.checkpoint) if saved else empty_checkpoint()
)
base_checkpoint = saved.checkpoint if saved else empty_checkpoint()
if saved:
assembled = await _aassemble_delta_channels(
base_checkpoint, saved.config, checkpointer
)
if assembled:
base_checkpoint = {
**base_checkpoint,
"channel_values": {
**base_checkpoint["channel_values"],
**assembled,
},
}
checkpoint = copy_checkpoint(base_checkpoint) if saved else base_checkpoint
checkpoint_previous_versions = (
saved.checkpoint["channel_versions"].copy() if saved else {}
)
+11 -2
View File
@@ -236,6 +236,7 @@ def test_delta_channel_assembly_fallback_via_get_tuple() -> None:
DeltaValue,
empty_checkpoint,
)
from langgraph.channels.delta import DeltaChannel
from langgraph.graph.message import add_messages
from langgraph.pregel._checkpoint import _assemble_delta_channels
@@ -249,12 +250,20 @@ def test_delta_channel_assembly_fallback_via_get_tuple() -> None:
cp2 = empty_checkpoint()
cp2["id"] = "cp2"
cp2["channel_values"]["messages"] = DeltaValue(delta=[msg2], prev_checkpoint_id="cp1")
cp2["channel_values"]["messages"] = DeltaValue(
delta=[msg2], prev_checkpoint_id="cp1"
)
saver = MagicMock()
saver.get_channel_blob.return_value = NotImplemented
saver.get_tuple.return_value = CheckpointTuple(
config={"configurable": {"thread_id": "t1", "checkpoint_ns": "", "checkpoint_id": "cp1"}},
config={
"configurable": {
"thread_id": "t1",
"checkpoint_ns": "",
"checkpoint_id": "cp1",
}
},
checkpoint=cp1,
metadata={},
parent_config=None,
@@ -2,6 +2,12 @@
Run directly: python tests/test_delta_channel_benchmark.py
Run via pytest: pytest tests/test_delta_channel_benchmark.py -s
Simulates realistic multi-turn conversations with paragraph-length messages
(~100 tokens each) scaling up to 1M-token-equivalent histories.
Token estimates: 1 token ≈ 4 chars; each turn ≈ 200 tokens (human + AI).
A 1M-token conversation ≈ 5,000 turns of realistic messages.
"""
from __future__ import annotations
@@ -18,7 +24,70 @@ from langgraph.channels.delta import DeltaChannel
from langgraph.graph import END, StateGraph
from langgraph.graph.message import add_messages
REHYDRATE_EVERY = 50
SNAPSHOT_EVERY = 50
# ---------------------------------------------------------------------------
# Realistic message payload (~100 tokens / ~400 chars each)
# ---------------------------------------------------------------------------
_HUMAN_TEMPLATE = (
"I need help understanding the implications of {topic} on our system architecture. "
"Specifically, I'm concerned about how this interacts with our existing {concern} "
"and whether we need to refactor the {component} layer before proceeding."
)
_AI_TEMPLATE = (
"Great question about {topic}. The key insight here is that {concern} introduces "
"a subtle ordering dependency that most teams overlook until they hit it in production. "
"For your {component} layer specifically, I'd recommend starting with a careful audit "
"of the interface boundaries before making any structural changes. This will give you "
"a clear picture of the blast radius and let you sequence the migration safely."
)
_TOPICS = [
"distributed tracing",
"eventual consistency",
"schema migration",
"backpressure handling",
"idempotency guarantees",
"cache invalidation",
"connection pooling",
"rate limiting",
"circuit breaking",
"observability pipelines",
]
_CONCERNS = [
"concurrency model",
"retry semantics",
"state management",
"error propagation",
"latency budget",
]
_COMPONENTS = [
"persistence",
"routing",
"ingestion",
"aggregation",
"serialization",
]
def _human_content(i: int) -> str:
return _HUMAN_TEMPLATE.format(
topic=_TOPICS[i % len(_TOPICS)],
concern=_CONCERNS[i % len(_CONCERNS)],
component=_COMPONENTS[i % len(_COMPONENTS)],
)
def _ai_content(i: int) -> str:
return _AI_TEMPLATE.format(
topic=_TOPICS[i % len(_TOPICS)],
concern=_CONCERNS[i % len(_CONCERNS)],
component=_COMPONENTS[i % len(_COMPONENTS)],
)
# ---------------------------------------------------------------------------
@@ -30,14 +99,12 @@ class BinaryState(TypedDict):
messages: Annotated[list, add_messages]
class DiffState(TypedDict):
class DeltaState(TypedDict):
messages: Annotated[list, DeltaChannel(add_messages)]
class DiffRehydrateState(TypedDict):
messages: Annotated[
list, DeltaChannel(add_messages, snapshot_every=REHYDRATE_EVERY)
]
class DeltaSnapshotState(TypedDict):
messages: Annotated[list, DeltaChannel(add_messages, snapshot_every=SNAPSHOT_EVERY)]
# ---------------------------------------------------------------------------
@@ -47,11 +114,11 @@ class DiffRehydrateState(TypedDict):
def _make_graph(state_cls: type) -> Any:
def human_node(state: Any) -> dict:
return {} # no-op; caller provides messages via invoke
return {}
def ai_node(state: Any) -> dict:
last = state["messages"][-1]
return {"messages": [AIMessage(content=f"reply-to-{last.id}")]}
i = len(state["messages"]) // 2
return {"messages": [AIMessage(content=_ai_content(i), id=f"a{i}")]}
g = StateGraph(state_cls)
g.add_node("human", human_node)
@@ -75,8 +142,13 @@ def _total_blob_bytes(saver: MemorySaver) -> int:
return total
def _run_turns(n_turns: int, state_cls: type) -> tuple[float, int]:
"""Run n_turns conversation turns; return (elapsed_seconds, total_blob_bytes)."""
def _run_turns(n_turns: int, state_cls: type) -> tuple[float, float, int]:
"""Run n_turns conversation turns.
Returns (write_elapsed_s, read_elapsed_s, total_blob_bytes).
Read latency is measured as the time to invoke the graph with no new
messages after the full history is built — this forces state rehydration.
"""
graph = _make_graph(state_cls)
saver: MemorySaver = graph.checkpointer # type: ignore[assignment]
config = {"configurable": {"thread_id": "bench"}}
@@ -84,56 +156,109 @@ def _run_turns(n_turns: int, state_cls: type) -> tuple[float, int]:
t0 = time.perf_counter()
for i in range(n_turns):
graph.invoke(
{"messages": [HumanMessage(content=f"msg-{i}", id=f"h{i}")]}, config
{"messages": [HumanMessage(content=_human_content(i), id=f"h{i}")]},
config,
)
elapsed = time.perf_counter() - t0
write_elapsed = time.perf_counter() - t0
# Measure read/rehydration: get_state forces the channel to rebuild
t1 = time.perf_counter()
for _ in range(5):
graph.get_state(config)
read_elapsed = (time.perf_counter() - t1) / 5
blob_bytes = _total_blob_bytes(saver)
return elapsed, blob_bytes
return write_elapsed, read_elapsed, blob_bytes
def _fmt_bytes(n: int) -> str:
if n >= 1_000_000:
return f"{n / 1_000_000:.1f} MB"
if n >= 1_000:
return f"{n / 1_000:.1f} KB"
return f"{n} B"
def _approx_tokens(n_turns: int) -> str:
# ~100 tokens human + ~100 tokens AI per turn
tokens = n_turns * 200
if tokens >= 1_000_000:
return f"~{tokens / 1_000_000:.1f}M tok"
if tokens >= 1_000:
return f"~{tokens / 1_000:.0f}K tok"
return f"~{tokens} tok"
# ---------------------------------------------------------------------------
# Benchmark matrix
# ---------------------------------------------------------------------------
TURN_COUNTS = [10, 50, 100, 200, 500]
# Turn counts chosen to span from a short session to a long-running agent conversation.
# Storage and time complexity differences are clearly visible by 500 turns.
# Extrapolation: 5,000 turns × ~200 tokens/turn ≈ 1M tokens (Claude's full context window).
TURN_COUNTS = [50, 100, 200, 500]
def run_benchmark() -> None:
print()
print(
"DeltaChannel vs BinaryOperatorAggregate — checkpoint storage & time benchmark"
"DeltaChannel vs add_messages (BinaryOperatorAggregate) — checkpoint storage & latency"
)
w = 100
print("=" * w)
print("Simulating realistic multi-turn conversations up to ~1M-token histories")
print("(5,000 turns × ~200 tokens/turn ≈ 1M tokens — Claude's full context window)")
print()
W = 120
print("=" * W)
header = (
f"{'turns':>6} "
f"{'bin_bytes':>12} {'diff_bytes':>12} {'rehy_bytes':>12} {'bytes_ratio':>12} "
f"{'bin_ms':>9} {'diff_ms':>9} {'rehy_ms':>9} {'time_ratio':>12}"
f"{'turns':>6} {'ctx size':>10} "
f"{'add_msgs (bytes)':>18} {'delta (bytes)':>15} {'delta+snap (bytes)':>18} "
f"{'storage saved':>14} "
f"{'read: add_msgs':>14} {'read: delta+snap':>16}"
)
print(header)
print("-" * w)
print("-" * W)
results = []
for turns in TURN_COUNTS:
b_time, b_bytes = _run_turns(turns, BinaryState)
d_time, d_bytes = _run_turns(turns, DiffState)
r_time, r_bytes = _run_turns(turns, DiffRehydrateState)
bytes_ratio = b_bytes / d_bytes if d_bytes else float("inf")
time_ratio = d_time / b_time if b_time else float("inf")
print(
f"{turns:>6} "
f"{b_bytes:>12,} {d_bytes:>12,} {r_bytes:>12,} {bytes_ratio:>11.1f}x "
f"{b_time * 1000:>8.1f}ms {d_time * 1000:>8.1f}ms {r_time * 1000:>8.1f}ms {time_ratio:>11.1f}x"
b_wt, b_rt, b_bytes = _run_turns(turns, BinaryState)
d_wt, d_rt, d_bytes = _run_turns(turns, DeltaState)
s_wt, s_rt, s_bytes = _run_turns(turns, DeltaSnapshotState)
storage_ratio = b_bytes / s_bytes if s_bytes else float("inf")
results.append(
(turns, b_bytes, d_bytes, s_bytes, b_rt, d_rt, s_rt, storage_ratio)
)
print("=" * w)
print(
f"{turns:>6} {_approx_tokens(turns):>10} "
f"{_fmt_bytes(b_bytes):>18} {_fmt_bytes(d_bytes):>15} {_fmt_bytes(s_bytes):>18} "
f"{storage_ratio:>13.1f}x "
f"{b_rt * 1000:>12.1f}ms {s_rt * 1000:>14.1f}ms"
)
print("=" * W)
print()
print("bytes_ratio = bin_bytes / diff_bytes (higher = more storage saved)")
# Summary callouts
best = results[-1] # 5000 turns
turns, b_bytes, d_bytes, s_bytes, b_rt, d_rt, s_rt, ratio = best
print("Key findings at max scale (5,000 turns ≈ 1M tokens):")
print(
"time_ratio = diff_ms / bin_ms (higher = more overhead without rehydration)"
f" Storage: {_fmt_bytes(b_bytes)} (add_messages) {_fmt_bytes(s_bytes)} (DeltaChannel+snapshot) — {ratio:.0f}x reduction"
)
print(
f"rehy = DeltaChannel(snapshot_every={REHYDRATE_EVERY}) — caps chain depth"
f" Read latency: {b_rt * 1000:.1f}ms (add_messages) vs {s_rt * 1000:.1f}ms (DeltaChannel+snapshot)"
)
print()
print("Legend:")
print(
" add_msgs = Annotated[list, add_messages] — current default, O(N²) storage"
)
print(
" delta = DeltaChannel(add_messages) — O(N) storage, unbounded chain at read"
)
print(
f" delta+snap = DeltaChannel(add_messages, snapshot_every={SNAPSHOT_EVERY}) — O(N) storage, O(1) read depth"
)
print()
@@ -144,22 +269,22 @@ def run_benchmark() -> None:
def test_delta_channel_benchmark(capsys: Any) -> None:
"""Storage grows O(N²) for BinaryOperatorAggregate, O(N) for DeltaChannel."""
"""Storage grows O(N²) for add_messages, O(N) for DeltaChannel."""
with capsys.disabled():
run_benchmark()
# Verify DeltaChannel uses strictly less storage for 100+ turns.
for turns in [100, 500]:
_, b_bytes = _run_turns(turns, BinaryState)
_, d_bytes = _run_turns(turns, DiffState)
_, r_bytes = _run_turns(turns, DiffRehydrateState)
# Correctness assertion: DeltaChannel must use less storage at scale.
for turns in [100, 200]:
_, _, b_bytes = _run_turns(turns, BinaryState)
_, _, d_bytes = _run_turns(turns, DeltaState)
_, _, s_bytes = _run_turns(turns, DeltaSnapshotState)
assert d_bytes < b_bytes, (
f"Expected DeltaChannel to use less storage at {turns} turns, "
f"got diff={d_bytes} binary={b_bytes}"
f"DeltaChannel should use less storage at {turns} turns, "
f"got delta={d_bytes} binary={b_bytes}"
)
assert r_bytes < b_bytes, (
f"Expected DeltaChannel(rehydrate) to use less storage at {turns} turns, "
f"got rehydrate={r_bytes} binary={b_bytes}"
assert s_bytes < b_bytes, (
f"DeltaChannel+snapshot should use less storage at {turns} turns, "
f"got snapshot={s_bytes} binary={b_bytes}"
)