mirror of
https://github.com/langchain-ai/langgraph.git
synced 2026-09-23 18:15:08 +02:00
Stage 1 of the sqlite delta history filtered `checkpoint_id <= target` and streamed `ORDER BY checkpoint_id DESC`. Both encode an extra assumption: that every child's checkpoint id sorts above its parent's. Ancestry is defined by `parent_checkpoint_id`, and nothing in the contract requires ids to be monotonic. A parent whose id sorted above its child's was dropped from the stream, so its stored value and its writes were lost with no error raised. Removing the range filter alone would not help: in DESC order that parent arrives before the target, so the walk passes it before it has started. Replace it with a recursive CTE anchored at the target that follows `parent_checkpoint_id`. Rows now arrive in walk order, so the off-path skip and parent tracking in `step_walk_with_row` are dead and removed, and the query reads only true ancestors instead of every row at or below the target. Following pointers can loop where a bounded id scan could not, and a loop is reachable through `put` alone: it writes with `INSERT OR REPLACE`, so re-putting an existing checkpoint id under a descendant's config repoints that checkpoint at its own descendant. The walk therefore stops on a repeated checkpoint id. sqlite yields recursive rows lazily, so abandoning the cursor ends the recursion. Postgres needs no equivalent change: it pages the whole thread without an id bound and follows parent pointers in Python, and its upsert never rewrites `parent_checkpoint_id`, so it cannot form this loop. Fixes #8550 Co-authored-by: lylelllll <59271327+lylelllll@users.noreply.github.com>
90 lines
3.3 KiB
Python
90 lines
3.3 KiB
Python
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
|
|
import pytest
|
|
from langgraph.checkpoint.base import (
|
|
BaseCheckpointSaver,
|
|
Checkpoint,
|
|
DeltaChannelHistory,
|
|
empty_checkpoint,
|
|
)
|
|
|
|
from langgraph.checkpoint.sqlite import SqliteSaver
|
|
from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver
|
|
|
|
CHANNEL = "ch"
|
|
CONFIG: dict[str, Any] = {"configurable": {"thread_id": "t", "checkpoint_ns": ""}}
|
|
EXPECTED: DeltaChannelHistory = {
|
|
"writes": [("task", CHANNEL, "write-root")],
|
|
"seed": "seed",
|
|
}
|
|
|
|
|
|
def _checkpoint(checkpoint_id: str, values: dict[str, Any]) -> Checkpoint:
|
|
value = empty_checkpoint()
|
|
value["id"] = checkpoint_id
|
|
value["channel_values"] = values
|
|
return value
|
|
|
|
|
|
PARENT_ID_ORDERS = [
|
|
pytest.param("z-older", "a-newer", id="parent_id_sorts_above_child"),
|
|
pytest.param("a-older", "z-newer", id="parent_id_sorts_below_child"),
|
|
]
|
|
|
|
|
|
@pytest.mark.parametrize(("root_id", "child_id"), PARENT_ID_ORDERS)
|
|
def test_sync_walk_reaches_parent_whatever_the_id_order(
|
|
root_id: str, child_id: str
|
|
) -> None:
|
|
with SqliteSaver.from_conn_string(":memory:") as saver:
|
|
root = saver.put(CONFIG, _checkpoint(root_id, {CHANNEL: "seed"}), {}, {})
|
|
saver.put_writes(root, [(CHANNEL, "write-root")], "task")
|
|
child = saver.put(root, _checkpoint(child_id, {}), {}, {})
|
|
|
|
got = saver.get_delta_channel_history(config=child, channels=[CHANNEL])
|
|
reference = BaseCheckpointSaver.get_delta_channel_history(
|
|
saver, config=child, channels=[CHANNEL]
|
|
)
|
|
assert got[CHANNEL] == EXPECTED
|
|
assert got[CHANNEL] == reference[CHANNEL], "fast path disagrees with base"
|
|
|
|
|
|
@pytest.mark.parametrize(("root_id", "child_id"), PARENT_ID_ORDERS)
|
|
async def test_async_walk_reaches_parent_whatever_the_id_order(
|
|
root_id: str, child_id: str
|
|
) -> None:
|
|
async with AsyncSqliteSaver.from_conn_string(":memory:") as saver:
|
|
root = await saver.aput(CONFIG, _checkpoint(root_id, {CHANNEL: "seed"}), {}, {})
|
|
await saver.aput_writes(root, [(CHANNEL, "write-root")], "task")
|
|
child = await saver.aput(root, _checkpoint(child_id, {}), {}, {})
|
|
|
|
got = await saver.aget_delta_channel_history(config=child, channels=[CHANNEL])
|
|
assert got[CHANNEL] == EXPECTED
|
|
|
|
|
|
def test_walk_reaches_root_of_long_chain_with_descending_ids() -> None:
|
|
steps = 40
|
|
with SqliteSaver.from_conn_string(":memory:") as saver:
|
|
parent = saver.put(
|
|
CONFIG, _checkpoint(f"id-{steps:03d}", {CHANNEL: "seed"}), {}, {}
|
|
)
|
|
saver.put_writes(parent, [(CHANNEL, "write-root")], "task")
|
|
for step in range(steps - 1, 0, -1):
|
|
parent = saver.put(parent, _checkpoint(f"id-{step:03d}", {}), {}, {})
|
|
|
|
got = saver.get_delta_channel_history(config=parent, channels=[CHANNEL])
|
|
assert got[CHANNEL] == EXPECTED
|
|
|
|
|
|
def test_walk_terminates_when_put_makes_the_parent_chain_cycle() -> None:
|
|
with SqliteSaver.from_conn_string(":memory:") as saver:
|
|
a = saver.put(CONFIG, _checkpoint("cid-a", {}), {}, {})
|
|
b = saver.put(a, _checkpoint("cid-b", {}), {}, {})
|
|
repoint_a_under_b = _checkpoint("cid-a", {})
|
|
saver.put(b, repoint_a_under_b, {}, {})
|
|
|
|
got = saver.get_delta_channel_history(config=b, channels=[CHANNEL])
|
|
assert got[CHANNEL] == {"writes": []}
|