mirror of
https://github.com/langchain-ai/langgraph.git
synced 2026-08-25 09:02:25 +02:00
## Summary Adds a sqlite-specific override of `BaseCheckpointSaver.get_delta_channel_history` (and async). Before this PR, `SqliteSaver` / `AsyncSqliteSaver` inherited the default impl, which calls `get_tuple` once per ancestor — N round-trips, full pending-writes fetch per step regardless of channel relevance. The override mirrors the postgres two-stage shape (ancestor walk + per-channel UNION ALL writes fetch) but adapted for sqlite: - **No JSONB** → stage 1 streams the cursor row-by-row in `checkpoint_id` DESC order. The merged walk advances one row at a time, deserializing only on-path checkpoints and dropping each before advancing — peak in-flight is one deserialized checkpoint, no `fetchall()` materialization. - **No separate blob table** → `channel_values` lives inline in the checkpoint blob, so seeds come back from stage 1 with no second fetch. - **Single merged walk (not K independent walks)**: each visited cid is deserialized exactly once, regardless of how many channels are still seeking their seed. - **Stage 2** stays per-channel UNION ALL to avoid over-fetching writes when channels have different chain depths — same rationale as postgres. `AsyncSqliteSaver.get_delta_channel_history` bridges to its async form via `run_coroutine_threadsafe`, matching the same cross-thread guard used by `get_tuple` / `delete_thread`. ## Tests - New `tests/test_delta_channel_migration.py`: covers the `BinaryOperatorAggregate -> DeltaChannel` migration path on sqlite (sync round-trip, sync continuation with post-migration delta folding, async round-trip). Mirrors `libs/langgraph/tests/test_delta_channel_migration.py` (which covered `InMemorySaver`); without these, the override's behavior on pre-migration threads was unverified — the override has to identify a plain accumulated `channel_values[ch]` at a pre-migration ancestor as a valid `seed`, not just `_DeltaSnapshot` sentinels. - Existing `tests/test_get_delta_channel_history.py` (7 tests) continues to pass and now exercises the optimized override end-to-end (previously hit the inherited default impl). - `make format`, `make lint`, `make test`: clean. 97/97 in the non-flaky sqlite suite (the one ignored test, `test_async_asearch_refresh_ttl`, is a known TTL-store timing flake on a separate module unrelated to this PR). ## Benchmarks ### `get_delta_channel_history` micro-bench (override vs inherited default impl) 1000-turn synthetic threads with sentinel snapshots + per-step writes; `bench_sqlite_delta_history.py`. Per-call latency in microseconds. | Scenario | min | median | mean | |---|---:|---:|---:| | S1 single channel, root-only snapshot | **4.60x** | **4.90x** | **5.13x** | | S2 mixed cadence (every-50 + root-only), 2 channels | **6.08x** | **6.37x** | **6.84x** | | S3 K=8 channels, root-only snapshot | 1.23x | 1.27x | 0.90x | S2 wins biggest because per-channel UNION ALL avoids over-fetching writes for the shallow channel. S3 is the worst case for sqlite (8 channels all walking to root, 1000 deserializations either way) — the override still wins on min/median. ### Long-running thread mem/storage bench (delta vs no-delta) `bench_sqlite_delta_memory.py`. `delta` mode uses `DeltaChannel` + the override; `no_delta` uses `Annotated[list, _messages_delta_reducer]` (full state in every blob). Same workload, file-backed sqlite. Latency measured untraced (30 iterations); peak heap measured separately under tracemalloc. | Scenario | Turns | Storage Δ | Peak heap Δ | Read latency Δ | |---|---:|---|---|---| | K=1, freq=50 | 200 | **-96%** (942 KB vs 25.1 MB) | +21% (504 KB vs 418 KB) | **+13%** | | K=1, freq=50 | 500 | **-98%** (2.9 MB vs 152.3 MB) | +20% (1.2 MB vs 1.0 MB) | **-6%** (delta wins) | | K=3, freq=50 uniform | 200 | **-98%** (1.7 MB vs 73.5 MB) | +7% (1.3 MB vs 1.2 MB) | **+10%** | | K=3, freq=50 uniform | 500 | **-99%** (6.0 MB vs 452.5 MB) | +7% (3.3 MB vs 3.0 MB) | **+6%** | | K=3, freq=mixed | 200 | **-98%** (1.4 MB vs 73.5 MB) | +5% (1.3 MB vs 1.2 MB) | +190% (5.1 ms vs 1.7 ms abs) | | K=3, freq=mixed | 500 | **-99%** (4.1 MB vs 452.5 MB) | +8% (3.3 MB vs 3.0 MB) | +377% (20.9 ms vs 4.4 ms abs) | - **Storage**: -96 to -99% on long threads (a 500-turn K=3 thread shrinks from 452 MB to 6 MB on disk). This is the headline win. - **Peak heap**: within +5 to +21% of the no-delta path — the streaming cursor + merged walk + drop-after-deserialize keep peak in-flight at one checkpoint at a time. - **Read latency**: equivalent-ish (within ~15%) on uniform-cadence scenarios; at K=1/500 turns delta even wins by 6%. The mixed-cadence rows have one channel with `snapshot_frequency=1000` walking to root on a 500-turn thread — by configuration. Absolute mixed-delta latency is still 5-21 ms per read. Bench scripts (not committed; workspace-root convention matches other `bench_*.py` files): - `bench_sqlite_delta_history.py` - `bench_sqlite_delta_memory.py` ## Test plan - [x] `cd libs/checkpoint-sqlite && make format` clean - [x] `cd libs/checkpoint-sqlite && make lint` clean - [x] `cd libs/checkpoint-sqlite && make test` — 97 passed (1 known flake unrelated) - [x] `tests/test_get_delta_channel_history.py` — 7/7 (now exercises the override) - [x] `tests/test_delta_channel_migration.py` — 3/3 (new)
171 lines
6.5 KiB
Python
171 lines
6.5 KiB
Python
"""Sqlite-specific migration smoke tests: BinaryOperatorAggregate -> DeltaChannel.
|
|
|
|
Mirrors `libs/langgraph/tests/test_delta_channel_migration.py` (which
|
|
covers `InMemorySaver` + a third-party fallback to the base default
|
|
impl). This file exercises the same migration scenario through the
|
|
sqlite-specific `SqliteSaver.get_delta_channel_history` override —
|
|
specifically that the streaming ancestor walk finds a pre-migration
|
|
plain `channel_values[ch]` entry and surfaces it as the `seed`, with
|
|
post-migration writes folding on top through the reducer.
|
|
|
|
Pre-migration checkpoints under `BinaryOperatorAggregate` carry the
|
|
full accumulated value at every settled super-step boundary. The
|
|
override has to identify those as "real" seeds (not `_DeltaSnapshot`
|
|
sentinels) — the saver layer is intentionally delta-agnostic and just
|
|
returns whatever is stored in `channel_values[ch]`.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import operator
|
|
from typing import Annotated, Any
|
|
|
|
import pytest
|
|
from langchain_core.runnables import RunnableConfig
|
|
|
|
# `langgraph` core isn't a dep of `langgraph-checkpoint-sqlite`. Skip the
|
|
# whole module rather than importerror-ing in the standalone CI shape.
|
|
pytest.importorskip("langgraph.channels.delta", reason="langgraph core not installed")
|
|
pytest.importorskip("langgraph.channels.binop", reason="langgraph core not installed")
|
|
pytest.importorskip("langgraph.graph", reason="langgraph core not installed")
|
|
|
|
from langgraph.channels.binop import BinaryOperatorAggregate # type: ignore[import-untyped] # noqa: E402,I001
|
|
from langgraph.channels.delta import DeltaChannel # type: ignore[import-untyped] # 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
|
|
|
|
|
|
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: RunnableConfig, tag: str, n: int) -> None:
|
|
for i in range(n):
|
|
graph.invoke({"items": [f"{tag}{i}"]}, config)
|
|
|
|
|
|
async def _adrive(graph: Any, config: RunnableConfig, 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[RunnableConfig, list]]:
|
|
"""`(config, items)` for every checkpoint with `next == ('__start__',)`
|
|
— the stable inter-invoke boundaries that round-trip predictably.
|
|
"""
|
|
return [
|
|
(s.config, list(s.values.get("items", [])))
|
|
for s in history
|
|
if s.next == ("__start__",)
|
|
]
|
|
|
|
|
|
def test_migration_preserves_pre_migration_state_sync() -> None:
|
|
"""Drive 3 invokes under `BinaryOperatorAggregate`, swap the
|
|
annotation to `DeltaChannel` on the same sqlite-backed thread, and
|
|
verify every settled pre-migration boundary round-trips exactly.
|
|
|
|
The override's streaming walk must identify the plain accumulated
|
|
list at each pre-migration ancestor as a valid `seed` even though
|
|
no `_DeltaSnapshot` was ever written there.
|
|
"""
|
|
with SqliteSaver.from_conn_string(":memory:") as saver:
|
|
config: RunnableConfig = {"configurable": {"thread_id": "mig-sync"}}
|
|
|
|
binop = _binop_graph(saver)
|
|
_drive(binop, config, "u", 3)
|
|
|
|
pre_boundaries = _settled_boundaries(list(binop.get_state_history(config)))
|
|
assert len(pre_boundaries) >= 2, "expected multiple settled boundaries"
|
|
|
|
delta = _delta_graph(saver)
|
|
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', [])}"
|
|
)
|
|
|
|
|
|
def test_migration_continued_thread_folds_deltas_on_seed_sync() -> None:
|
|
"""After migration, driving one more super-step extends the
|
|
pre-migration accumulated state via the delta reducer — the seed
|
|
plus a single new write.
|
|
"""
|
|
with SqliteSaver.from_conn_string(":memory:") as saver:
|
|
config: RunnableConfig = {"configurable": {"thread_id": "mig-continue-sync"}}
|
|
|
|
binop = _binop_graph(saver)
|
|
_drive(binop, config, "u", 3)
|
|
|
|
pre_history = list(binop.get_state_history(config))
|
|
pre_boundaries = _settled_boundaries(pre_history)
|
|
# Latest settled boundary — the leaf pre-migration state.
|
|
leaf_cfg, leaf_items = pre_boundaries[0]
|
|
assert leaf_items, "expected non-empty pre-migration leaf"
|
|
|
|
delta = _delta_graph(saver)
|
|
delta.invoke({"items": ["after-migration"]}, leaf_cfg)
|
|
new_state = delta.get_state(config).values["items"]
|
|
assert new_state[: len(leaf_items)] == leaf_items
|
|
assert "after-migration" in new_state
|
|
|
|
|
|
async def test_migration_preserves_pre_migration_state_async() -> None:
|
|
"""Async equivalent of the basic-migration round-trip check on
|
|
`AsyncSqliteSaver`."""
|
|
async with AsyncSqliteSaver.from_conn_string(":memory:") as saver:
|
|
config: RunnableConfig = {"configurable": {"thread_id": "mig-async"}}
|
|
|
|
binop = _binop_graph(saver)
|
|
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(saver)
|
|
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']}"
|
|
)
|