mirror of
https://github.com/langchain-ai/langgraph.git
synced 2026-08-24 16:42:24 +02:00
DeltaChannel reconstructs its value by replaying ancestor writes through the reducer. Every saver ordered a checkpoint's writes by (task_id, idx), but live execution applies them in task-path order: apply_writes sorts a super-step's tasks by task_path_str(task.path[:3]) before calling channel.update. task_id is a hash of the path, so the two orders are unrelated, and two or more tasks writing one DeltaChannel in a single super-step replayed in an arbitrary permutation. Reducers are only required to be batching-invariant, not order-invariant, so the permutation changes the value: get_state disagreed with what invoke returned, and continuing the thread persisted the reordered replay as the base for later writes. Replay now orders by (task_path, task_id, idx), following the precedent already set for the Send channel by SELECT_PENDING_SENDS_SQL. InMemorySaver and the postgres savers already persisted task_path and only needed the sort key; sqlite accepted task_path on put_writes and dropped it, so the writes table gains the column plus a probe-guarded ALTER for databases created by earlier versions. Writes stored without a task_path sort first within their checkpoint, which is where live execution applies the task-less input writes that carry "". The graph-level regression tests run against the full async_checkpointer matrix (memory, sqlite, postgres in three pool modes) because each saver reconstructs delta channels through its own override; 20 of the 25 cases fail on main, and the 5 that pass are the sequential control. Co-authored-by: ErenAta16 <149434812+ErenAta16@users.noreply.github.com> Co-authored-by: ragnarok268 <58264829+ragnarok268@users.noreply.github.com>
215 lines
7.1 KiB
Python
215 lines
7.1 KiB
Python
"""Tests for the additive `writes.task_path` migration (#8382).
|
|
|
|
`task_path` records the path of the task that produced a write, so delta
|
|
channel replay can restore the order `apply_writes` applied a super-step's
|
|
writes in. The sqlite savers previously accepted `task_path` on `put_writes`
|
|
and dropped it, so the column has to be added to databases created by earlier
|
|
versions as well as to fresh ones.
|
|
|
|
Sqlite has no `ADD COLUMN IF NOT EXISTS`, so `setup()` probes
|
|
`pragma_table_info` before issuing the `ALTER` — these tests pin that the
|
|
probe makes the migration both effective and repeatable.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import sqlite3
|
|
from pathlib import Path
|
|
|
|
import aiosqlite
|
|
import pytest
|
|
from langgraph.checkpoint.base import empty_checkpoint
|
|
|
|
from langgraph.checkpoint.sqlite import SqliteSaver
|
|
from langgraph.checkpoint.sqlite._schema import ADD_WRITES_TASK_PATH_SQL
|
|
from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver
|
|
|
|
# The `writes` table as created before `task_path` existed.
|
|
LEGACY_SCHEMA = """
|
|
CREATE TABLE checkpoints (
|
|
thread_id TEXT NOT NULL,
|
|
checkpoint_ns TEXT NOT NULL DEFAULT '',
|
|
checkpoint_id TEXT NOT NULL,
|
|
parent_checkpoint_id TEXT,
|
|
type TEXT,
|
|
checkpoint BLOB,
|
|
metadata BLOB,
|
|
PRIMARY KEY (thread_id, checkpoint_ns, checkpoint_id)
|
|
);
|
|
CREATE TABLE writes (
|
|
thread_id TEXT NOT NULL,
|
|
checkpoint_ns TEXT NOT NULL DEFAULT '',
|
|
checkpoint_id TEXT NOT NULL,
|
|
task_id TEXT NOT NULL,
|
|
idx INTEGER NOT NULL,
|
|
channel TEXT NOT NULL,
|
|
type TEXT,
|
|
value BLOB,
|
|
PRIMARY KEY (thread_id, checkpoint_ns, checkpoint_id, task_id, idx)
|
|
);
|
|
"""
|
|
|
|
|
|
def _write_legacy_db(path: Path) -> None:
|
|
conn = sqlite3.connect(path)
|
|
try:
|
|
conn.executescript(LEGACY_SCHEMA)
|
|
conn.commit()
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def _columns(conn: sqlite3.Connection, table: str) -> list[str]:
|
|
return [row[1] for row in conn.execute(f"PRAGMA table_info({table})")]
|
|
|
|
|
|
def test_fresh_database_has_task_path(tmp_path: Path) -> None:
|
|
with SqliteSaver.from_conn_string(str(tmp_path / "fresh.sqlite")) as saver:
|
|
saver.setup()
|
|
assert "task_path" in _columns(saver.conn, "writes")
|
|
|
|
|
|
def test_legacy_database_gains_task_path(tmp_path: Path) -> None:
|
|
db = tmp_path / "legacy.sqlite"
|
|
_write_legacy_db(db)
|
|
|
|
with SqliteSaver.from_conn_string(str(db)) as saver:
|
|
saver.setup()
|
|
columns = _columns(saver.conn, "writes")
|
|
|
|
assert "task_path" in columns
|
|
# Existing columns are untouched — this is additive, not a table rebuild.
|
|
assert columns[:8] == [
|
|
"thread_id",
|
|
"checkpoint_ns",
|
|
"checkpoint_id",
|
|
"task_id",
|
|
"idx",
|
|
"channel",
|
|
"type",
|
|
"value",
|
|
]
|
|
|
|
|
|
def test_setup_is_repeatable_on_migrated_database(tmp_path: Path) -> None:
|
|
"""A second `setup()` must not re-issue the `ALTER`.
|
|
|
|
Sqlite raises `duplicate column name` rather than ignoring it, so an
|
|
unguarded `ALTER` would break every reopen of a migrated database.
|
|
"""
|
|
db = tmp_path / "legacy.sqlite"
|
|
_write_legacy_db(db)
|
|
|
|
with SqliteSaver.from_conn_string(str(db)) as saver:
|
|
saver.setup()
|
|
saver.is_setup = False
|
|
saver.setup()
|
|
assert "task_path" in _columns(saver.conn, "writes")
|
|
|
|
# And again through a fresh connection to the migrated file.
|
|
with SqliteSaver.from_conn_string(str(db)) as saver:
|
|
saver.setup()
|
|
assert "task_path" in _columns(saver.conn, "writes")
|
|
|
|
|
|
def test_legacy_rows_keep_default_and_sort_first(tmp_path: Path) -> None:
|
|
"""Rows predating the column read back as `''` and order ahead of paths.
|
|
|
|
`''` precedes every `task_path_str` output, which puts pre-migration
|
|
writes before path-carrying ones within their checkpoint instead of
|
|
interleaving them under a rule that never applied to them.
|
|
"""
|
|
db = tmp_path / "legacy.sqlite"
|
|
_write_legacy_db(db)
|
|
|
|
conn = sqlite3.connect(db)
|
|
try:
|
|
conn.execute(
|
|
"INSERT INTO writes (thread_id, checkpoint_ns, checkpoint_id, task_id,"
|
|
" idx, channel, type, value) VALUES ('t', '', 'c', 'task', 0, 'ch',"
|
|
" 'null', X'')"
|
|
)
|
|
conn.commit()
|
|
finally:
|
|
conn.close()
|
|
|
|
with SqliteSaver.from_conn_string(str(db)) as saver:
|
|
saver.setup()
|
|
stored = saver.conn.execute("SELECT task_path FROM writes").fetchall()
|
|
assert stored == [("",)]
|
|
|
|
ordered = saver.conn.execute(
|
|
"SELECT task_path FROM writes ORDER BY task_path, task_id, idx"
|
|
).fetchall()
|
|
assert ordered[0] == ("",)
|
|
|
|
|
|
def test_setup_survives_losing_the_migration_race(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""`setup()` succeeds when another connection migrates first.
|
|
|
|
The `pragma_table_info` probe is not a lock. Two connections opening the
|
|
same file can both see the column missing, and whichever issues the `ALTER`
|
|
second gets `duplicate column name` — `ALTER TABLE ADD COLUMN` has no
|
|
`IF NOT EXISTS` form to fall back on, unlike the `CREATE TABLE`s above it.
|
|
|
|
Stubbing the probe to always report the column missing reproduces exactly
|
|
the losing interleaving (probe says absent, another connection adds it,
|
|
then we `ALTER`) without depending on thread timing.
|
|
"""
|
|
db = tmp_path / "legacy.sqlite"
|
|
_write_legacy_db(db)
|
|
|
|
# Winner of the race: migrates the file out from under the saver below.
|
|
winner = sqlite3.connect(db)
|
|
try:
|
|
winner.execute(ADD_WRITES_TASK_PATH_SQL)
|
|
winner.commit()
|
|
finally:
|
|
winner.close()
|
|
|
|
monkeypatch.setattr(
|
|
"langgraph.checkpoint.sqlite.HAS_WRITES_TASK_PATH_SQL", "SELECT 1 WHERE 0"
|
|
)
|
|
with SqliteSaver.from_conn_string(str(db)) as loser:
|
|
loser.setup()
|
|
assert "task_path" in _columns(loser.conn, "writes")
|
|
|
|
|
|
def test_put_writes_persists_task_path(tmp_path: Path) -> None:
|
|
with SqliteSaver.from_conn_string(str(tmp_path / "fresh.sqlite")) as saver:
|
|
config = saver.put(
|
|
{"configurable": {"thread_id": "t", "checkpoint_ns": ""}},
|
|
empty_checkpoint(),
|
|
{},
|
|
{},
|
|
)
|
|
saver.put_writes(config, [("ch", "v")], "task-1", "~__pregel_pull, node")
|
|
|
|
stored = saver.conn.execute("SELECT task_id, task_path FROM writes").fetchall()
|
|
assert stored == [("task-1", "~__pregel_pull, node")]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_async_saver_migrates_and_persists_task_path(tmp_path: Path) -> None:
|
|
db = tmp_path / "legacy.sqlite"
|
|
_write_legacy_db(db)
|
|
|
|
async with AsyncSqliteSaver.from_conn_string(str(db)) as saver:
|
|
await saver.setup()
|
|
config = await saver.aput(
|
|
{"configurable": {"thread_id": "t", "checkpoint_ns": ""}},
|
|
empty_checkpoint(),
|
|
{},
|
|
{},
|
|
)
|
|
await saver.aput_writes(config, [("ch", "v")], "task-1", "~__pregel_pull, node")
|
|
# Idempotent for the async saver too.
|
|
saver.is_setup = False
|
|
await saver.setup()
|
|
|
|
async with aiosqlite.connect(db) as conn:
|
|
async with conn.execute("SELECT task_id, task_path FROM writes") as cur:
|
|
assert await cur.fetchall() == [("task-1", "~__pregel_pull, node")]
|