chore(delta-channel): remove snapshot_every — simpler design, better storage savings

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Sydney Runkle
2026-04-30 14:49:05 -04:00
co-authored by Claude Sonnet 4.6
parent eb0687a108
commit 57e53fc600
6 changed files with 21 additions and 212 deletions
@@ -400,10 +400,7 @@ class AsyncPostgresSaver(BasePostgresSaver):
channel: str,
cur: Any,
) -> list[Any]:
"""Fetch writes for `channel` across the checkpoint ancestor chain, oldest→newest (async).
Two queries instead of a recursive CTE — see sync version for rationale.
"""
"""Async version of _get_channel_writes_cur — see sync version for rationale."""
await cur.execute(
"SELECT checkpoint_id, parent_checkpoint_id FROM checkpoints "
"WHERE thread_id = %s AND checkpoint_ns = %s",
@@ -427,9 +424,8 @@ class AsyncPostgresSaver(BasePostgresSaver):
"ORDER BY task_id, idx",
(thread_id, checkpoint_ns, channel, ancestor_ids),
)
rows = await cur.fetchall()
writes_by_cp: dict[str, list[tuple[str, bytes]]] = defaultdict(list)
for row in rows:
for row in await cur.fetchall():
writes_by_cp[row["checkpoint_id"]].append((row["type"], row["blob"]))
result = []
for cid in reversed(ancestor_ids):
@@ -225,8 +225,8 @@ class BasePostgresSaver(BaseCheckpointSaver[str]):
) -> list[Any]:
"""Fetch writes for `channel` across the checkpoint ancestor chain, oldest→newest.
Two queries instead of a recursive CTE:
1. Fetch all (checkpoint_id, parent_checkpoint_id) for the thread — cheap, just IDs.
Two queries:
1. Fetch all (checkpoint_id, parent_checkpoint_id) for the thread — cheap, IDs only.
2. Walk the ancestor chain in Python, then fetch writes with a plain ANY() filter.
"""
cur.execute(
@@ -237,9 +237,6 @@ class BasePostgresSaver(BaseCheckpointSaver[str]):
parent_map: dict[str, str | None] = {
row["checkpoint_id"]: row["parent_checkpoint_id"] for row in cur.fetchall()
}
# Walk newest→oldest starting from the current checkpoint's parent.
# Writes stored under checkpoint C produced the state *after* C, so we
# want ancestors of the current checkpoint (not the checkpoint itself).
ancestor_ids: list[str] = []
cid: str | None = parent_map.get(checkpoint_id)
while cid is not None:
@@ -257,7 +254,6 @@ class BasePostgresSaver(BaseCheckpointSaver[str]):
writes_by_cp: dict[str, list[tuple[str, bytes]]] = defaultdict(list)
for row in cur.fetchall():
writes_by_cp[row["checkpoint_id"]].append((row["type"], row["blob"]))
# ancestor_ids is newest→oldest; replay oldest→newest
result = []
for cid in reversed(ancestor_ids):
for type_tag, blob in writes_by_cp.get(cid, []):
@@ -153,7 +153,7 @@ class InMemorySaver(
checkpoint_ns = config["configurable"].get("checkpoint_ns", "")
checkpoint_id = config["configurable"].get("checkpoint_id", "")
ns_storage = self.storage.get(thread_id, {}).get(checkpoint_ns, {})
# Walk the parent chain, collecting checkpoint IDs oldest→newest.
# Walk the parent chain newest→oldest collecting checkpoint IDs.
chain: list[str] = []
current: str | None = checkpoint_id
while current is not None:
@@ -163,7 +163,7 @@ class InMemorySaver(
chain.append(current)
_, _, parent = entry
current = parent
# Collect writes for `channel` from each checkpoint in oldest→newest order.
# Collect writes oldest→newest.
result: list[Any] = []
for cp_id in reversed(chain):
step_writes = self.writes.get((thread_id, checkpoint_ns, cp_id), {})
+5 -4
View File
@@ -41,8 +41,6 @@ class DeltaChannel(
def __init__(
self,
operator: Callable[[list[Value], Any], list[Value]],
*,
snapshot_every: int | None = None, # reserved for future use
) -> None:
super().__init__(list)
self.operator = operator
@@ -83,8 +81,11 @@ class DeltaChannel(
except Exception:
new.value = []
elif isinstance(checkpoint, list):
# Flat list of individual write values (oldest→newest) from get_channel_writes.
value: Any = new.typ()
# Flat list of write values (oldest→newest) from get_channel_writes.
try:
value: Any = new.typ()
except Exception:
value = []
for write in checkpoint:
value = new.operator(value, write)
new.value = value
@@ -219,7 +219,7 @@ def _approx_tokens(n_turns: int) -> str:
# Turn counts chosen to demonstrate O(N²) vs O(N) storage growth without running too long.
# Extrapolation: 5,000 turns × ~200 tokens/turn ≈ 1M tokens (Claude's full context window).
TURN_COUNTS = [10, 25, 50, 100]
TURN_COUNTS = [10, 25, 50, 100, 500]
def _checkpointer_factories() -> list[tuple[str, Any]]:
@@ -240,6 +240,7 @@ def run_benchmark() -> None:
if _POSTGRES_AVAILABLE:
try:
import psycopg
psycopg.connect(_POSTGRES_URI).close()
checkpointers.append(("Postgres (recursive CTE)", "postgres"))
except Exception:
@@ -292,7 +293,7 @@ def _run_benchmark_for_checkpointer(cp_hint: Any) -> None:
)
print("-" * W)
storage_results = []
for turns, b_bytes, d_bytes, *_ in rows:
for turns, b_bytes, d_bytes, b_rt, d_rt in rows:
if b_bytes < 0:
print(
f"{turns:>6} {_approx_tokens(turns):>10} {'n/a':>12} {'n/a':>12} {'n/a':>8}"
@@ -311,9 +312,7 @@ def _run_benchmark_for_checkpointer(cp_hint: Any) -> None:
# ── Table 2: Read latency ─────────────────────────────────────────────────
print("Read latency (avg of 5 get_state calls)")
print("=" * W)
print(
f"{'turns':>6} {'ctx size':>10} {'add_msgs':>12} {'delta':>12}"
)
print(f"{'turns':>6} {'ctx size':>10} {'add_msgs':>12} {'delta':>12}")
print("-" * W)
for turns, b_bytes, d_bytes, b_rt, d_rt in rows:
print(
@@ -324,9 +323,9 @@ def _run_benchmark_for_checkpointer(cp_hint: Any) -> None:
print()
if storage_results:
best = storage_results[-1]
turns, b_bytes, d_bytes, ratio = best
_, _, _, b_rt, d_rt = rows[-1]
turns, b_bytes, d_bytes, ratio = storage_results[-1]
b_rt = rows[-1][-2]
d_rt = rows[-1][-1]
print(
f"At {turns} turns: {_fmt_bytes(b_bytes)}{_fmt_bytes(d_bytes)} ({ratio:.0f}x less storage); "
f"read {b_rt * 1000:.1f}ms → {d_rt * 1000:.1f}ms"
@@ -335,7 +334,9 @@ def _run_benchmark_for_checkpointer(cp_hint: Any) -> None:
print("Legend:")
print(" add_msgs = Annotated[list, add_messages] — O(N²) storage")
print(" delta = DeltaChannel(add_messages) — O(N) storage, reconstructed from writes")
print(
" delta = DeltaChannel(add_messages) — O(N) storage, full chain replay"
)
print()
@@ -1,185 +0,0 @@
"""Sweep snapshot_every values to find the storage vs. time-travel tradeoff.
Run directly: python tests/test_rehydrate_sweep.py
Run via pytest: pytest tests/test_rehydrate_sweep.py -s
"""
from __future__ import annotations
import sys
import time
from typing import Annotated, Any
from langchain_core.messages import AIMessage, HumanMessage
from langgraph.checkpoint.memory import MemorySaver
from typing_extensions import TypedDict
from langgraph.channels.delta import DeltaChannel
from langgraph.graph import END, StateGraph
from langgraph.graph.message import add_messages
# ---------------------------------------------------------------------------
# Config
# ---------------------------------------------------------------------------
REHYDRATE_SWEEP = [5, 10, 25, 50, 100, None] # None = no rehydration (pure diff)
TURN_COUNTS = [50, 100, 250, 500]
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_state(snapshot_every: int | None) -> type:
channel = DeltaChannel(add_messages, snapshot_every=snapshot_every)
return TypedDict("S", {"messages": Annotated[list, channel]})
def _make_graph(state_cls: type) -> Any:
def human_node(state: Any) -> dict:
return {}
def ai_node(state: Any) -> dict:
last = state["messages"][-1]
return {"messages": [AIMessage(content=f"reply-to-{last.id}")]}
g = StateGraph(state_cls)
g.add_node("human", human_node)
g.add_node("ai", ai_node)
g.add_edge("human", "ai")
g.add_edge("ai", END)
g.set_entry_point("human")
return g.compile(checkpointer=MemorySaver())
def _total_blob_bytes(saver: MemorySaver) -> int:
total = 0
for (_, _, _, _), (type_tag, blob) in saver.blobs.items():
if blob is not None:
total += len(blob)
return total
def _measure_time_travel_ms(graph: Any, config: dict) -> float:
"""Time how long it takes to get state at the very first checkpoint (worst case)."""
history = list(graph.get_state_history(config))
if not history:
return 0.0
oldest = history[-1]
t0 = time.perf_counter()
graph.get_state(oldest.config)
return (time.perf_counter() - t0) * 1000
def _run(n_turns: int, snapshot_every: int | None) -> tuple[float, int, float]:
"""Returns (write_ms, blob_bytes, time_travel_ms)."""
state_cls = _make_state(snapshot_every)
graph = _make_graph(state_cls)
saver: MemorySaver = graph.checkpointer # type: ignore[assignment]
config = {"configurable": {"thread_id": "sweep"}}
t0 = time.perf_counter()
for i in range(n_turns):
graph.invoke(
{"messages": [HumanMessage(content=f"msg-{i}", id=f"h{i}")]}, config
)
write_ms = (time.perf_counter() - t0) * 1000
blob_bytes = _total_blob_bytes(saver)
tt_ms = _measure_time_travel_ms(graph, config)
return write_ms, blob_bytes, tt_ms
# ---------------------------------------------------------------------------
# ASCII sparkline
# ---------------------------------------------------------------------------
def _sparkline(values: list[float], width: int = 20) -> str:
bars = " ▁▂▃▄▅▆▇█"
lo, hi = min(values), max(values)
span = hi - lo or 1
chars = [bars[round((v - lo) / span * (len(bars) - 1))] for v in values]
return "".join(chars).ljust(width)
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def run_sweep() -> None:
label = {v: (str(v) if v is not None else "None(∞)") for v in REHYDRATE_SWEEP}
print()
print("snapshot_every sweep — storage vs time-travel cost")
print("=" * 90)
for turns in TURN_COUNTS:
print(f"\n--- {turns} turns ---")
col_w = 12
header = (
f"{'snapshot_every':>18} "
f"{'blob_bytes':>{col_w}} "
f"{'write_ms':>{col_w}} "
f"{'time_travel_ms':>{col_w}}"
)
print(header)
print("-" * 60)
tt_vals: list[float] = []
byte_vals: list[int] = []
write_vals: list[float] = []
rows: list[tuple] = []
for rv in REHYDRATE_SWEEP:
write_ms, blob_bytes, tt_ms = _run(turns, rv)
rows.append((rv, blob_bytes, write_ms, tt_ms))
byte_vals.append(blob_bytes)
write_vals.append(write_ms)
tt_vals.append(tt_ms)
for rv, blob_bytes, write_ms, tt_ms in rows:
print(
f"{label[rv]:>18} "
f"{blob_bytes:>{col_w},} "
f"{write_ms:>{col_w}.1f} "
f"{tt_ms:>{col_w}.2f}"
)
print()
print(
f" bytes spark: [{_sparkline(byte_vals)}] "
f"lo={min(byte_vals):,} hi={max(byte_vals):,}"
)
print(
f" time-travel spark: [{_sparkline(tt_vals)}] "
f"lo={min(tt_vals):.2f}ms hi={max(tt_vals):.2f}ms"
)
print(
f" write spark: [{_sparkline(write_vals)}] "
f"lo={min(write_vals):.1f}ms hi={max(write_vals):.1f}ms"
)
print()
print("=" * 90)
print(
"snapshot_every=None means pure diff (no snapshots) — "
"lowest storage, highest time-travel cost."
)
print(
"Lower snapshot_every = more frequent full snapshots = "
"faster time-travel, more storage."
)
print()
def test_rehydrate_sweep(capsys: Any) -> None:
with capsys.disabled():
run_sweep()
if __name__ == "__main__":
run_sweep()
sys.exit(0)