mirror of
https://github.com/langchain-ai/langgraph.git
synced 2026-10-19 14:31:47 +02:00
Reading a DeltaChannel at an older checkpoint (the state at a past checkpoint, each entry of `get_state_history`, a fork or replay from one) walked `checkpoints` down from the newest row, so every newer row was read before the target's chain. A checkpoint's ancestors have smaller ids, the order `get_tuple` already relies on to find the latest checkpoint, so the walk now starts at the target: the first page is `checkpoint_id <= target`, later pages stay `< cursor`. The cursor is never NULL anymore, so the `(cursor IS NULL OR checkpoint_id < cursor)` predicate goes too. Once Postgres caches a generic plan for the prepared statement (`from_conn_string` sets `prepare_threshold=0`), that predicate is a filter rather than an index condition, and each later page re-read every newer row. `get_delta_channel_history` on a 6,000-checkpoint thread with a snapshot every 200 checkpoints, median of three runs of 60 reads each, same results before and after: | target | main | this PR | |---|---|---| | newest | 3.1 ms | 2.7 ms | | 1,000 back | 4.5 ms | 2.9 ms | | 3,000 back | 8.9 ms | 2.8 ms | | 5,000 back | 15.7 ms | 2.8 ms | The target is now always the first row read, so the pagination tests from #8556 can no longer leave it off the first page; they are renamed for what they still cover, a walk that continues across pages. ## Test plan - [x] `test_{sync,async}_walk_reads_nothing_newer_than_the_target` record the rows the walk reads; both fail on main, which also reads the three newer checkpoints. - [x] `checkpoint-postgres` suite on Postgres 16: 281 passed, 3 skipped. - [x] `langgraph` DeltaChannel tests against this saver (`-k "delta and not redis"`): 1,154 passed, 1 skipped.
178 lines
6.2 KiB
Python
178 lines
6.2 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)
|
|
|
|
|
|
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": []}
|