mirror of
https://github.com/langchain-ai/langgraph.git
synced 2026-10-14 04:05:27 +02:00
`PostgresSaver` and `SqliteSaver`, sync and async, read a DeltaChannel's history from the latest checkpoint only when the config's `checkpoint_id` was missing or `None`. An empty one was looked up as an id, matched nothing, and came back with no seed and no writes, while their own `get_tuple` treats an empty id as the latest checkpoint, as the default implementation does. They now check for a missing or empty id the way `get_tuple` does. The in-memory saver gets the same in #9257. Graphs always pass the id of a saved checkpoint, so only direct callers of the saver API hit this. ## Tests - `test_{sync,async}_empty_checkpoint_id_reads_from_the_latest_checkpoint` in `checkpoint-postgres/tests/test_delta_pagination.py` and `checkpoint-sqlite/tests/test_delta_parent_walk.py`: all four fail on main. - The `checkpoint-postgres` (Postgres 16) and `checkpoint-sqlite` suites pass, and `lint_package` and `lint_tests` are clean in both.
212 lines
7.3 KiB
Python
212 lines
7.3 KiB
Python
from __future__ import annotations
|
|
|
|
from collections.abc import Mapping, Sequence
|
|
from typing import Any
|
|
from uuid import uuid4
|
|
|
|
import pytest
|
|
from langgraph.checkpoint.base import (
|
|
Checkpoint,
|
|
DeltaChannelHistory,
|
|
empty_checkpoint,
|
|
)
|
|
from langgraph.checkpoint.base.id import uuid6
|
|
from langgraph.checkpoint.serde.types import _DeltaSnapshot
|
|
|
|
from langgraph.checkpoint.postgres import PostgresSaver
|
|
from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver
|
|
from langgraph.checkpoint.postgres.base import _DELTA_PAGE_SIZE, BasePostgresSaver
|
|
from tests.conftest import DEFAULT_URI
|
|
|
|
CHANNEL = "items"
|
|
STEPS = 8
|
|
SEED_STEP = 1
|
|
SEED_VALUE = [10, 20]
|
|
TARGET_STEP = 4
|
|
|
|
# The real page size is the control; the rest split the walk from the target
|
|
# to its seed across pages.
|
|
PAGE_SIZES = [_DELTA_PAGE_SIZE, 3, 2, 1]
|
|
|
|
|
|
def _step_args(
|
|
thread_id: str, step: int, parent: dict | None
|
|
) -> tuple[dict, Checkpoint, dict[str, Any]]:
|
|
config: dict = {"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}}
|
|
if parent is not None:
|
|
config["configurable"]["checkpoint_id"] = parent["configurable"][
|
|
"checkpoint_id"
|
|
]
|
|
checkpoint: Checkpoint = empty_checkpoint()
|
|
checkpoint["id"] = str(uuid6(clock_seq=step))
|
|
checkpoint["channel_versions"][CHANNEL] = f"v{step}"
|
|
if step == SEED_STEP:
|
|
checkpoint["channel_values"][CHANNEL] = _DeltaSnapshot(list(SEED_VALUE))
|
|
return config, checkpoint, {CHANNEL: f"v{step}"}
|
|
return config, checkpoint, {}
|
|
|
|
|
|
async def _abuild_chain(saver: AsyncPostgresSaver) -> list[dict]:
|
|
thread_id = str(uuid4())
|
|
parent: dict | None = None
|
|
configs: list[dict] = []
|
|
for step in range(STEPS):
|
|
config, checkpoint, new_versions = _step_args(thread_id, step, parent)
|
|
parent = await saver.aput(
|
|
config,
|
|
checkpoint,
|
|
{"source": "loop", "step": step, "parents": {}},
|
|
new_versions,
|
|
)
|
|
await saver.aput_writes(parent, [(CHANNEL, f"w{step}")], str(uuid4()))
|
|
configs.append(parent)
|
|
return configs
|
|
|
|
|
|
def _build_chain(saver: PostgresSaver) -> list[dict]:
|
|
thread_id = str(uuid4())
|
|
parent: dict | None = None
|
|
configs: list[dict] = []
|
|
for step in range(STEPS):
|
|
config, checkpoint, new_versions = _step_args(thread_id, step, parent)
|
|
parent = saver.put(
|
|
config,
|
|
checkpoint,
|
|
{"source": "loop", "step": step, "parents": {}},
|
|
new_versions,
|
|
)
|
|
saver.put_writes(parent, [(CHANNEL, f"w{step}")], str(uuid4()))
|
|
configs.append(parent)
|
|
return configs
|
|
|
|
|
|
def _assert_history(entry: DeltaChannelHistory, page_size: int) -> None:
|
|
seed = entry.get("seed")
|
|
assert isinstance(seed, _DeltaSnapshot), (
|
|
f"page_size={page_size}: expected a snapshot seed, "
|
|
f"got {entry.get('seed', '<missing>')!r}"
|
|
)
|
|
assert seed.value == SEED_VALUE
|
|
assert [w[2] for w in entry["writes"]] == ["w1", "w2", "w3"], (
|
|
f"page_size={page_size}: got {[w[2] for w in entry['writes']]}"
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize("page_size", PAGE_SIZES)
|
|
async def test_async_walk_continues_across_pages(
|
|
page_size: int, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
monkeypatch.setattr("langgraph.checkpoint.postgres.aio._DELTA_PAGE_SIZE", page_size)
|
|
async with AsyncPostgresSaver.from_conn_string(DEFAULT_URI) as saver:
|
|
await saver.setup()
|
|
configs = await _abuild_chain(saver)
|
|
result = await saver.aget_delta_channel_history(
|
|
config=configs[TARGET_STEP], channels=[CHANNEL]
|
|
)
|
|
_assert_history(result[CHANNEL], page_size)
|
|
|
|
|
|
@pytest.mark.parametrize("page_size", PAGE_SIZES)
|
|
def test_sync_walk_continues_across_pages(
|
|
page_size: int, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
monkeypatch.setattr("langgraph.checkpoint.postgres._DELTA_PAGE_SIZE", page_size)
|
|
with PostgresSaver.from_conn_string(DEFAULT_URI) as saver:
|
|
saver.setup()
|
|
configs = _build_chain(saver)
|
|
result = saver.get_delta_channel_history(
|
|
config=configs[TARGET_STEP], channels=[CHANNEL]
|
|
)
|
|
_assert_history(result[CHANNEL], page_size)
|
|
|
|
|
|
def _record_rows_read(monkeypatch: pytest.MonkeyPatch) -> list[str]:
|
|
read: list[str] = []
|
|
ingest = BasePostgresSaver._ingest_stage1_page
|
|
|
|
def record(rows: Sequence[Mapping[str, Any]], *args: Any) -> str | None:
|
|
read.extend(row["checkpoint_id"] for row in rows)
|
|
return ingest(rows, *args)
|
|
|
|
monkeypatch.setattr(BasePostgresSaver, "_ingest_stage1_page", staticmethod(record))
|
|
return read
|
|
|
|
|
|
def _ids_from_target_down(configs: list[dict]) -> list[str]:
|
|
return [c["configurable"]["checkpoint_id"] for c in configs[TARGET_STEP::-1]]
|
|
|
|
|
|
async def test_async_walk_reads_nothing_newer_than_the_target(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
read = _record_rows_read(monkeypatch)
|
|
async with AsyncPostgresSaver.from_conn_string(DEFAULT_URI) as saver:
|
|
await saver.setup()
|
|
configs = await _abuild_chain(saver)
|
|
result = await saver.aget_delta_channel_history(
|
|
config=configs[TARGET_STEP], channels=[CHANNEL]
|
|
)
|
|
_assert_history(result[CHANNEL], _DELTA_PAGE_SIZE)
|
|
assert read == _ids_from_target_down(configs)
|
|
|
|
|
|
def test_sync_walk_reads_nothing_newer_than_the_target(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
read = _record_rows_read(monkeypatch)
|
|
with PostgresSaver.from_conn_string(DEFAULT_URI) as saver:
|
|
saver.setup()
|
|
configs = _build_chain(saver)
|
|
result = saver.get_delta_channel_history(
|
|
config=configs[TARGET_STEP], channels=[CHANNEL]
|
|
)
|
|
_assert_history(result[CHANNEL], _DELTA_PAGE_SIZE)
|
|
assert read == _ids_from_target_down(configs)
|
|
|
|
|
|
def _empty_checkpoint_id(config: dict) -> dict:
|
|
return {"configurable": {**config["configurable"], "checkpoint_id": ""}}
|
|
|
|
|
|
async def test_async_empty_checkpoint_id_reads_from_the_latest_checkpoint() -> None:
|
|
async with AsyncPostgresSaver.from_conn_string(DEFAULT_URI) as saver:
|
|
await saver.setup()
|
|
configs = await _abuild_chain(saver)
|
|
latest = await saver.aget_delta_channel_history(
|
|
config=configs[-1], channels=[CHANNEL]
|
|
)
|
|
|
|
result = await saver.aget_delta_channel_history(
|
|
config=_empty_checkpoint_id(configs[-1]), channels=[CHANNEL]
|
|
)
|
|
|
|
assert latest[CHANNEL]["writes"]
|
|
assert result == latest
|
|
|
|
|
|
def test_sync_empty_checkpoint_id_reads_from_the_latest_checkpoint() -> None:
|
|
with PostgresSaver.from_conn_string(DEFAULT_URI) as saver:
|
|
saver.setup()
|
|
configs = _build_chain(saver)
|
|
latest = saver.get_delta_channel_history(config=configs[-1], channels=[CHANNEL])
|
|
|
|
result = saver.get_delta_channel_history(
|
|
config=_empty_checkpoint_id(configs[-1]), channels=[CHANNEL]
|
|
)
|
|
|
|
assert latest[CHANNEL]["writes"]
|
|
assert result == latest
|
|
|
|
|
|
async def test_root_target_has_no_history_and_still_terminates(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.setattr("langgraph.checkpoint.postgres.aio._DELTA_PAGE_SIZE", 1)
|
|
async with AsyncPostgresSaver.from_conn_string(DEFAULT_URI) as saver:
|
|
await saver.setup()
|
|
configs = await _abuild_chain(saver)
|
|
result = await saver.aget_delta_channel_history(
|
|
config=configs[0], channels=[CHANNEL]
|
|
)
|
|
assert result[CHANNEL] == {"writes": []}
|