Compare commits

...
Author SHA1 Message Date
Quanzheng LongandCursor 8da59aba37 chore: remove unnecessary missing-typed-dict-key suppression
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-08 09:49:57 -07:00
Quanzheng LongandCursor 44805588b6 docs: clarify get_delta_channel_keepset is a basic reference implementation
Make it clear that custom backends may override with a more efficient
version but the default works correctly for any saver.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-07 17:10:20 -07:00
Quanzheng LongandCursor 68f8893847 test(conformance): add migration case for delta_channel_history
Test that a pre-delta plain value in channel_values[ch] (from a thread
that used BinaryOperatorAggregate before switching to DeltaChannel) is
correctly treated as the seed and terminates the walk.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-07 17:09:10 -07:00
Quanzheng Long 4cf12f9500 rm 2026-05-07 12:13:35 -07:00
Quanzheng LongandCursor a8ab1b1638 fix: resolve ruff import sorting in conformance wrapper tests
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-07 11:26:40 -07:00
Quanzheng Long c78270c40f simp 2026-05-07 11:16:52 -07:00
Quanzheng LongandCursor 9dc68d6986 test: add delta-channel conformance wrappers for InMemory and SQLite savers
Runs the three new delta-channel conformance capabilities
(delta_channel_history, delta_channel_keepset, delta_channel_reconstruction)
against InMemorySaver and AsyncSqliteSaver. Guarded by importorskip so
they're skipped gracefully when checkpoint-conformance isn't installed.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-07 11:15:34 -07:00
Quanzheng Long d6c29f8157 simp 2026-05-07 11:14:51 -07:00
Quanzheng LongandCursor 4b3839af0f fix(conformance): lazy-import _DeltaSnapshot to avoid CI collection failure
Move _DeltaSnapshot imports from module level to function bodies so the
conformance test files can be collected even when the installed
langgraph-checkpoint version doesn't yet export the symbol.

Also fix get_delta_channel_keepset to check the target checkpoint's own
channel_values before walking the parent chain — if the target itself
has a snapshot, no walk is needed.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-07 11:07:39 -07:00
Quanzheng Long 569f2d2d14 done 2026-05-07 10:58:13 -07:00
Quanzheng Long 0d32281d5d dc 2026-05-07 10:17:24 -07:00
13 changed files with 1587 additions and 504 deletions
+3
View File
@@ -63,6 +63,9 @@ The suite tests **base** capabilities (required) and **extended** capabilities (
| `delete_for_runs` | no | `adelete_for_runs` |
| `copy_thread` | no | `acopy_thread` |
| `prune` | no | `aprune` |
| `delta_channel_history` | no | `aget_delta_channel_history` |
| `delta_channel_keepset` | no | `aget_delta_channel_keepset` |
| `delta_channel_reconstruction` | no | `aput` |
Extended capabilities are detected by checking whether the method is overridden from `BaseCheckpointSaver`. If not overridden, those tests are skipped.
@@ -23,6 +23,9 @@ class Capability(str, Enum):
DELETE_FOR_RUNS = "delete_for_runs"
COPY_THREAD = "copy_thread"
PRUNE = "prune"
DELTA_CHANNEL_HISTORY = "delta_channel_history"
DELTA_CHANNEL_KEEPSET = "delta_channel_keepset"
DELTA_CHANNEL_RECONSTRUCTION = "delta_channel_reconstruction"
# Capabilities that every checkpointer must support.
@@ -42,6 +45,9 @@ EXTENDED_CAPABILITIES = frozenset(
Capability.DELETE_FOR_RUNS,
Capability.COPY_THREAD,
Capability.PRUNE,
Capability.DELTA_CHANNEL_HISTORY,
Capability.DELTA_CHANNEL_KEEPSET,
Capability.DELTA_CHANNEL_RECONSTRUCTION,
}
)
@@ -57,6 +63,9 @@ _CAPABILITY_METHOD_MAP: dict[Capability, str] = {
Capability.DELETE_FOR_RUNS: "adelete_for_runs",
Capability.COPY_THREAD: "acopy_thread",
Capability.PRUNE: "aprune",
Capability.DELTA_CHANNEL_HISTORY: "aget_delta_channel_history",
Capability.DELTA_CHANNEL_KEEPSET: "aget_tuple",
Capability.DELTA_CHANNEL_RECONSTRUCTION: "aput",
}
@@ -9,6 +9,15 @@ from langgraph.checkpoint.conformance.spec.test_delete_for_runs import (
from langgraph.checkpoint.conformance.spec.test_delete_thread import (
run_delete_thread_tests,
)
from langgraph.checkpoint.conformance.spec.test_delta_channel_history import (
run_delta_channel_history_tests,
)
from langgraph.checkpoint.conformance.spec.test_delta_channel_keepset import (
run_delta_channel_keepset_tests,
)
from langgraph.checkpoint.conformance.spec.test_delta_channel_reconstruction import (
run_delta_channel_reconstruction_tests,
)
from langgraph.checkpoint.conformance.spec.test_get_tuple import run_get_tuple_tests
from langgraph.checkpoint.conformance.spec.test_list import run_list_tests
from langgraph.checkpoint.conformance.spec.test_prune import run_prune_tests
@@ -24,4 +33,7 @@ __all__ = [
"run_delete_for_runs_tests",
"run_copy_thread_tests",
"run_prune_tests",
"run_delta_channel_history_tests",
"run_delta_channel_keepset_tests",
"run_delta_channel_reconstruction_tests",
]
@@ -0,0 +1,99 @@
"""Shared fixtures for delta-channel conformance tests.
Builds a parent chain with `_DeltaSnapshot` blobs at known positions via
direct `aput` / `aput_writes` calls. No langgraph or Pregel dependency.
"""
from __future__ import annotations
from collections.abc import Sequence
from typing import Any
from uuid import uuid4
from langchain_core.runnables import RunnableConfig
from langgraph.checkpoint.base import BaseCheckpointSaver, Checkpoint
from langgraph.checkpoint.base.id import uuid6
from langgraph.checkpoint.conformance.test_utils import generate_metadata
async def build_delta_chain(
saver: BaseCheckpointSaver,
*,
thread_id: str | None = None,
checkpoint_ns: str = "",
channel: str = "messages",
snapshots_at_steps: Sequence[int] = (0,),
total_steps: int = 6,
write_value_fn: Any | None = None,
) -> list[RunnableConfig]:
"""Build a parent chain with `_DeltaSnapshot` at known positions.
Args:
saver: Checkpointer instance.
thread_id: Defaults to a random UUID.
checkpoint_ns: Namespace (default root).
channel: Channel name used for snapshots and writes.
snapshots_at_steps: Steps at which a `_DeltaSnapshot` blob is stored
in `channel_values[channel]`. Step 0 is the oldest checkpoint.
total_steps: Number of checkpoints in the chain.
write_value_fn: Callable(step) -> write value. Defaults to step index.
Returns:
List of stored configs (oldest first), one per step.
"""
if write_value_fn is None:
def write_value_fn(step: int) -> Any:
return step
from langgraph.checkpoint.serde.types import _DeltaSnapshot
thread_id = thread_id or str(uuid4())
snapshot_set = set(snapshots_at_steps)
stored: list[RunnableConfig] = []
parent_cfg: RunnableConfig | None = None
for step in range(total_steps):
config: RunnableConfig = {
"configurable": {
"thread_id": thread_id,
"checkpoint_ns": checkpoint_ns,
}
}
if parent_cfg:
config["configurable"]["checkpoint_id"] = parent_cfg["configurable"][
"checkpoint_id"
]
channel_values: dict[str, Any] = {}
channel_versions: dict[str, int] = {}
if step in snapshot_set:
channel_values[channel] = _DeltaSnapshot(
write_value_fn(step),
)
channel_versions[channel] = step + 1
cp = Checkpoint(
v=1,
id=str(uuid6(clock_seq=-1)),
ts="",
channel_values=channel_values,
channel_versions=channel_versions,
versions_seen={},
updated_channels=None,
)
new_versions = dict(channel_versions)
parent_cfg = await saver.aput(
config, cp, generate_metadata(step=step), new_versions
)
stored.append(parent_cfg)
# Write a pending write for non-snapshot steps so the walk has
# something to collect.
if step not in snapshot_set:
await saver.aput_writes(
parent_cfg, [(channel, write_value_fn(step))], str(uuid4())
)
return stored
@@ -0,0 +1,247 @@
"""DELTA_CHANNEL_HISTORY capability tests — aget_delta_channel_history contract."""
from __future__ import annotations
import traceback
from collections.abc import Callable
from uuid import uuid4
from langgraph.checkpoint.base import BaseCheckpointSaver
from langgraph.checkpoint.conformance.spec._delta_fixtures import build_delta_chain
async def test_history_returns_writes_oldest_first(
saver: BaseCheckpointSaver,
) -> None:
"""Writes are returned oldest-to-newest."""
tid = str(uuid4())
# 5 steps: snapshot at 0, writes at 1,2,3,4.
# Head is step 4. Walk starts at step 3 (parent of head).
# Collects writes from steps 1,2,3 (between snapshot at 0 and head's parent).
configs = await build_delta_chain(
saver, thread_id=tid, channel="ch", snapshots_at_steps=[0], total_steps=5
)
head = configs[-1]
result = await saver.aget_delta_channel_history(config=head, channels=["ch"])
writes = result["ch"]["writes"]
values = [w[2] for w in writes]
assert values == [1, 2, 3], f"Expected [1,2,3], got {values}"
async def test_history_seed_is_nearest_snapshot(
saver: BaseCheckpointSaver,
) -> None:
"""Seed is the value from the nearest ancestor with channel_values populated."""
tid = str(uuid4())
# 6 steps: snapshots at 0 and 3, writes at 1,2,4,5.
# Head is step 5. Walk from step 4 backward stops at step 3 (snapshot).
# Collects writes from step 4 only (between step 3 and head's parent step 4).
configs = await build_delta_chain(
saver,
thread_id=tid,
channel="ch",
snapshots_at_steps=[0, 3],
total_steps=6,
)
head = configs[-1]
result = await saver.aget_delta_channel_history(config=head, channels=["ch"])
assert "seed" in result["ch"], "Expected seed from snapshot at step 3"
seed = result["ch"]["seed"]
from langgraph.checkpoint.serde.types import _DeltaSnapshot
actual_value = seed.value if isinstance(seed, _DeltaSnapshot) else seed
assert actual_value == 3, f"Expected seed value 3 (step 3), got {actual_value}"
writes = result["ch"]["writes"]
values = [w[2] for w in writes]
assert values == [4], f"Expected [4], got {values}"
async def test_history_excludes_target_pending_writes(
saver: BaseCheckpointSaver,
) -> None:
"""Target's own pending_writes are NOT included in the history."""
tid = str(uuid4())
configs = await build_delta_chain(
saver, thread_id=tid, channel="ch", snapshots_at_steps=[0], total_steps=3
)
head = configs[-1]
# Add writes directly to the head checkpoint
await saver.aput_writes(head, [("ch", "extra")], str(uuid4()))
result = await saver.aget_delta_channel_history(config=head, channels=["ch"])
writes = result["ch"]["writes"]
values = [w[2] for w in writes]
assert "extra" not in values, f"Target's writes should be excluded, got {values}"
async def test_history_multi_channel(
saver: BaseCheckpointSaver,
) -> None:
"""Multiple channels have independent walk termination."""
tid = str(uuid4())
configs: list = []
parent_cfg = None
from langgraph.checkpoint.base import Checkpoint
from langgraph.checkpoint.base.id import uuid6
from langgraph.checkpoint.serde.types import _DeltaSnapshot
from langgraph.checkpoint.conformance.test_utils import generate_metadata
for step in range(5):
config = {"configurable": {"thread_id": tid, "checkpoint_ns": ""}}
if parent_cfg:
config["configurable"]["checkpoint_id"] = parent_cfg["configurable"][
"checkpoint_id"
]
cv: dict = {}
cvs: dict = {}
if step == 1:
cv["a"] = _DeltaSnapshot("snap_a")
cvs["a"] = step + 1
if step == 3:
cv["b"] = _DeltaSnapshot("snap_b")
cvs["b"] = step + 1
cp = Checkpoint(
v=1,
id=str(uuid6(clock_seq=-1)),
ts="",
channel_values=cv,
channel_versions=cvs,
versions_seen={},
updated_channels=None,
)
parent_cfg = await saver.aput(config, cp, generate_metadata(step=step), cvs)
configs.append(parent_cfg)
await saver.aput_writes(parent_cfg, [("a", step), ("b", step)], str(uuid4()))
head = configs[-1]
result = await saver.aget_delta_channel_history(config=head, channels=["a", "b"])
a_writes = [w[2] for w in result["a"]["writes"]]
b_writes = [w[2] for w in result["b"]["writes"]]
assert a_writes == [1, 2, 3], f"Expected a writes [1,2,3], got {a_writes}"
assert b_writes == [3], f"Expected b writes [3], got {b_writes}"
async def test_history_empty_channels_returns_empty(
saver: BaseCheckpointSaver,
) -> None:
"""Empty channels list returns empty mapping."""
tid = str(uuid4())
configs = await build_delta_chain(
saver, thread_id=tid, channel="ch", snapshots_at_steps=[0], total_steps=3
)
result = await saver.aget_delta_channel_history(config=configs[-1], channels=[])
assert result == {}
async def test_history_walk_to_root_no_seed(
saver: BaseCheckpointSaver,
) -> None:
"""Walk reaches root without finding seed — no 'seed' key in result."""
tid = str(uuid4())
configs = await build_delta_chain(
saver,
thread_id=tid,
channel="ch",
snapshots_at_steps=[],
total_steps=4,
)
head = configs[-1]
result = await saver.aget_delta_channel_history(config=head, channels=["ch"])
assert "seed" not in result["ch"], f"Expected no seed, got {result['ch']}"
async def test_history_migration_plain_value_as_seed(
saver: BaseCheckpointSaver,
) -> None:
"""Pre-delta plain value in channel_values acts as seed (migration case).
When a thread was originally using a regular channel (BinaryOperatorAggregate)
and later switches to DeltaChannel, the old checkpoint has a plain value in
channel_values[ch] (not a _DeltaSnapshot). The walk should treat it as the
seed and terminate there.
"""
from langgraph.checkpoint.base import Checkpoint
from langgraph.checkpoint.base.id import uuid6
from langgraph.checkpoint.conformance.test_utils import generate_metadata
tid = str(uuid4())
configs: list = []
parent_cfg = None
for step in range(4):
config = {"configurable": {"thread_id": tid, "checkpoint_ns": ""}}
if parent_cfg:
config["configurable"]["checkpoint_id"] = parent_cfg["configurable"][
"checkpoint_id"
]
cv: dict = {}
cvs: dict = {}
# Step 1: plain value (migration case — old checkpoint before delta)
if step == 1:
cv["ch"] = [10, 20, 30]
cvs["ch"] = step + 1
cp = Checkpoint(
v=1,
id=str(uuid6(clock_seq=-1)),
ts="",
channel_values=cv,
channel_versions=cvs,
versions_seen={},
updated_channels=None,
)
parent_cfg = await saver.aput(config, cp, generate_metadata(step=step), cvs)
configs.append(parent_cfg)
if step != 1:
await saver.aput_writes(parent_cfg, [("ch", step)], str(uuid4()))
head = configs[-1]
result = await saver.aget_delta_channel_history(config=head, channels=["ch"])
# Seed should be the plain value from step 1
assert "seed" in result["ch"], "Expected seed from migration plain value at step 1"
seed = result["ch"]["seed"]
assert seed == [10, 20, 30], f"Expected plain value [10,20,30], got {seed}"
# Writes should be from step 2 only (between seed at step 1 and head's parent step 2)
writes = result["ch"]["writes"]
values = [w[2] for w in writes]
assert values == [2], f"Expected [2], got {values}"
ALL_DELTA_CHANNEL_HISTORY_TESTS = [
test_history_returns_writes_oldest_first,
test_history_seed_is_nearest_snapshot,
test_history_excludes_target_pending_writes,
test_history_multi_channel,
test_history_empty_channels_returns_empty,
test_history_walk_to_root_no_seed,
test_history_migration_plain_value_as_seed,
]
async def run_delta_channel_history_tests(
saver: BaseCheckpointSaver,
on_test_result: Callable[[str, str, bool, str | None], None] | None = None,
) -> tuple[int, int, list[str]]:
"""Run all delta_channel_history tests. Returns (passed, failed, failure_names)."""
passed = 0
failed = 0
failures: list[str] = []
for test_fn in ALL_DELTA_CHANNEL_HISTORY_TESTS:
try:
await test_fn(saver)
passed += 1
if on_test_result:
on_test_result("delta_channel_history", test_fn.__name__, True, None)
except Exception:
failed += 1
msg = f"{test_fn.__name__}: {traceback.format_exc()}"
failures.append(msg)
if on_test_result:
on_test_result(
"delta_channel_history",
test_fn.__name__,
False,
traceback.format_exc(),
)
return passed, failed, failures
@@ -0,0 +1,172 @@
"""DELTA_CHANNEL_KEEPSET capability tests — aget_delta_channel_keepset contract."""
from __future__ import annotations
import traceback
from collections.abc import Callable
from uuid import uuid4
from langgraph.checkpoint.base import BaseCheckpointSaver
from langgraph.checkpoint.conformance.spec._delta_fixtures import build_delta_chain
async def test_keepset_empty_channels_returns_target_only(
saver: BaseCheckpointSaver,
) -> None:
"""Empty channels → keep-set is just {target_id}."""
tid = str(uuid4())
configs = await build_delta_chain(
saver, thread_id=tid, channel="ch", snapshots_at_steps=[0], total_steps=4
)
head = configs[-1]
keep = await saver.aget_delta_channel_keepset(config=head, channels=[])
head_id = head["configurable"]["checkpoint_id"]
assert keep == {head_id}, f"Expected only target, got {keep}"
async def test_keepset_snapshot_at_target(
saver: BaseCheckpointSaver,
) -> None:
"""When target itself has a snapshot, keep-set is just {target_id}."""
tid = str(uuid4())
configs = await build_delta_chain(
saver,
thread_id=tid,
channel="ch",
snapshots_at_steps=[0, 3],
total_steps=4,
)
head = configs[3]
keep = await saver.aget_delta_channel_keepset(config=head, channels=["ch"])
head_id = head["configurable"]["checkpoint_id"]
assert keep == {head_id}, f"Snapshot at target should yield only target, got {keep}"
async def test_keepset_snapshot_n_back(
saver: BaseCheckpointSaver,
) -> None:
"""Snapshot N steps back → target + intermediates + snapshot ancestor."""
tid = str(uuid4())
configs = await build_delta_chain(
saver, thread_id=tid, channel="ch", snapshots_at_steps=[0], total_steps=5
)
head = configs[-1]
keep = await saver.aget_delta_channel_keepset(config=head, channels=["ch"])
expected_ids = {c["configurable"]["checkpoint_id"] for c in configs}
assert keep == expected_ids, f"Expected all ancestors, got {keep}"
async def test_keepset_multi_channel_union(
saver: BaseCheckpointSaver,
) -> None:
"""Multi-channel keep-set is the union (max chain per channel)."""
tid = str(uuid4())
from langgraph.checkpoint.base import Checkpoint
from langgraph.checkpoint.base.id import uuid6
from langgraph.checkpoint.serde.types import _DeltaSnapshot
from langgraph.checkpoint.conformance.test_utils import generate_metadata
configs: list = []
parent_cfg = None
for step in range(6):
config = {"configurable": {"thread_id": tid, "checkpoint_ns": ""}}
if parent_cfg:
config["configurable"]["checkpoint_id"] = parent_cfg["configurable"][
"checkpoint_id"
]
cv: dict = {}
cvs: dict = {}
# Channel "a" has snapshot at step 3 (recent)
if step == 3:
cv["a"] = _DeltaSnapshot("snap_a")
cvs["a"] = step + 1
# Channel "b" has snapshot at step 1 (further back)
if step == 1:
cv["b"] = _DeltaSnapshot("snap_b")
cvs["b"] = step + 1
cp = Checkpoint(
v=1,
id=str(uuid6(clock_seq=-1)),
ts="",
channel_values=cv,
channel_versions=cvs,
versions_seen={},
updated_channels=None,
)
parent_cfg = await saver.aput(config, cp, generate_metadata(step=step), cvs)
configs.append(parent_cfg)
head = configs[-1]
keep = await saver.aget_delta_channel_keepset(config=head, channels=["a", "b"])
# Union: b needs back to step 1, so steps 1..5 (all except step 0) plus head
expected_ids = {c["configurable"]["checkpoint_id"] for c in configs[1:]}
expected_ids.add(head["configurable"]["checkpoint_id"])
assert keep == expected_ids, f"Expected union, got {keep} vs {expected_ids}"
async def test_keepset_walk_to_root(
saver: BaseCheckpointSaver,
) -> None:
"""No snapshot anywhere → entire chain to root is in keep-set."""
tid = str(uuid4())
configs = await build_delta_chain(
saver, thread_id=tid, channel="ch", snapshots_at_steps=[], total_steps=4
)
head = configs[-1]
keep = await saver.aget_delta_channel_keepset(config=head, channels=["ch"])
all_ids = {c["configurable"]["checkpoint_id"] for c in configs}
assert keep == all_ids, f"Expected full chain, got {keep}"
async def test_keepset_deterministic(
saver: BaseCheckpointSaver,
) -> None:
"""Same inputs return identical sets."""
tid = str(uuid4())
configs = await build_delta_chain(
saver, thread_id=tid, channel="ch", snapshots_at_steps=[0], total_steps=5
)
head = configs[-1]
keep1 = await saver.aget_delta_channel_keepset(config=head, channels=["ch"])
keep2 = await saver.aget_delta_channel_keepset(config=head, channels=["ch"])
assert keep1 == keep2
ALL_DELTA_CHANNEL_KEEPSET_TESTS = [
test_keepset_empty_channels_returns_target_only,
test_keepset_snapshot_at_target,
test_keepset_snapshot_n_back,
test_keepset_multi_channel_union,
test_keepset_walk_to_root,
test_keepset_deterministic,
]
async def run_delta_channel_keepset_tests(
saver: BaseCheckpointSaver,
on_test_result: Callable[[str, str, bool, str | None], None] | None = None,
) -> tuple[int, int, list[str]]:
"""Run all delta_channel_keepset tests. Returns (passed, failed, failure_names)."""
passed = 0
failed = 0
failures: list[str] = []
for test_fn in ALL_DELTA_CHANNEL_KEEPSET_TESTS:
try:
await test_fn(saver)
passed += 1
if on_test_result:
on_test_result("delta_channel_keepset", test_fn.__name__, True, None)
except Exception:
failed += 1
msg = f"{test_fn.__name__}: {traceback.format_exc()}"
failures.append(msg)
if on_test_result:
on_test_result(
"delta_channel_keepset",
test_fn.__name__,
False,
traceback.format_exc(),
)
return passed, failed, failures
@@ -0,0 +1,164 @@
"""DELTA_CHANNEL_RECONSTRUCTION capability tests — end-to-end round-trip.
Exercises: aput + aput_writes + aget_delta_channel_history + reconstruction.
This catches the most common silent-corruption mode: failing to round-trip
`_DeltaSnapshot` blobs through serialization.
NOTE: This test does NOT import from `langgraph` (which is not a dependency
of checkpoint-conformance). Instead it inlines a minimal reconstruction
equivalent: seed + fold writes through a simple list-append reducer.
"""
from __future__ import annotations
import traceback
from collections.abc import Callable
from typing import Any
from uuid import uuid4
from langgraph.checkpoint.base import BaseCheckpointSaver
from langgraph.checkpoint.conformance.spec._delta_fixtures import build_delta_chain
def _reconstruct(seed: Any, writes: list) -> list:
"""Minimal DeltaChannel reconstruction: list-append reducer.
Mirrors DeltaChannel.from_checkpoint(seed) + replay_writes(writes).
"""
from langgraph.checkpoint.serde.types import _DeltaSnapshot
if seed is None:
base: list = []
elif isinstance(seed, _DeltaSnapshot):
base = list(seed.value)
else:
base = list(seed)
for _task_id, _ch, value in writes:
base = base + value
return base
async def test_reconstruction_basic(
saver: BaseCheckpointSaver,
) -> None:
"""Reconstruct delta channel value from history matches expected."""
tid = str(uuid4())
# 5 steps: snapshot at 0 (value=[0]), writes at 1,2,3,4.
# Head = step 4. Walk from parent (step 3) collects writes 1,2,3.
configs = await build_delta_chain(
saver,
thread_id=tid,
channel="msgs",
snapshots_at_steps=[0],
total_steps=5,
write_value_fn=lambda step: [step],
)
head = configs[-1]
result = await saver.aget_delta_channel_history(config=head, channels=["msgs"])
history = result["msgs"]
seed = history.get("seed")
reconstructed = _reconstruct(seed, history["writes"])
# seed=[0] from step 0, writes from steps 1,2,3
expected = [0] + [1] + [2] + [3]
assert reconstructed == expected, (
f"Reconstructed {reconstructed} != expected {expected}"
)
async def test_reconstruction_mid_chain_snapshot(
saver: BaseCheckpointSaver,
) -> None:
"""Reconstruction works when snapshot is mid-chain."""
tid = str(uuid4())
# 6 steps: snapshots at 0 and 3, writes at 1,2,4,5.
# Head = step 5. Walk from step 4 stops at step 3 (snapshot).
# Collects writes from step 4.
configs = await build_delta_chain(
saver,
thread_id=tid,
channel="msgs",
snapshots_at_steps=[0, 3],
total_steps=6,
write_value_fn=lambda step: [step],
)
head = configs[-1]
result = await saver.aget_delta_channel_history(config=head, channels=["msgs"])
history = result["msgs"]
seed = history.get("seed")
reconstructed = _reconstruct(seed, history["writes"])
# Snapshot at step 3 = [3], write from step 4
expected = [3] + [4]
assert reconstructed == expected, (
f"Reconstructed {reconstructed} != expected {expected}"
)
async def test_reconstruction_no_snapshot(
saver: BaseCheckpointSaver,
) -> None:
"""Reconstruction from root (no snapshot) gives all writes accumulated."""
tid = str(uuid4())
# 4 steps: no snapshot, writes at 0,1,2,3.
# Head = step 3. Walk from step 2 collects writes 0,1,2.
configs = await build_delta_chain(
saver,
thread_id=tid,
channel="msgs",
snapshots_at_steps=[],
total_steps=4,
write_value_fn=lambda step: [step],
)
head = configs[-1]
result = await saver.aget_delta_channel_history(config=head, channels=["msgs"])
history = result["msgs"]
seed = history.get("seed")
reconstructed = _reconstruct(seed, history["writes"])
# No seed → start empty, writes from steps 0,1,2
expected = [0] + [1] + [2]
assert reconstructed == expected, (
f"Reconstructed {reconstructed} != expected {expected}"
)
ALL_DELTA_CHANNEL_RECONSTRUCTION_TESTS = [
test_reconstruction_basic,
test_reconstruction_mid_chain_snapshot,
test_reconstruction_no_snapshot,
]
async def run_delta_channel_reconstruction_tests(
saver: BaseCheckpointSaver,
on_test_result: Callable[[str, str, bool, str | None], None] | None = None,
) -> tuple[int, int, list[str]]:
"""Run all reconstruction tests. Returns (passed, failed, failure_names)."""
passed = 0
failed = 0
failures: list[str] = []
for test_fn in ALL_DELTA_CHANNEL_RECONSTRUCTION_TESTS:
try:
await test_fn(saver)
passed += 1
if on_test_result:
on_test_result(
"delta_channel_reconstruction", test_fn.__name__, True, None
)
except Exception:
failed += 1
msg = f"{test_fn.__name__}: {traceback.format_exc()}"
failures.append(msg)
if on_test_result:
on_test_result(
"delta_channel_reconstruction",
test_fn.__name__,
False,
traceback.format_exc(),
)
return passed, failed, failures
@@ -19,6 +19,15 @@ from langgraph.checkpoint.conformance.spec.test_delete_for_runs import (
from langgraph.checkpoint.conformance.spec.test_delete_thread import (
run_delete_thread_tests,
)
from langgraph.checkpoint.conformance.spec.test_delta_channel_history import (
run_delta_channel_history_tests,
)
from langgraph.checkpoint.conformance.spec.test_delta_channel_keepset import (
run_delta_channel_keepset_tests,
)
from langgraph.checkpoint.conformance.spec.test_delta_channel_reconstruction import (
run_delta_channel_reconstruction_tests,
)
from langgraph.checkpoint.conformance.spec.test_get_tuple import run_get_tuple_tests
from langgraph.checkpoint.conformance.spec.test_list import run_list_tests
from langgraph.checkpoint.conformance.spec.test_prune import run_prune_tests
@@ -35,6 +44,9 @@ _RUNNERS = {
Capability.DELETE_FOR_RUNS: run_delete_for_runs_tests,
Capability.COPY_THREAD: run_copy_thread_tests,
Capability.PRUNE: run_prune_tests,
Capability.DELTA_CHANNEL_HISTORY: run_delta_channel_history_tests,
Capability.DELTA_CHANNEL_KEEPSET: run_delta_channel_keepset_tests,
Capability.DELTA_CHANNEL_RECONSTRUCTION: run_delta_channel_reconstruction_tests,
}
@@ -43,7 +43,11 @@ asyncio_mode = "auto"
# The extended methods (acopy_thread, adelete_for_runs, aprune) are checked
# at runtime via capability detection and may not exist on the installed
# base class. Dict literal inference is also overly strict for RunnableConfig.
# Delta-channel tests import from `langgraph` (not a declared dep of this
# package — at test time it is installed alongside); private `_DeltaSnapshot`
# imports are intentional (beta surface).
unresolved-attribute = "ignore"
unresolved-import = "ignore"
invalid-argument-type = "ignore"
invalid-return-type = "ignore"
@@ -58,6 +62,9 @@ lint.select = [
lint.ignore = ["E501", "B008"]
target-version = "py310"
[tool.uv.sources]
langgraph-checkpoint = {path = "../checkpoint", editable = true}
[[tool.uv.index]]
name = "testpypi"
url = "https://test.pypi.org/simple/"
+605 -504
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,37 @@
"""Run delta-channel conformance capabilities against AsyncSqliteSaver."""
from __future__ import annotations
import pytest
pytest.importorskip(
"langgraph.checkpoint.conformance",
reason="langgraph-checkpoint-conformance not installed",
)
pytest.importorskip("aiosqlite", reason="aiosqlite not installed")
@pytest.mark.asyncio
async def test_delta_channel_conformance():
from langgraph.checkpoint.conformance import validate
from langgraph.checkpoint.conformance.initializer import checkpointer_test
from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver
@checkpointer_test(name="AsyncSqliteSaver")
async def sqlite_saver():
async with AsyncSqliteSaver.from_conn_string(":memory:") as saver:
yield saver
report = await validate(
sqlite_saver,
capabilities={
"delta_channel_history",
"delta_channel_keepset",
"delta_channel_reconstruction",
},
)
for cap, result in report.results.items():
if result.passed is False:
details = "\n".join(result.failures or [])
pytest.fail(f"Capability {cap} failed:\n{details}")
@@ -327,6 +327,14 @@ class BaseCheckpointSaver(Generic[V]):
Args:
run_ids: The run IDs whose checkpoints should be deleted.
!!! warning "DeltaChannel"
Deleting a run that produced ancestor `checkpoint_writes` — or
the only `_DeltaSnapshot` blob — for a still-live thread will
break reconstruction of any `DeltaChannel` whose history
depended on those rows. See the `DeltaChannel` note on `prune`
for safe-recovery strategies.
"""
raise NotImplementedError
@@ -340,6 +348,17 @@ class BaseCheckpointSaver(Generic[V]):
Args:
source_thread_id: The thread ID to copy from.
target_thread_id: The thread ID to copy to.
!!! warning "DeltaChannel"
Implementations must copy the **complete** parent chain (all
ancestor checkpoints and their `checkpoint_writes`) — copying
only the head checkpoint will leave the target thread with
`DeltaChannel` state that cannot be reconstructed (no path back
to a `_DeltaSnapshot` ancestor). Equivalently, the copy must
include enough ancestors that every `DeltaChannel`-backed key
has either a `_DeltaSnapshot` in `channel_values` somewhere in
the chain, or a complete write history back to the chain root.
"""
raise NotImplementedError
@@ -355,6 +374,34 @@ class BaseCheckpointSaver(Generic[V]):
thread_ids: The thread IDs to prune.
strategy: The pruning strategy. `"keep_latest"` retains only the most
recent checkpoint per namespace. `"delete"` removes all checkpoints.
!!! warning "DeltaChannel"
Custom implementations must be `DeltaChannel`-aware. `DeltaChannel`
stores only a sentinel in `channel_values` for non-snapshot steps;
reconstruction walks the parent chain via
`get_delta_channel_history`, accumulating rows from
`checkpoint_writes` until it reaches an ancestor whose
`channel_values` contains a `_DeltaSnapshot` blob (written every
`snapshot_frequency` updates).
A naive `"keep_latest"` that drops intermediate checkpoints and
their writes can sever that chain: the surviving "latest"
checkpoint is rarely a snapshot point itself, so its delta
channels would silently reconstruct as empty (no error raised —
`get_delta_channel_history` simply returns no `seed`). Safe
options when the graph uses `DeltaChannel`:
* Walk back from each kept checkpoint and preserve every
ancestor (plus its `checkpoint_writes`) up to the nearest one
whose `channel_values` already contains a `_DeltaSnapshot` for
every `DeltaChannel`-backed key.
* Force a fresh snapshot on the kept checkpoint before deleting
ancestors — rewrite `channel_values[k] = _DeltaSnapshot(value)`
for each delta channel `k` (resolving `value` via the existing
ancestor walk first), then prune.
* Skip pruning threads whose graph uses `DeltaChannel` until one
of the above is implemented.
"""
raise NotImplementedError
@@ -471,6 +518,13 @@ class BaseCheckpointSaver(Generic[V]):
Args:
run_ids: The run IDs whose checkpoints should be deleted.
!!! warning "DeltaChannel"
See `delete_for_runs` — deleting rows a still-live thread's
`DeltaChannel` reconstruction depends on (writes between the
head and its nearest `_DeltaSnapshot` ancestor) will silently
corrupt that channel's state.
"""
raise NotImplementedError
@@ -484,6 +538,13 @@ class BaseCheckpointSaver(Generic[V]):
Args:
source_thread_id: The thread ID to copy from.
target_thread_id: The thread ID to copy to.
!!! warning "DeltaChannel"
See `copy_thread` — the copy must carry the complete parent
chain (or at least back to a `_DeltaSnapshot` ancestor for every
`DeltaChannel`) so the target thread can reconstruct delta
state.
"""
raise NotImplementedError
@@ -499,6 +560,13 @@ class BaseCheckpointSaver(Generic[V]):
thread_ids: The thread IDs to prune.
strategy: The pruning strategy. `"keep_latest"` retains only the most
recent checkpoint per namespace. `"delete"` removes all checkpoints.
!!! warning "DeltaChannel"
See `prune` for the full `DeltaChannel` caveat. In short:
`"keep_latest"` must not drop ancestor checkpoints / writes that
sit between the kept checkpoint and the nearest `_DeltaSnapshot`
ancestor, or delta channels will silently reconstruct as empty.
"""
raise NotImplementedError
@@ -612,6 +680,123 @@ class BaseCheckpointSaver(Generic[V]):
result[ch] = entry
return result
def get_delta_channel_keepset(
self,
*,
config: RunnableConfig,
channels: Sequence[str],
) -> set[str]:
"""Return ancestor checkpoint_ids that must survive deletion.
!!! warning "Beta"
This method is part of the `DeltaChannel` support surface and is
in beta. The signature may change while the delta-channel design
stabilizes.
Walks the parent chain from `config` backward, collecting visited
checkpoint_ids (inclusive of the target), and terminates per-channel
when that channel has a populated `channel_values[ch]` (a
`_DeltaSnapshot` blob or a pre-migration plain value). The returned
set is the minimum keep-set: every checkpoint_id whose removal would
break reconstruction of the listed channels at `config`.
Pass `channels=[]` to return just `{config.checkpoint_id}` — useful
for graphs that don't use `DeltaChannel`.
Compose this into custom `prune` / `delete_for_runs` / `copy_thread`::
keep = saver.get_delta_channel_keepset(
config=head_config, channels=delta_channels,
)
delete_rows_not_in(keep)
Note:
The default implementation here uses repeated `get_tuple` calls
to walk the parent chain. This is a basic reference implementation
suitable for low-frequency maintenance operations (prune, etc.).
Custom checkpointer backends may override with a more efficient
version tailored to their data model (e.g. a single SQL query
with a recursive CTE), but it is not required — the default works
correctly for any saver that implements `get_tuple`.
Args:
config: Configuration identifying the target checkpoint.
channels: Channel names whose delta history must be preserved.
Empty sequence means only the target checkpoint_id is kept.
Returns:
Set of checkpoint_ids that must not be deleted.
"""
target_tuple = self.get_tuple(config)
if target_tuple is None:
return set()
target_id = target_tuple.config["configurable"]["checkpoint_id"]
keep: set[str] = {target_id}
if not channels:
return keep
remaining: set[str] = set(channels)
for ch in list(remaining):
if ch in target_tuple.checkpoint["channel_values"]:
remaining.discard(ch)
if not remaining:
return keep
cursor_config: RunnableConfig | None = target_tuple.parent_config
while cursor_config is not None and remaining:
tup = self.get_tuple(cursor_config)
if tup is None:
break
cid = tup.config["configurable"]["checkpoint_id"]
keep.add(cid)
for ch in list(remaining):
if ch in tup.checkpoint["channel_values"]:
remaining.discard(ch)
if not remaining:
break
cursor_config = tup.parent_config
return keep
async def aget_delta_channel_keepset(
self,
*,
config: RunnableConfig,
channels: Sequence[str],
) -> set[str]:
"""Async version of `get_delta_channel_keepset`.
!!! warning "Beta"
This method is part of the `DeltaChannel` support surface and is
in beta. See `get_delta_channel_keepset` for full documentation.
"""
target_tuple = await self.aget_tuple(config)
if target_tuple is None:
return set()
target_id = target_tuple.config["configurable"]["checkpoint_id"]
keep: set[str] = {target_id}
if not channels:
return keep
remaining: set[str] = set(channels)
for ch in list(remaining):
if ch in target_tuple.checkpoint["channel_values"]:
remaining.discard(ch)
if not remaining:
return keep
cursor_config: RunnableConfig | None = target_tuple.parent_config
while cursor_config is not None and remaining:
tup = await self.aget_tuple(cursor_config)
if tup is None:
break
cid = tup.config["configurable"]["checkpoint_id"]
keep.add(cid)
for ch in list(remaining):
if ch in tup.checkpoint["channel_values"]:
remaining.discard(ch)
if not remaining:
break
cursor_config = tup.parent_config
return keep
def get_next_version(self, current: V | None, channel: None) -> V:
"""Generate the next version ID for a channel.
@@ -0,0 +1,35 @@
"""Run delta-channel conformance capabilities against InMemorySaver."""
from __future__ import annotations
import pytest
conformance = pytest.importorskip(
"langgraph.checkpoint.conformance",
reason="langgraph-checkpoint-conformance not installed",
)
@pytest.mark.asyncio
async def test_delta_channel_conformance():
from langgraph.checkpoint.conformance import validate
from langgraph.checkpoint.conformance.initializer import checkpointer_test
from langgraph.checkpoint.memory import InMemorySaver
@checkpointer_test(name="InMemorySaver")
async def mem_saver():
yield InMemorySaver()
report = await validate(
mem_saver,
capabilities={
"delta_channel_history",
"delta_channel_keepset",
"delta_channel_reconstruction",
},
)
for cap, result in report.results.items():
if result.passed is False:
details = "\n".join(result.failures or [])
pytest.fail(f"Capability {cap} failed:\n{details}")