mirror of
https://github.com/langchain-ai/langgraph.git
synced 2026-09-20 16:47:55 +02:00
## Summary
- Promotes the private K-channel batched ancestor-walk to a stable
public `get_delta_channel_history` / `aget_delta_channel_history` API on
`BaseCheckpointSaver` (returns `Mapping[str, DeltaChannelHistory]`, a
TypedDict with `writes` always present and `seed` `NotRequired`)
- Removes `DELTA_SENTINEL` / `_DeltaSentinel` entirely — the saver layer
is now delta-agnostic on both write and read paths
- Reworks `DeltaChannel` snapshot cadence from "every Nth superstep" to
"every N updates to this channel," persisted in
`CheckpointMetadata.delta_updates_since_snapshot`
- Adds Postgres optimizations: paged stage-1 with cursor (1024-row
pages) and per-channel UNION ALL stage-2 (no over-fetch when channels
have different chain depths)
- Default `snapshot_frequency` becomes a positive int (default `1000`);
the previous `None` opt-out is removed
## Public API
```python
class DeltaChannelHistory(TypedDict):
writes: list[PendingWrite] # always present, possibly empty
seed: NotRequired[Any] # absent if walk reached root
def get_delta_channel_history(
self, *, config: RunnableConfig, channels: Sequence[str]
) -> Mapping[str, DeltaChannelHistory]: ...
async def aget_delta_channel_history(
self, *, config: RunnableConfig, channels: Sequence[str]
) -> Mapping[str, DeltaChannelHistory]: ...
```
`config` and `channels` are keyword-only so later additions (e.g.
`page_size`) don't shift the positional API.
The TypedDict-with-`NotRequired[seed]` shape matches the existing
checkpoint-package convention (`CheckpointMetadata` is
`TypedDict(total=False)`) — absence-via-key-omission rather than
introducing a new sentinel. Pregel translates `"seed" not in hist` to
`MISSING` on its side at consume time.
The default impl walks `get_tuple` + `parent_config` correctly but is
slow on long chains; savers that care override (`InMemorySaver`,
`PostgresSaver`).
## Sentinel removal
`DELTA_SENTINEL` and `_DeltaSentinel` are deleted entirely. The saver
layer becomes delta-agnostic:
- `DeltaChannel.checkpoint()` returns `MISSING` for non-snapshot steps;
pregel's `create_checkpoint` skips MISSING so delta channels without a
snapshot simply don't appear in `channel_values`
- `InMemorySaver.put` and Postgres `put` no longer filter sentinels
(they have nothing to filter)
- `_needs_replay` becomes `stored is MISSING`
- `DeltaChannel.from_checkpoint` accepts: `MISSING` → empty,
`_DeltaSnapshot(value)` → snapshot value, plain value → pre-migration
legacy
## Snapshot cadence
`DeltaChannel.snapshot_frequency: int` (default `1000`, positive). The
previous `None` opt-out is gone.
```python
def should_snapshot(ch_name, ch):
if force_delta_snapshot: # durability="exit"
return True
return updates_since_snapshot.get(ch_name, 0) >= ch.snapshot_frequency
```
Per-channel update counters are persisted in
`CheckpointMetadata.delta_updates_since_snapshot` (`NotRequired`,
`total=False`). The counter is incremented by `_put_checkpoint` for any
delta channel in `updated_channels` and reset to `0` by
`create_checkpoint` for channels that fire a snapshot this step.
Version-format-independent — works for `int`, `float`, and `str`
versioning schemes alike.
## Postgres optimization
Two improvements internal to the override:
**Stage-1 paged with cursor** (`LIMIT 1024` internal const, `AND
checkpoint_id < ?` for subsequent pages). The previous unpaged form
scanned every checkpoint in `(thread_id, ns)` and was pathological at
high thread depths.
**Stage-2 per-channel UNION ALL**: one `WHERE channel='X' AND
checkpoint_id = ANY(chain_X)` branch per channel plus one seed-blob
branch per channel with a seed. The previous form filtered by `channel =
ANY(channels) AND checkpoint_id = ANY(union_chain_cids)`, over-fetching
writes when channels had different chain depths (`K ×
max(chain_lengths)` vs the correct `sum(chain_lengths)`).
Both improvements stay internal to `PostgresSaver`/`AsyncPostgresSaver`;
the public contract returns a single `Mapping`.
## Benchmarks
`libs/langgraph/tests/test_delta_channel_benchmark.py`. Run via `python
libs/langgraph/tests/test_delta_channel_benchmark.py`. Postgres against
local pg:5441.
Results below trimmed to the high-signal cells. Sub-millisecond /
sub-100-turn rows omitted as warmup-bound; freq=1 omitted (chain depth =
1, nothing to optimize); peak read-time memory and Postgres storage are
flat between branches and omitted. Deep-thread reads and the
cadence-rework storage win are the load-bearing numbers.
### Postgres reads, 500 turns
| Scenario | main | branch | Δ |
|---|---:|---:|---:|
| Single-channel deep read | 17.7 ms | **6.1 ms** | **-66%** |
| Single-channel, 1000 turns | 35.0 ms | **14.3 ms** | **-59%** |
| K=3 channels, freq=50 uniform | 70.5 ms | **41.4 ms** | **-41%** |
| K=8 channels, freq=50 uniform | 214.2 ms | **139.4 ms** | **-35%** |
| K=8 channels, mixed freq (25/50/100/.../1000) | 295.6 ms | **214.4
ms** | **-27%** |
K-channel batching + paged stage-1 + per-channel UNION ALL stage-2 doing
exactly what they should at depth.
### InMemory reads, 500 turns
| Scenario | main | branch | Δ |
|---|---:|---:|---:|
| Single-channel deep read | 7.9 ms | **3.8 ms** | **-52%** |
| Single-channel, 1000 turns | 15.6 ms | **7.2 ms** | **-54%** |
| K=8 channels, freq=50 uniform | 112.3 ms | 94.6 ms | -16% |
| K=8 channels, mixed freq | 184.9 ms | **134.5 ms** | **-27%** |
### InMemory storage, 500 turns (cadence-rework win)
| Scenario | main | branch | Δ |
|---|---:|---:|---:|
| K=3, freq=50 uniform | 8.7 MB | **3.3 MB** | **-62%** |
| K=3 mixed freq | 3.8 MB | **1.3 MB** | **-66%** |
| K=8, freq=50 uniform | 23.1 MB | **8.7 MB** | **-62%** |
| K=8 mixed freq | 11.5 MB | **4.2 MB** | **-64%** |
Snapshot frequency now counts **channel updates** instead of
**supersteps**. On graphs where supersteps outpace per-channel updates
(e.g., input/end steps that don't write to channels), branch stores ~3×
fewer snapshot blobs.
### Tradeoff worth flagging
InMemory K=3 with mixed frequencies (50/200/1000) at 500 turns: **+64%
read latency** (46.6 → 76.5 ms). The mixed scenario has a channel with
`freq=1000` that goes the entire 500-turn run with no snapshot. On main,
the old superstep-counted cadence happened to fire at step=500 anyway.
New cadence gives users explicit control over walk depth via
`snapshot_frequency`. The K=8 mixed case still wins overall (-27%); this
regression is specific to the K=3 mixed shape.
Default `snapshot_frequency=1000` is the upper bound on walk depth —
it's a tunable knob.
## Tests
- New sqlite smoke test (`test_get_delta_channel_history.py`) exercises
the inherited default `BaseCheckpointSaver` impl via `SqliteSaver` /
`AsyncSqliteSaver` end-to-end with a real `DeltaChannel`-backed graph.
Sqlite uses the default unchanged — this validates the default path
actually works on a real second saver, not just on the optimized
override.
- Module-level `pytest.importorskip("langgraph.channels.delta")` guards
the test for sqlite's standalone CI environment (matches the postgres
pattern).
## Test plan
- [x] `libs/checkpoint`: 150 passed, 16 skipped
- [x] `libs/langgraph` (channels + delta migration): 41/41 (post-merge)
- [x] `libs/langgraph` (full pregel suite): 1784 passing — 6 "failures"
verified via `env -i` clean shell are local LangSmith env vars + `git
describe revision_id` polluting LangChain metadata fixtures; CI is
unaffected
- [x] `libs/checkpoint-postgres`: 40/40 saver tests + 3/3 delta channel
reconstruction tests against local Postgres
- [x] `libs/checkpoint-sqlite`: 105/105 (incl. retry-passed flake
`test_ttl_refresh`, unrelated to this PR)
- [x] Lint clean across all four libs (`ruff format`, `ruff check`,
`mypy`)
- [x] Branch-vs-main benchmarks — see results above
---------
Co-authored-by: Quanzheng Long <long@langchain.dev>
Co-authored-by: Cursor <cursoragent@cursor.com>
272 lines
11 KiB
Python
272 lines
11 KiB
Python
"""Smoke tests for `BaseCheckpointSaver.get_delta_channel_history` on sqlite.
|
|
|
|
`SqliteSaver` (and `AsyncSqliteSaver`) deliberately don't override the
|
|
default implementation in `BaseCheckpointSaver` — these tests pin the
|
|
default impl to behave correctly end-to-end against a real persistent
|
|
saver and a real `DeltaChannel`-backed graph.
|
|
|
|
Scenarios covered:
|
|
|
|
* Empty `channels` argument returns an empty mapping (no I/O).
|
|
* On a non-trivial multi-checkpoint thread, per-channel writes come back
|
|
oldest→newest.
|
|
* When the walk reaches the root without ever finding a stored value,
|
|
`seed` is omitted from the entry (consumer treats absence as "start
|
|
empty").
|
|
* When a `_DeltaSnapshot` blob is present at an ancestor, it is returned
|
|
as the `seed`.
|
|
* The async saver returns the same shape via `aget_delta_channel_history`.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import operator
|
|
from typing import Annotated, Any
|
|
|
|
import pytest
|
|
from langchain_core.runnables import RunnableConfig
|
|
|
|
# `langgraph` is not a dep of `langgraph-checkpoint-sqlite`. When tests run
|
|
# in the sqlite lib's standalone CI environment without it installed, skip
|
|
# the whole module rather than failing at import.
|
|
pytest.importorskip("langgraph.channels.delta", reason="langgraph core not installed")
|
|
pytest.importorskip("langgraph.graph", reason="langgraph core not installed")
|
|
|
|
from langgraph.channels.delta import DeltaChannel # type: ignore[import-untyped] # noqa: E402,I001
|
|
from langgraph.checkpoint.serde.types import _DeltaSnapshot # noqa: E402
|
|
from langgraph.graph import END, START, StateGraph # type: ignore[import-untyped] # noqa: E402
|
|
from typing_extensions import TypedDict # noqa: E402
|
|
|
|
from langgraph.checkpoint.sqlite import SqliteSaver # noqa: E402
|
|
from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver # noqa: E402
|
|
|
|
pytestmark = pytest.mark.anyio
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Graph helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _noop(_state: Any) -> dict[str, Any]:
|
|
return {}
|
|
|
|
|
|
class _DeltaState(TypedDict):
|
|
items: Annotated[list, DeltaChannel(operator.add)]
|
|
|
|
|
|
def _delta_graph(checkpointer: Any) -> Any:
|
|
return (
|
|
StateGraph(_DeltaState)
|
|
.add_node("noop", _noop)
|
|
.add_edge(START, "noop")
|
|
.add_edge("noop", END)
|
|
.compile(checkpointer=checkpointer)
|
|
)
|
|
|
|
|
|
def _drive(graph: Any, config: RunnableConfig, n: int) -> None:
|
|
for i in range(n):
|
|
graph.invoke({"items": [f"v{i}"]}, config)
|
|
|
|
|
|
async def _adrive(graph: Any, config: RunnableConfig, n: int) -> None:
|
|
for i in range(n):
|
|
await graph.ainvoke({"items": [f"v{i}"]}, config)
|
|
|
|
|
|
def _pick_non_root(saver: Any, config: RunnableConfig) -> RunnableConfig:
|
|
"""Return a config pointing at a checkpoint that has at least one ancestor.
|
|
|
|
`get_delta_channel_history` walks the parent chain — calling it on the root
|
|
checkpoint produces `writes=[]` and no `seed`, which is uninteresting
|
|
for the multi-step assertions below.
|
|
"""
|
|
history = list(saver.list(config))
|
|
assert history, "expected non-empty history"
|
|
# `list` yields newest→oldest; the second entry has the first entry
|
|
# as its parent, so its parent_config is non-None.
|
|
for tup in history:
|
|
if tup.parent_config is not None:
|
|
return tup.config
|
|
raise AssertionError("no checkpoint with a parent in history")
|
|
|
|
|
|
async def _apick_non_root(saver: Any, config: RunnableConfig) -> RunnableConfig:
|
|
history = [tup async for tup in saver.alist(config)]
|
|
assert history, "expected non-empty history"
|
|
for tup in history:
|
|
if tup.parent_config is not None:
|
|
return tup.config
|
|
raise AssertionError("no checkpoint with a parent in history")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Sync: SqliteSaver
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_empty_channels_returns_empty_mapping_sync() -> None:
|
|
"""Empty `channels` short-circuits to `{}` without touching storage."""
|
|
with SqliteSaver.from_conn_string(":memory:") as saver:
|
|
config: RunnableConfig = {"configurable": {"thread_id": "empty"}}
|
|
assert saver.get_delta_channel_history(config=config, channels=[]) == {}
|
|
|
|
|
|
def test_writes_history_oldest_to_newest_sync() -> None:
|
|
"""Per-channel writes accumulated across the walk come back oldest→newest."""
|
|
with SqliteSaver.from_conn_string(":memory:") as saver:
|
|
config: RunnableConfig = {"configurable": {"thread_id": "history-sync"}}
|
|
graph = _delta_graph(saver)
|
|
_drive(graph, config, 3)
|
|
|
|
target_cfg = _pick_non_root(saver, config)
|
|
result = saver.get_delta_channel_history(config=target_cfg, channels=["items"])
|
|
|
|
assert "items" in result
|
|
entry = result["items"]
|
|
assert isinstance(entry["writes"], list)
|
|
|
|
# If any writes were collected, their values should be in oldest→newest
|
|
# order — i.e. tagged 'v0', 'v1', ... matching invoke order.
|
|
write_values: list[Any] = []
|
|
for _task_id, channel, value in entry["writes"]:
|
|
assert channel == "items"
|
|
write_values.extend(value if isinstance(value, list) else [value])
|
|
|
|
# `_drive` invokes with payloads ['v0'], ['v1'], ['v2']. Whatever
|
|
# subset shows up in the chain must be a contiguous prefix in order.
|
|
for idx, val in enumerate(write_values):
|
|
assert val == f"v{idx}", (
|
|
f"writes not in oldest→newest order: {write_values}"
|
|
)
|
|
|
|
|
|
def test_seed_present_when_snapshot_in_ancestor_sync() -> None:
|
|
"""Inserting a `_DeltaSnapshot` blob at an ancestor → walk returns it as `seed`."""
|
|
with SqliteSaver.from_conn_string(":memory:") as saver:
|
|
config: RunnableConfig = {"configurable": {"thread_id": "seed-sync"}}
|
|
graph = _delta_graph(saver)
|
|
_drive(graph, config, 2)
|
|
|
|
# Find the oldest non-root checkpoint, then walk to its parent and
|
|
# rewrite that parent's `channel_values["items"]` to a real
|
|
# `_DeltaSnapshot`. After this surgery, calling `get_delta_channel_history`
|
|
# at the leaf must return the snapshot value as `seed`.
|
|
history = list(saver.list(config))
|
|
assert len(history) >= 2
|
|
leaf_tup = history[0]
|
|
# Walk to an ancestor with a parent_config (any non-root will do).
|
|
ancestor_tup = next(
|
|
(tup for tup in history if tup.parent_config is not None), None
|
|
)
|
|
assert ancestor_tup is not None
|
|
parent_cfg = ancestor_tup.parent_config
|
|
assert parent_cfg is not None
|
|
parent_tup = saver.get_tuple(parent_cfg)
|
|
assert parent_tup is not None
|
|
|
|
snapshot_value = ["seeded", "items"]
|
|
parent_tup.checkpoint["channel_values"]["items"] = _DeltaSnapshot(
|
|
snapshot_value
|
|
)
|
|
# Make sure the channel has a version so the optimized blob lookup
|
|
# in any future override has something to hit.
|
|
parent_tup.checkpoint["channel_versions"].setdefault("items", 1)
|
|
saver.put(
|
|
parent_tup.parent_config or {"configurable": parent_cfg["configurable"]},
|
|
parent_tup.checkpoint,
|
|
parent_tup.metadata,
|
|
{},
|
|
)
|
|
|
|
result = saver.get_delta_channel_history(
|
|
config=leaf_tup.config, channels=["items"]
|
|
)
|
|
entry = result["items"]
|
|
assert "seed" in entry, f"expected seed to be present, got {entry}"
|
|
seed = entry["seed"]
|
|
assert isinstance(seed, _DeltaSnapshot), (
|
|
f"expected _DeltaSnapshot, got {seed!r}"
|
|
)
|
|
assert seed.value == snapshot_value
|
|
|
|
|
|
def test_seed_omitted_when_walk_reaches_root_sync() -> None:
|
|
"""`get_delta_channel_history` on the root checkpoint → no `seed` key, no writes."""
|
|
with SqliteSaver.from_conn_string(":memory:") as saver:
|
|
config: RunnableConfig = {"configurable": {"thread_id": "root-sync"}}
|
|
graph = _delta_graph(saver)
|
|
_drive(graph, config, 1)
|
|
|
|
history = list(saver.list(config))
|
|
# Root is the oldest checkpoint (no parent_config).
|
|
root_tup = history[-1]
|
|
assert root_tup.parent_config is None
|
|
|
|
result = saver.get_delta_channel_history(
|
|
config=root_tup.config, channels=["items"]
|
|
)
|
|
entry = result["items"]
|
|
assert "seed" not in entry, f"root-walk should have no seed, got {entry}"
|
|
assert entry["writes"] == []
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Async: AsyncSqliteSaver
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def test_empty_channels_returns_empty_mapping_async() -> None:
|
|
"""Async equivalent of the empty-channels short-circuit."""
|
|
async with AsyncSqliteSaver.from_conn_string(":memory:") as saver:
|
|
config: RunnableConfig = {"configurable": {"thread_id": "empty-async"}}
|
|
assert await saver.aget_delta_channel_history(config=config, channels=[]) == {}
|
|
|
|
|
|
async def test_writes_history_oldest_to_newest_async() -> None:
|
|
"""Async equivalent of the oldest→newest ordering check."""
|
|
async with AsyncSqliteSaver.from_conn_string(":memory:") as saver:
|
|
config: RunnableConfig = {"configurable": {"thread_id": "history-async"}}
|
|
graph = _delta_graph(saver)
|
|
await _adrive(graph, config, 3)
|
|
|
|
target_cfg = await _apick_non_root(saver, config)
|
|
result = await saver.aget_delta_channel_history(
|
|
config=target_cfg, channels=["items"]
|
|
)
|
|
|
|
assert "items" in result
|
|
entry = result["items"]
|
|
assert isinstance(entry["writes"], list)
|
|
|
|
write_values: list[Any] = []
|
|
for _task_id, channel, value in entry["writes"]:
|
|
assert channel == "items"
|
|
write_values.extend(value if isinstance(value, list) else [value])
|
|
|
|
for idx, val in enumerate(write_values):
|
|
assert val == f"v{idx}", (
|
|
f"writes not in oldest→newest order: {write_values}"
|
|
)
|
|
|
|
|
|
async def test_seed_omitted_when_walk_reaches_root_async() -> None:
|
|
"""Async equivalent of the root-walk seed-absence check."""
|
|
async with AsyncSqliteSaver.from_conn_string(":memory:") as saver:
|
|
config: RunnableConfig = {"configurable": {"thread_id": "root-async"}}
|
|
graph = _delta_graph(saver)
|
|
await _adrive(graph, config, 1)
|
|
|
|
history = [tup async for tup in saver.alist(config)]
|
|
root_tup = history[-1]
|
|
assert root_tup.parent_config is None
|
|
|
|
result = await saver.aget_delta_channel_history(
|
|
config=root_tup.config, channels=["items"]
|
|
)
|
|
entry = result["items"]
|
|
assert "seed" not in entry, f"root-walk should have no seed, got {entry}"
|
|
assert entry["writes"] == []
|