mirror of
https://github.com/langchain-ai/langgraph.git
synced 2026-08-17 21:25:46 +02:00
# DeltaChannel: sentinel-based checkpoint blobs + write-replay
reconstruction
## Summary
`DeltaChannel` is a new fold-reducer channel that stores only a
zero-byte sentinel in checkpoint blobs instead of the full accumulated
value. On restore, the runtime replays ancestor writes through the
reducer to reconstruct state. For long-running threads with large
accumulating state (e.g. message histories), this delivers dramatically
smaller checkpoint blobs with configurable read-depth bounds.
```python
from typing import Annotated
from typing_extensions import TypedDict
from langgraph.channels.delta import DeltaChannel
from langgraph.graph.message import _messages_delta_reducer
class State(TypedDict):
# blob per step: ~60 bytes (sentinel) instead of growing full list
messages: Annotated[list, DeltaChannel(_messages_delta_reducer)]
# bound read depth to 10 steps via periodic snapshots
messages_bounded: Annotated[list, DeltaChannel(_messages_delta_reducer, snapshot_frequency=10)]
```
---
## Storage benchmarks (InMemory, ~400 char/msg)
**Messages blob storage** (`checkpoint_blobs` bytes for the messages
channel):
| turns | add\_messages | delta(inf) | delta(freq=50) | delta(freq=10) |
delta(freq=5) |
|------:|-------------:|-----------:|---------------:|---------------:|--------------:|
| 10 | 91.0 KB | 60 B (1517x) | 60 B (1517x) | 14.4 KB (6x) | 32.6 KB
(3x) |
| 50 | 2.20 MB | 300 B (7347x) | 67.1 KB (33x) | 423 KB (5x) | 864 KB
(3x) |
| 100 | 8.78 MB | 600 B (14636x) | 310 KB (28x) | 1.72 MB (5x) | 3.48 MB
(3x) |
| 250 | 54.80 MB | 1.5 KB (36536x) | 2.09 MB (26x) | 10.87 MB (5x) |
21.84 MB (3x) |
| 500 | 219.19 MB | 3.0 KB (73063x) | 8.56 MB (26x) | 43.67 MB (5x) |
87.50 MB (3x) |
**Total checkpoint storage** (blobs + writes + metadata):
| turns | add\_messages | delta(inf) | delta(freq=50) | delta(freq=10) |
delta(freq=5) |
|------:|-------------:|-----------:|---------------:|---------------:|--------------:|
| 10 | 129.7 KB | 38.7 KB (3.4x) | 38.7 KB (3.4x) | 53.1 KB (2.4x) |
71.2 KB (1.8x) |
| 50 | 2.40 MB | 196 KB (12x) | 263 KB (9x) | 620 KB (3.9x) | 1.06 MB
(2.3x) |
| 100 | 9.18 MB | 394 KB (23x) | 703 KB (13x) | 2.12 MB (4.3x) | 3.87 MB
(2.4x) |
| 250 | 55.79 MB | 987 KB (57x) | 3.07 MB (18x) | 11.86 MB (4.7x) |
22.82 MB (2.4x) |
| 500 | 221.16 MB | 1.98 MB (112x) | 10.53 MB (21x) | 45.64 MB (4.9x) |
89.48 MB (2.5x) |
**Write-phase peak heap**:
| turns | add\_messages | delta(inf) | delta(freq=50) | delta(freq=10) |
delta(freq=5) |
|------:|-------------:|-----------:|---------------:|---------------:|--------------:|
| 10 | 456 KB | 199 KB (2.3x) | 199 KB (2.3x) | 212 KB (2.2x) | 232 KB
(2.0x) |
| 50 | 3.04 MB | 742 KB (4.1x) | 805 KB (3.8x) | 1.21 MB (2.5x) | 1.67
MB (1.8x) |
| 100 | 10.70 MB | 1.41 MB (7.6x) | 1.82 MB (5.9x) | 3.42 MB (3.1x) |
5.25 MB (2.0x) |
| 250 | 60.44 MB | 3.36 MB (18x) | 5.67 MB (11x) | 14.87 MB (4.1x) |
26.31 MB (2.3x) |
**Read-phase avg `get_state` latency** (5 calls, InMemory):
| turns | add\_messages | delta(inf) | delta(freq=50) | delta(freq=10) |
delta(freq=5) |
|------:|-------------:|-----------:|---------------:|---------------:|--------------:|
| 10 | 0.7 ms | 1.1 ms (0.6x) | 1.1 ms (0.6x) | 0.8 ms (0.9x) | 0.6 ms
(1.1x) |
| 50 | 2.7 ms | 5.3 ms (0.5x) | 3.5 ms (0.8x) | 2.7 ms (1.0x) | 2.7 ms
(1.0x) |
| 100 | 5.5 ms | 11.1 ms (0.5x) | 6.0 ms (0.9x) | 5.2 ms (1.1x) | 5.4 ms
(1.0x) |
| 250 | 12.9 ms | 27.2 ms (0.5x) | 13.6 ms (0.9x) | 12.9 ms (1.0x) |
13.0 ms (1.0x) |
**Postgres `get_tuple` read latency** (~100 tok/msg per step):
| steps | full-list | delta(inf) | delta(freq=50) | delta(freq=10) |
delta(freq=5) |
|------:|----------:|-----------:|---------------:|---------------:|--------------:|
| 10 | 0.29 ms | 0.21 ms (1.4x) | 0.19 ms (1.6x) | 0.19 ms (1.5x) | 0.19
ms (1.6x) |
| 50 | 0.19 ms | 0.15 ms (1.3x) | 0.19 ms (1.0x) | 0.22 ms (0.8x) | 0.29
ms (0.7x) |
| 100 | 0.27 ms | 0.17 ms (1.6x) | 0.22 ms (1.2x) | 0.23 ms (1.2x) |
0.21 ms (1.3x) |
| 500 | 0.60 ms | 0.30 ms (2.0x) | 0.66 ms (0.9x) | 0.56 ms (1.1x) |
0.69 ms (0.9x) |
**Takeaway:** `snapshot_frequency=10` matches full-list read latency
while still saving 5x on blob storage and ~4x on total storage.
---
## How it works
### Checkpoint blobs
`checkpoint()` always returns `DELTA_SENTINEL` (a zero-byte msgpack ext
marker) instead of the accumulated value. On restore, the saver's
`_get_channel_writes_history` walks the ancestor chain collecting
`checkpoint_writes` entries and replays them through the reducer:
```python
# blob stored per step: ~1 byte (sentinel)
# vs. full list growing O(N) every step with BinaryOperatorAggregate
```
### Reducer interface
`DeltaChannel` takes a **batch reducer** `(state, list[writes]) ->
state` — all writes for a step arrive in one call, enabling single-pass
implementations:
```python
# ❌ Don't use add_messages directly — it's a binary operator, not a batch reducer
messages: Annotated[list, DeltaChannel(add_messages)] # wrong
# ✅ Use _messages_delta_reducer — single pass, dedup by ID, RemoveMessage support
messages: Annotated[list, DeltaChannel(_messages_delta_reducer)]
# ✅ Or write your own batch reducer for custom types
def my_dict_reducer(state: dict, writes: list[dict]) -> dict:
result = dict(state)
for w in writes:
result.update(w)
return result
files: Annotated[dict, DeltaChannel(my_dict_reducer)]
```
### Snapshot frequency
`snapshot_frequency=N` writes a full `_DeltaSnapshot` blob every N
pregel steps, bounding replay depth regardless of thread length.
Snapshots are eager — written even if the channel had no update that
step, so the depth bound always holds:
```python
# Replay walks at most 10 ancestors before hitting a snapshot
messages: Annotated[list, DeltaChannel(_messages_delta_reducer, snapshot_frequency=10)]
```
### Migration from `BinaryOperatorAggregate`
Pre-existing threads written under `BinaryOperatorAggregate` work
transparently after swapping the annotation — the saver detects a
plain-value ancestor blob and uses it as the reconstruction seed:
```python
# Before: BinaryOperatorAggregate stores full list every step
items: Annotated[list, add_messages]
# After: DeltaChannel — existing checkpoints still readable, new steps use sentinel
items: Annotated[list, DeltaChannel(_messages_delta_reducer)]
```
### Async write-ordering safety
In `durability="async"` mode (default), `put_writes` calls are
fire-and-forget. `AsyncPregelLoop` tracks in-flight `aput_writes`
futures for DeltaChannel channels in `_delta_write_futs` and drains them
via `await asyncio.gather()` in `_checkpointer_put_after_previous`
before `aput()` — ensuring `checkpoint_writes` are durable before the
sentinel blob is committed.
---
## What's in scope
- **`libs/langgraph/langgraph/channels/delta.py`** — `DeltaChannel`
implementation
- **`libs/langgraph/langgraph/graph/message.py`** —
`_messages_delta_reducer` (experimental)
- **`libs/checkpoint/`** — `_get_channel_writes_history` ancestor-walk
API on `BaseCheckpointSaver`, `InMemorySaver` optimized override
- **`libs/checkpoint-postgres/`** — `PostgresSaver` /
`AsyncPostgresSaver` single-roundtrip UNION ALL override
- **`libs/langgraph/langgraph/pregel/`** — `channels_from_checkpoint` /
`create_checkpoint` wiring, async write-ordering safety
---
## Follow-ups
- **Batch reconstruction**: each DeltaChannel field issues its own
`_get_channel_writes_history` call; a single walk collecting all
sentinel channels would reduce roundtrips proportionally to the number
of DeltaChannel fields.
- **Sync write ordering**: `BackgroundExecutor.__exit__` guarantees
completion before `invoke()` returns, but within a run there's no
explicit ordering between `put_writes` and `put`. Two-phase commit for
sync would close this gap.
- **`ShallowPostgresSaver` compatibility**: shallow savers keep only the
latest checkpoint and have no parent chain to walk; DeltaChannel is
currently incompatible and should raise or warn at compile time.
- Updating the writes table w/ delta epoch ids for more efficient reads
- follow up w/ LSD checkpointer implementations to support delta
channel! and update prune
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: ccurme <chester.curme@gmail.com>
Co-authored-by: Will Fu-Hinthorn <will@langchain.dev>
418 lines
14 KiB
Python
418 lines
14 KiB
Python
# type: ignore
|
|
|
|
from contextlib import asynccontextmanager
|
|
from typing import Any
|
|
from uuid import uuid4
|
|
|
|
import pytest
|
|
from langchain_core.runnables import RunnableConfig
|
|
from langgraph.checkpoint.base import (
|
|
EXCLUDED_METADATA_KEYS,
|
|
Checkpoint,
|
|
CheckpointMetadata,
|
|
create_checkpoint,
|
|
empty_checkpoint,
|
|
)
|
|
from langgraph.checkpoint.serde.types import TASKS
|
|
from psycopg import AsyncConnection
|
|
from psycopg.rows import dict_row
|
|
from psycopg_pool import AsyncConnectionPool
|
|
|
|
from langgraph.checkpoint.postgres.aio import (
|
|
AsyncPostgresSaver,
|
|
AsyncShallowPostgresSaver,
|
|
)
|
|
from tests.conftest import DEFAULT_POSTGRES_URI
|
|
|
|
|
|
def _exclude_keys(config: dict[str, Any]) -> dict[str, Any]:
|
|
return {k: v for k, v in config.items() if k not in EXCLUDED_METADATA_KEYS}
|
|
|
|
|
|
@asynccontextmanager
|
|
async def _pool_saver():
|
|
"""Fixture for pool mode testing."""
|
|
database = f"test_{uuid4().hex[:16]}"
|
|
# create unique db
|
|
async with await AsyncConnection.connect(
|
|
DEFAULT_POSTGRES_URI, autocommit=True
|
|
) as conn:
|
|
await conn.execute(f"CREATE DATABASE {database}")
|
|
try:
|
|
# yield checkpointer
|
|
async with AsyncConnectionPool(
|
|
DEFAULT_POSTGRES_URI + database,
|
|
max_size=10,
|
|
kwargs={"autocommit": True, "row_factory": dict_row},
|
|
) as pool:
|
|
checkpointer = AsyncPostgresSaver(pool)
|
|
await checkpointer.setup()
|
|
yield checkpointer
|
|
finally:
|
|
# drop unique db
|
|
async with await AsyncConnection.connect(
|
|
DEFAULT_POSTGRES_URI, autocommit=True
|
|
) as conn:
|
|
await conn.execute(f"DROP DATABASE {database}")
|
|
|
|
|
|
@asynccontextmanager
|
|
async def _pipe_saver():
|
|
"""Fixture for pipeline mode testing."""
|
|
database = f"test_{uuid4().hex[:16]}"
|
|
# create unique db
|
|
async with await AsyncConnection.connect(
|
|
DEFAULT_POSTGRES_URI, autocommit=True
|
|
) as conn:
|
|
await conn.execute(f"CREATE DATABASE {database}")
|
|
try:
|
|
async with await AsyncConnection.connect(
|
|
DEFAULT_POSTGRES_URI + database,
|
|
autocommit=True,
|
|
prepare_threshold=0,
|
|
row_factory=dict_row,
|
|
) as conn:
|
|
checkpointer = AsyncPostgresSaver(conn)
|
|
await checkpointer.setup()
|
|
async with conn.pipeline() as pipe:
|
|
checkpointer = AsyncPostgresSaver(conn, pipe=pipe)
|
|
yield checkpointer
|
|
finally:
|
|
# drop unique db
|
|
async with await AsyncConnection.connect(
|
|
DEFAULT_POSTGRES_URI, autocommit=True
|
|
) as conn:
|
|
await conn.execute(f"DROP DATABASE {database}")
|
|
|
|
|
|
@asynccontextmanager
|
|
async def _base_saver():
|
|
"""Fixture for regular connection mode testing."""
|
|
database = f"test_{uuid4().hex[:16]}"
|
|
# create unique db
|
|
async with await AsyncConnection.connect(
|
|
DEFAULT_POSTGRES_URI, autocommit=True
|
|
) as conn:
|
|
await conn.execute(f"CREATE DATABASE {database}")
|
|
try:
|
|
async with await AsyncConnection.connect(
|
|
DEFAULT_POSTGRES_URI + database,
|
|
autocommit=True,
|
|
prepare_threshold=0,
|
|
row_factory=dict_row,
|
|
) as conn:
|
|
checkpointer = AsyncPostgresSaver(conn)
|
|
await checkpointer.setup()
|
|
yield checkpointer
|
|
finally:
|
|
# drop unique db
|
|
async with await AsyncConnection.connect(
|
|
DEFAULT_POSTGRES_URI, autocommit=True
|
|
) as conn:
|
|
await conn.execute(f"DROP DATABASE {database}")
|
|
|
|
|
|
@asynccontextmanager
|
|
async def _shallow_saver():
|
|
"""Fixture for shallow connection mode testing."""
|
|
database = f"test_{uuid4().hex[:16]}"
|
|
# create unique db
|
|
async with await AsyncConnection.connect(
|
|
DEFAULT_POSTGRES_URI, autocommit=True
|
|
) as conn:
|
|
await conn.execute(f"CREATE DATABASE {database}")
|
|
try:
|
|
async with await AsyncConnection.connect(
|
|
DEFAULT_POSTGRES_URI + database,
|
|
autocommit=True,
|
|
prepare_threshold=0,
|
|
row_factory=dict_row,
|
|
) as conn:
|
|
checkpointer = AsyncShallowPostgresSaver(conn)
|
|
await checkpointer.setup()
|
|
yield checkpointer
|
|
finally:
|
|
# drop unique db
|
|
async with await AsyncConnection.connect(
|
|
DEFAULT_POSTGRES_URI, autocommit=True
|
|
) as conn:
|
|
await conn.execute(f"DROP DATABASE {database}")
|
|
|
|
|
|
@asynccontextmanager
|
|
async def _saver(name: str):
|
|
if name == "base":
|
|
async with _base_saver() as saver:
|
|
yield saver
|
|
elif name == "shallow":
|
|
async with _shallow_saver() as saver:
|
|
yield saver
|
|
elif name == "pool":
|
|
async with _pool_saver() as saver:
|
|
yield saver
|
|
elif name == "pipe":
|
|
async with _pipe_saver() as saver:
|
|
yield saver
|
|
|
|
|
|
@pytest.fixture
|
|
def test_data():
|
|
"""Fixture providing test data for checkpoint tests."""
|
|
config_1: RunnableConfig = {
|
|
"configurable": {
|
|
"thread_id": "thread-1",
|
|
"checkpoint_id": "1",
|
|
"checkpoint_ns": "",
|
|
}
|
|
}
|
|
config_2: RunnableConfig = {
|
|
"configurable": {
|
|
"thread_id": "thread-2",
|
|
"checkpoint_id": "2",
|
|
"checkpoint_ns": "",
|
|
}
|
|
}
|
|
config_3: RunnableConfig = {
|
|
"configurable": {
|
|
"thread_id": "thread-2",
|
|
"checkpoint_id": "2-inner",
|
|
"checkpoint_ns": "inner",
|
|
}
|
|
}
|
|
|
|
chkpnt_1: Checkpoint = empty_checkpoint()
|
|
chkpnt_2: Checkpoint = create_checkpoint(chkpnt_1, {}, 1)
|
|
chkpnt_3: Checkpoint = empty_checkpoint()
|
|
|
|
metadata_1: CheckpointMetadata = {
|
|
"source": "input",
|
|
"step": 2,
|
|
"score": 1,
|
|
}
|
|
metadata_2: CheckpointMetadata = {
|
|
"source": "loop",
|
|
"step": 1,
|
|
"score": None,
|
|
}
|
|
metadata_3: CheckpointMetadata = {}
|
|
|
|
return {
|
|
"configs": [config_1, config_2, config_3],
|
|
"checkpoints": [chkpnt_1, chkpnt_2, chkpnt_3],
|
|
"metadata": [metadata_1, metadata_2, metadata_3],
|
|
}
|
|
|
|
|
|
@pytest.mark.parametrize("saver_name", ["base", "pool", "pipe", "shallow"])
|
|
async def test_combined_metadata(saver_name: str, test_data) -> None:
|
|
async with _saver(saver_name) as saver:
|
|
config = {
|
|
"configurable": {
|
|
"thread_id": "thread-2",
|
|
"checkpoint_ns": "",
|
|
"__super_private_key": "super_private_value",
|
|
},
|
|
"metadata": {"run_id": "my_run_id"},
|
|
}
|
|
chkpnt: Checkpoint = create_checkpoint(empty_checkpoint(), {}, 1)
|
|
metadata: CheckpointMetadata = {
|
|
"source": "loop",
|
|
"step": 1,
|
|
"score": None,
|
|
}
|
|
await saver.aput(config, chkpnt, metadata, {})
|
|
checkpoint = await saver.aget_tuple(config)
|
|
assert checkpoint.metadata == {
|
|
**metadata,
|
|
"run_id": "my_run_id",
|
|
}
|
|
|
|
|
|
@pytest.mark.parametrize("saver_name", ["base", "pool", "pipe", "shallow"])
|
|
async def test_asearch(saver_name: str, test_data) -> None:
|
|
async with _saver(saver_name) as saver:
|
|
configs = test_data["configs"]
|
|
checkpoints = test_data["checkpoints"]
|
|
metadata = test_data["metadata"]
|
|
|
|
await saver.aput(configs[0], checkpoints[0], metadata[0], {})
|
|
await saver.aput(configs[1], checkpoints[1], metadata[1], {})
|
|
await saver.aput(configs[2], checkpoints[2], metadata[2], {})
|
|
|
|
# call method / assertions
|
|
query_1 = {"source": "input"} # search by 1 key
|
|
query_2 = {
|
|
"step": 1,
|
|
} # search by multiple keys
|
|
query_3: dict[str, Any] = {} # search by no keys, return all checkpoints
|
|
query_4 = {"source": "update", "step": 1} # no match
|
|
|
|
search_results_1 = [c async for c in saver.alist(None, filter=query_1)]
|
|
assert len(search_results_1) == 1
|
|
assert search_results_1[0].metadata == {
|
|
**_exclude_keys(configs[0]["configurable"]),
|
|
**metadata[0],
|
|
}
|
|
|
|
search_results_2 = [c async for c in saver.alist(None, filter=query_2)]
|
|
assert len(search_results_2) == 1
|
|
assert search_results_2[0].metadata == {
|
|
**_exclude_keys(configs[1]["configurable"]),
|
|
**metadata[1],
|
|
}
|
|
|
|
search_results_3 = [c async for c in saver.alist(None, filter=query_3)]
|
|
assert len(search_results_3) == 3
|
|
|
|
search_results_4 = [c async for c in saver.alist(None, filter=query_4)]
|
|
assert len(search_results_4) == 0
|
|
|
|
# search by config (defaults to checkpoints across all namespaces)
|
|
search_results_5 = [
|
|
c async for c in saver.alist({"configurable": {"thread_id": "thread-2"}})
|
|
]
|
|
assert len(search_results_5) == 2
|
|
assert {
|
|
search_results_5[0].config["configurable"]["checkpoint_ns"],
|
|
search_results_5[1].config["configurable"]["checkpoint_ns"],
|
|
} == {"", "inner"}
|
|
|
|
|
|
@pytest.mark.parametrize("saver_name", ["base", "pool", "pipe", "shallow"])
|
|
async def test_null_chars(saver_name: str, test_data) -> None:
|
|
async with _saver(saver_name) as saver:
|
|
config = await saver.aput(
|
|
test_data["configs"][0],
|
|
test_data["checkpoints"][0],
|
|
{"my_key": "\x00abc"},
|
|
{},
|
|
)
|
|
assert (await saver.aget_tuple(config)).metadata["my_key"] == "abc" # type: ignore
|
|
assert [c async for c in saver.alist(None, filter={"my_key": "abc"})][
|
|
0
|
|
].metadata["my_key"] == "abc"
|
|
|
|
|
|
@pytest.mark.parametrize("saver_name", ["base", "pool", "pipe"])
|
|
async def test_pending_sends_migration(saver_name: str) -> None:
|
|
async with _saver(saver_name) as saver:
|
|
config = {
|
|
"configurable": {
|
|
"thread_id": "thread-1",
|
|
"checkpoint_ns": "",
|
|
}
|
|
}
|
|
|
|
# create the first checkpoint
|
|
# and put some pending sends
|
|
checkpoint_0 = empty_checkpoint()
|
|
config = await saver.aput(config, checkpoint_0, {}, {})
|
|
await saver.aput_writes(
|
|
config, [(TASKS, "send-1"), (TASKS, "send-2")], task_id="task-1"
|
|
)
|
|
await saver.aput_writes(config, [(TASKS, "send-3")], task_id="task-2")
|
|
|
|
# check that fetching checkpoint_0 doesn't attach pending sends
|
|
# (they should be attached to the next checkpoint)
|
|
tuple_0 = await saver.aget_tuple(config)
|
|
assert tuple_0.checkpoint["channel_values"] == {}
|
|
assert tuple_0.checkpoint["channel_versions"] == {}
|
|
|
|
# create the second checkpoint
|
|
checkpoint_1 = create_checkpoint(checkpoint_0, {}, 1)
|
|
config = await saver.aput(config, checkpoint_1, {}, {})
|
|
|
|
# check that pending sends are attached to checkpoint_1
|
|
tuple_1 = await saver.aget_tuple(config)
|
|
assert tuple_1.checkpoint["channel_values"] == {
|
|
TASKS: ["send-1", "send-2", "send-3"]
|
|
}
|
|
assert TASKS in tuple_1.checkpoint["channel_versions"]
|
|
|
|
# check that list also applies the migration
|
|
search_results = [
|
|
c async for c in saver.alist({"configurable": {"thread_id": "thread-1"}})
|
|
]
|
|
assert len(search_results) == 2
|
|
assert search_results[-1].checkpoint["channel_values"] == {}
|
|
assert search_results[-1].checkpoint["channel_versions"] == {}
|
|
assert search_results[0].checkpoint["channel_values"] == {
|
|
TASKS: ["send-1", "send-2", "send-3"]
|
|
}
|
|
assert TASKS in search_results[0].checkpoint["channel_versions"]
|
|
|
|
|
|
@pytest.mark.parametrize("saver_name", ["base", "pool", "pipe"])
|
|
async def test_get_checkpoint_no_channel_values(
|
|
monkeypatch, saver_name: str, test_data
|
|
) -> None:
|
|
"""Backwards compatibility test that verifies a checkpoint with no channel_values key can be retrieved without throwing an error."""
|
|
async with _saver(saver_name) as saver:
|
|
config = {
|
|
"configurable": {
|
|
"thread_id": "thread-2",
|
|
"checkpoint_ns": "",
|
|
"__super_private_key": "super_private_value",
|
|
},
|
|
"metadata": {"run_id": "my_run_id"},
|
|
}
|
|
chkpnt: Checkpoint = create_checkpoint(empty_checkpoint(), {}, 1)
|
|
await saver.aput(config, chkpnt, {}, {})
|
|
|
|
load_checkpoint_tuple = saver._load_checkpoint_tuple
|
|
|
|
async def patched_load_checkpoint_tuple(value):
|
|
value["checkpoint"].pop("channel_values", None)
|
|
return await load_checkpoint_tuple(value)
|
|
|
|
monkeypatch.setattr(
|
|
saver, "_load_checkpoint_tuple", patched_load_checkpoint_tuple
|
|
)
|
|
|
|
checkpoint = await saver.aget_tuple(config)
|
|
assert checkpoint.checkpoint["channel_values"] == {}
|
|
|
|
|
|
@pytest.mark.parametrize("saver_name", ["base", "pool", "pipe"])
|
|
async def test_delta_channel_chain_reconstruction(saver_name: str) -> None:
|
|
"""AsyncPostgresSaver reconstructs DeltaChannel chain via point-lookup traversal."""
|
|
pytest.importorskip(
|
|
"langgraph.channels.delta", reason="langgraph core not installed"
|
|
)
|
|
|
|
from typing import Annotated
|
|
|
|
from langchain_core.messages import AIMessage, HumanMessage
|
|
from langgraph.channels.delta import DeltaChannel
|
|
from langgraph.graph import START, StateGraph
|
|
from langgraph.graph.message import _messages_delta_reducer
|
|
from typing_extensions import TypedDict
|
|
|
|
class State(TypedDict):
|
|
messages: Annotated[list, DeltaChannel(_messages_delta_reducer)]
|
|
|
|
def respond(state: State) -> dict:
|
|
n = len(state["messages"])
|
|
return {"messages": [AIMessage(content=f"reply-{n}", id=f"ai-{n}")]}
|
|
|
|
builder = StateGraph(State)
|
|
builder.add_node("respond", respond)
|
|
builder.add_edge(START, "respond")
|
|
|
|
async with _saver(saver_name) as saver:
|
|
graph = builder.compile(checkpointer=saver)
|
|
config = {"configurable": {"thread_id": "diff-channel-test-1"}}
|
|
|
|
await graph.ainvoke({"messages": [HumanMessage(content="hi", id="h1")]}, config)
|
|
await graph.ainvoke(
|
|
{"messages": [HumanMessage(content="there", id="h2")]}, config
|
|
)
|
|
|
|
state = await graph.aget_state(config)
|
|
msgs = state.values["messages"]
|
|
assert len(msgs) == 4, f"expected 4, got {len(msgs)}: {msgs}"
|
|
assert msgs[0].content == "hi"
|
|
assert msgs[1].content == "reply-1"
|
|
assert msgs[2].content == "there"
|
|
assert msgs[3].content == "reply-3"
|