mirror of
https://github.com/langchain-ai/langgraph.git
synced 2026-09-12 20:57:52 +02:00
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>
This commit is contained in:
@@ -64,7 +64,7 @@ _CAPABILITY_METHOD_MAP: dict[Capability, str] = {
|
||||
Capability.COPY_THREAD: "acopy_thread",
|
||||
Capability.PRUNE: "aprune",
|
||||
Capability.DELTA_CHANNEL_HISTORY: "aget_delta_channel_history",
|
||||
Capability.DELTA_CHANNEL_KEEPSET: "aget_delta_channel_keepset",
|
||||
Capability.DELTA_CHANNEL_KEEPSET: "aget_tuple",
|
||||
Capability.DELTA_CHANNEL_RECONSTRUCTION: "aput",
|
||||
}
|
||||
|
||||
|
||||
+2
-1
@@ -13,7 +13,6 @@ 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.serde.types import _DeltaSnapshot
|
||||
|
||||
from langgraph.checkpoint.conformance.test_utils import generate_metadata
|
||||
|
||||
@@ -48,6 +47,8 @@ async def build_delta_chain(
|
||||
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] = []
|
||||
|
||||
+8
-2
@@ -16,6 +16,9 @@ async def test_history_returns_writes_oldest_first(
|
||||
) -> 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
|
||||
)
|
||||
@@ -23,7 +26,7 @@ async def test_history_returns_writes_oldest_first(
|
||||
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, 4], f"Expected [1,2,3,4], got {values}"
|
||||
assert values == [1, 2, 3], f"Expected [1,2,3], got {values}"
|
||||
|
||||
|
||||
async def test_history_seed_is_nearest_snapshot(
|
||||
@@ -31,6 +34,9 @@ async def test_history_seed_is_nearest_snapshot(
|
||||
) -> 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,
|
||||
@@ -48,7 +54,7 @@ async def test_history_seed_is_nearest_snapshot(
|
||||
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, 5], f"Expected [4,5], got {values}"
|
||||
assert values == [4], f"Expected [4], got {values}"
|
||||
|
||||
|
||||
async def test_history_excludes_target_pending_writes(
|
||||
|
||||
+41
-43
@@ -1,14 +1,19 @@
|
||||
"""DELTA_CHANNEL_RECONSTRUCTION capability tests — end-to-end round-trip.
|
||||
|
||||
Exercises: aput + aput_writes + aget_delta_channel_history + from_checkpoint +
|
||||
replay_writes. This catches the most common silent-corruption mode: failing to
|
||||
round-trip `_DeltaSnapshot` blobs through serialization.
|
||||
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
|
||||
@@ -16,18 +21,31 @@ from langgraph.checkpoint.base import BaseCheckpointSaver
|
||||
from langgraph.checkpoint.conformance.spec._delta_fixtures import build_delta_chain
|
||||
|
||||
|
||||
def _list_reducer(state: list, writes: list) -> list:
|
||||
"""Simple append reducer for testing."""
|
||||
return state + writes
|
||||
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."""
|
||||
from langgraph.channels.delta import DeltaChannel
|
||||
|
||||
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,
|
||||
@@ -41,18 +59,10 @@ async def test_reconstruction_basic(
|
||||
history = result["msgs"]
|
||||
|
||||
seed = history.get("seed")
|
||||
from langgraph._internal._typing import MISSING
|
||||
reconstructed = _reconstruct(seed, history["writes"])
|
||||
|
||||
if seed is None:
|
||||
seed = MISSING
|
||||
|
||||
ch = DeltaChannel(_list_reducer, list)
|
||||
replay_ch = ch.from_checkpoint(seed)
|
||||
replay_ch.replay_writes(history["writes"])
|
||||
reconstructed = replay_ch.get()
|
||||
|
||||
# Expected: snapshot at step 0 = [0], then writes at steps 1,2,3,4
|
||||
expected = [0] + [1] + [2] + [3] + [4]
|
||||
# seed=[0] from step 0, writes from steps 1,2,3
|
||||
expected = [0] + [1] + [2] + [3]
|
||||
assert reconstructed == expected, (
|
||||
f"Reconstructed {reconstructed} != expected {expected}"
|
||||
)
|
||||
@@ -62,9 +72,10 @@ async def test_reconstruction_mid_chain_snapshot(
|
||||
saver: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
"""Reconstruction works when snapshot is mid-chain."""
|
||||
from langgraph.channels.delta import DeltaChannel
|
||||
|
||||
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,
|
||||
@@ -78,18 +89,10 @@ async def test_reconstruction_mid_chain_snapshot(
|
||||
history = result["msgs"]
|
||||
|
||||
seed = history.get("seed")
|
||||
from langgraph._internal._typing import MISSING
|
||||
reconstructed = _reconstruct(seed, history["writes"])
|
||||
|
||||
if seed is None:
|
||||
seed = MISSING
|
||||
|
||||
ch = DeltaChannel(_list_reducer, list)
|
||||
replay_ch = ch.from_checkpoint(seed)
|
||||
replay_ch.replay_writes(history["writes"])
|
||||
reconstructed = replay_ch.get()
|
||||
|
||||
# Snapshot at step 3 = [3], writes at steps 4,5
|
||||
expected = [3] + [4] + [5]
|
||||
# Snapshot at step 3 = [3], write from step 4
|
||||
expected = [3] + [4]
|
||||
assert reconstructed == expected, (
|
||||
f"Reconstructed {reconstructed} != expected {expected}"
|
||||
)
|
||||
@@ -99,9 +102,9 @@ async def test_reconstruction_no_snapshot(
|
||||
saver: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
"""Reconstruction from root (no snapshot) gives all writes accumulated."""
|
||||
from langgraph.channels.delta import DeltaChannel
|
||||
|
||||
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,
|
||||
@@ -114,16 +117,11 @@ async def test_reconstruction_no_snapshot(
|
||||
result = await saver.aget_delta_channel_history(config=head, channels=["msgs"])
|
||||
history = result["msgs"]
|
||||
|
||||
from langgraph._internal._typing import MISSING
|
||||
seed = history.get("seed")
|
||||
reconstructed = _reconstruct(seed, history["writes"])
|
||||
|
||||
seed = history.get("seed", MISSING)
|
||||
|
||||
ch = DeltaChannel(_list_reducer, list)
|
||||
replay_ch = ch.from_checkpoint(seed)
|
||||
replay_ch.replay_writes(history["writes"])
|
||||
reconstructed = replay_ch.get()
|
||||
|
||||
expected = [0] + [1] + [2] + [3]
|
||||
# No seed → start empty, writes from steps 0,1,2
|
||||
expected = [0] + [1] + [2]
|
||||
assert reconstructed == expected, (
|
||||
f"Reconstructed {reconstructed} != expected {expected}"
|
||||
)
|
||||
|
||||
@@ -63,6 +63,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/"
|
||||
|
||||
Generated
+605
-504
File diff suppressed because it is too large
Load Diff
@@ -727,6 +727,11 @@ class BaseCheckpointSaver(Generic[V]):
|
||||
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)
|
||||
@@ -763,6 +768,11 @@ class BaseCheckpointSaver(Generic[V]):
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user