mirror of
https://github.com/langchain-ai/langgraph.git
synced 2026-10-14 12:15:25 +02:00
Fixes langchain-ai/langgraph#8382 `DeltaChannel` rebuilds its value by replaying ancestor writes through the reducer. Every saver ordered a checkpoint's writes by `(task_id, idx)`, but live execution applies a super-step's writes in task-path order (`apply_writes` sorts tasks by `task_path_str(task.path[:3])`). `task_id` is a hash of the path, so when two or more tasks write one `DeltaChannel` in the same super-step, replay applies them in an arbitrary permutation. Reducers must be batching-invariant, not order-invariant, so the permutation changes the value: `get_state` disagrees with what `invoke` returned, and continuing the thread saves the reordered value as the base for every later write. ## Fix Replay orders by `(task_path, task_id, idx)`, the order `SELECT_PENDING_SENDS_SQL` already uses for sends. `apply_writes` and every saver now sort with one `writes_sort_key` from `langgraph.checkpoint.base`, in Python, so live and replay can't drift apart and don't depend on a database collation. `apply_writes` sorts by the full task path, the string `put_writes` stores, instead of `path[:3]`. The paths that reach `apply_writes` no longer carry task ids, so the live order doesn't change. `BaseCheckpointSaver`'s default `get_delta_channel_history` can't sort by path, since `PendingWrite` has no task path, so it replays each checkpoint's writes in `get_tuple` order. `get_tuple` now has to return `pending_writes` in `writes_sort_key` order, and memory, SQLite and Postgres do, so the default walk is right for any saver that follows that. `AsyncPostgresSaver` also gets the sync `get_delta_channel_history` that `AsyncSqliteSaver` already had; sync reads used to fall back to the default walk. Memory and Postgres already stored `task_path`. SQLite accepted it on `put_writes` and dropped it, so `writes` gains the column and `setup()` adds it to existing databases. Writes without a task path (graph input, `update_state` updates, rows written before the column existed) sort first by `task_id`, which is also where live execution applies input. ## Compatibility - Memory and Postgres already had `task_path` on disk, so existing threads replay in the corrected order after upgrade. - On SQLite, rows from before the upgrade read back as `''` and keep their old order. Only new writes get the corrected order, since there is no stored path to recover. - SQLite has no `ADD COLUMN IF NOT EXISTS`, so `setup()` runs the `ALTER` and treats `duplicate column name` as success, which also covers two processes migrating one file. That error comes while SQLite prepares the statement, before it asks for the write lock, so `setup()` on an up-to-date database doesn't wait on other writers. A read-only database from before the column is read with empty paths instead of failing setup. - Downgrade is safe: older code's `INSERT` omits the column and the column has a default. - `get_tuple` and `list` return `pending_writes` in `writes_sort_key` order instead of task id order (SQLite, Postgres) or put order (memory). Nothing in langgraph depends on the old order. - Postgres reads `task_path` and `idx` with each pending write, so a subclass that pairs `SELECT_SQL` with its own `_load_writes`, or the other way round, has to match the new row shape. - A third-party saver has to replay in `writes_sort_key` order: in its own `get_delta_channel_history` if it has one, otherwise by returning `pending_writes` from `get_tuple` in that order. Otherwise it fails the new conformance tests; the `get_tuple` one is a base test, so it runs for every saver. - langgraph, checkpoint-sqlite and checkpoint-postgres import `writes_sort_key` from langgraph-checkpoint, so this bumps langgraph-checkpoint to 4.3.0 and requires it there. 4.3.0 has to be released first. ## Limits - Several updates in one `bulk_update_state` super-step share a task id, so the saver keeps only the first one's writes and the `DeltaChannel` loses the rest (plain channels are fine). That's on main too; #9128 fixes it and stores a task path per update, so with this PR those updates also replay in the order given. - Exit durability stores its writes differently; resume ordering there is fixed in #9114, stacked on this PR. - langgraphjs applies `DeltaChannel` writes in task id order, live and on replay (langchain-ai/langgraphjs#2544), so its reads match too. Moving JS to task path order is a follow-up. ## Tests Each fails on main and passes here: - `test_delta_channel_parallel_order.py`, across memory, SQLite and Postgres: parallel writers and a `Send` fan-out, comparing `get_state` with the live result, including sync `get_state` on the async savers. - Three conformance tests whose task ids sort opposite to their task paths, so they cannot pass by chance: two on `get_delta_channel_history`, one on `get_tuple`'s `pending_writes` order. - SQLite migration tests from a pre-column database, sync and async, including a read-only file. One more that passes on main too: SQLite `setup()` while another connection holds the write lock, with no busy timeout, sync and async. It keeps the `ALTER` from waiting on that lock. Thanks to @ErenAta16 for the report, the reproduction, and for tracing `task_path` through all three backends, and to @ragnarok268 for the conformance-test design with task ids ordered opposite to their paths. --------- Co-authored-by: ErenAta16 <149434812+ErenAta16@users.noreply.github.com> Co-authored-by: ragnarok268 <58264829+ragnarok268@users.noreply.github.com>
193 lines
7.6 KiB
Python
193 lines
7.6 KiB
Python
"""Shared helpers for `get_delta_channel_history` on sqlite savers.
|
|
|
|
Mirrors the two-stage shape of `BasePostgresSaver` (ancestor walk +
|
|
per-channel UNION ALL writes fetch), but adapted for sqlite's
|
|
constraints. The structural differences:
|
|
|
|
* No JSONB — to inspect `channel_values` for a checkpoint we must
|
|
deserialize the full blob. Stage 1 streams the cursor row-by-row and
|
|
deserializes only the rows the merged walk visits, freeing each blob
|
|
before advancing.
|
|
* No separate blob table — `channel_values` lives inline in the
|
|
checkpoint, so seeds come back from stage 1 with no second fetch.
|
|
* Single merged walk (not K independent walks): each visited cid is
|
|
deserialized exactly once, regardless of how many channels are still
|
|
seeking their seed.
|
|
|
|
The streaming design keeps peak in-flight memory at roughly one
|
|
deserialized checkpoint at a time, instead of holding the entire
|
|
ancestor chain's worth of raw blobs as a `fetchall()`-materialized list.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from collections.abc import Mapping, Sequence
|
|
from typing import Any
|
|
|
|
from langgraph.checkpoint.base import (
|
|
DeltaChannelHistory,
|
|
PendingWrite,
|
|
writes_sort_key,
|
|
)
|
|
|
|
# Stage 1 streams target, then its ancestors nearest-first, by following
|
|
# `parent_checkpoint_id` rather than id order: ids are only monotonic within
|
|
# one process, so a range scan by id can miss a parent whose id sorts above
|
|
# its child's. Target is the anchor row; its own writes/seed are skipped
|
|
# (matches the `BaseCheckpointSaver` contract).
|
|
#
|
|
# `put` is `INSERT OR REPLACE`, so re-putting an existing id under a
|
|
# descendant's config makes the chain a loop. `step_walk_with_row` stops on a
|
|
# repeated id; sqlite yields recursive rows lazily, so abandoning the cursor
|
|
# ends the recursion.
|
|
#
|
|
# `CROSS JOIN` pins `ancestors` as the outer loop, so each step is one primary
|
|
# key lookup. With a plain `JOIN` and no `ANALYZE` stats, sqlite can put
|
|
# `checkpoints` outside and scan the whole thread per step.
|
|
DELTA_STAGE1_SQL = (
|
|
"WITH RECURSIVE ancestors(checkpoint_id, parent_checkpoint_id, type, "
|
|
"checkpoint) AS ("
|
|
"SELECT checkpoint_id, parent_checkpoint_id, type, checkpoint "
|
|
"FROM checkpoints "
|
|
"WHERE thread_id = ? AND checkpoint_ns = ? AND checkpoint_id = ? "
|
|
"UNION ALL "
|
|
"SELECT c.checkpoint_id, c.parent_checkpoint_id, c.type, c.checkpoint "
|
|
"FROM ancestors a CROSS JOIN checkpoints c "
|
|
"ON c.checkpoint_id = a.parent_checkpoint_id "
|
|
"WHERE c.thread_id = ? AND c.checkpoint_ns = ?"
|
|
") "
|
|
"SELECT checkpoint_id, type, checkpoint FROM ancestors"
|
|
)
|
|
|
|
|
|
def build_delta_stage2_sql(
|
|
*, chain_lens: Sequence[int], has_task_path: bool = True
|
|
) -> str:
|
|
"""Stage-2 per-channel UNION ALL fetching writes from `writes`.
|
|
|
|
One branch per channel with a non-empty chain. Each branch inlines its
|
|
own `IN (?, ?, ...)` placeholder list because sqlite has no array-bind
|
|
equivalent of postgres's `= ANY(%s)`. Caller passes parameters in
|
|
matching order: `[thread_id, checkpoint_ns, channel, *chain_cids]` per
|
|
branch.
|
|
|
|
Returns an empty string when no channel has a chain (caller skips
|
|
executing in that case). Per-channel UNION ALL avoids the over-fetch
|
|
of a single `channel = ANY(channels)` filter when channels have
|
|
different chain depths — same rationale as postgres.
|
|
"""
|
|
task_path = "task_path" if has_task_path else "''"
|
|
branches: list[str] = []
|
|
for n in chain_lens:
|
|
cid_placeholders = ",".join("?" * n)
|
|
branches.append(
|
|
f"SELECT checkpoint_id, channel, task_id, idx, type, value, {task_path} "
|
|
"FROM writes "
|
|
"WHERE thread_id = ? AND checkpoint_ns = ? AND channel = ? "
|
|
f"AND checkpoint_id IN ({cid_placeholders})"
|
|
)
|
|
return " UNION ALL ".join(branches)
|
|
|
|
|
|
def step_walk_with_row(
|
|
*,
|
|
cid: str,
|
|
type_tag: str,
|
|
blob: bytes,
|
|
target_id: str,
|
|
serde: Any,
|
|
chain_by_ch: dict[str, list[str]],
|
|
seed_val_by_ch: dict[str, Any],
|
|
walk_state: dict[str, Any],
|
|
seeded: set[str],
|
|
channels: Sequence[str],
|
|
) -> bool:
|
|
"""Process one streamed stage-1 row in the merged ancestor walk.
|
|
|
|
The cursor returns (cid, type, blob) rows in walk order starting at
|
|
target. The first row is target itself and is skipped (target's own
|
|
writes/seed are not part of the contract).
|
|
|
|
For each subsequent row we deserialize the blob, append the cid to
|
|
every not-yet-seeded channel's chain, and check `channel_values` for
|
|
seeds. The deserialized checkpoint is dropped before advancing — no
|
|
cross-row cache, so peak in-flight is one deserialized checkpoint.
|
|
|
|
Returns True when the caller can stop iterating and close the cursor:
|
|
every requested channel is seeded, or the chain revisited a checkpoint.
|
|
"""
|
|
if "started" not in walk_state:
|
|
if cid == target_id:
|
|
walk_state["started"] = True
|
|
walk_state["active"] = {ch for ch in channels if ch not in seeded}
|
|
walk_state["walked"] = {cid}
|
|
# Not target yet (or target not present): keep streaming.
|
|
return False
|
|
active: set[str] = walk_state["active"]
|
|
if not active:
|
|
return True
|
|
walked: set[str] = walk_state["walked"]
|
|
if cid in walked:
|
|
return True
|
|
walked.add(cid)
|
|
for ch in active:
|
|
chain_by_ch[ch].append(cid)
|
|
ckpt = serde.loads_typed((type_tag, blob))
|
|
channel_values: Mapping[str, Any] = ckpt.get("channel_values") or {}
|
|
for ch in [ch for ch in active if ch in channel_values]:
|
|
seed_val_by_ch[ch] = channel_values[ch]
|
|
seeded.add(ch)
|
|
active.discard(ch)
|
|
del ckpt, channel_values
|
|
return not active
|
|
|
|
|
|
def build_delta_channels_writes_history(
|
|
*,
|
|
channels: Sequence[str],
|
|
chain_by_ch: Mapping[str, list[str]],
|
|
seed_val_by_ch: Mapping[str, Any],
|
|
seeded: set[str],
|
|
stage2_rows: Sequence[tuple[str, str, str, int, str, bytes, str]],
|
|
serde: Any,
|
|
) -> dict[str, DeltaChannelHistory]:
|
|
"""Demux stage-2 rows per channel; produce per-channel histories.
|
|
|
|
Stage-2 rows are
|
|
`(checkpoint_id, channel, task_id, idx, type, value, task_path)`.
|
|
Final write order is oldest→newest globally and `writes_sort_key`
|
|
within a checkpoint, matching the contract on
|
|
`DeltaChannelHistory.writes`.
|
|
|
|
`seed` is omitted when the walk reached a true root with no snapshot
|
|
found (channel never entered `seeded`); consumers treat absence as
|
|
"start empty".
|
|
"""
|
|
writes_by_ch_by_cid: dict[
|
|
str, dict[str, list[tuple[str, bytes, str, int, str]]]
|
|
] = {ch: {} for ch in channels}
|
|
for cid, ch, task_id, idx, type_tag, value_blob, task_path in stage2_rows:
|
|
writes_by_ch_by_cid.setdefault(ch, {}).setdefault(cid, []).append(
|
|
(type_tag, value_blob, task_id, idx, task_path)
|
|
)
|
|
for cid_map in writes_by_ch_by_cid.values():
|
|
for ws in cid_map.values():
|
|
ws.sort(key=lambda w: writes_sort_key(w[4], w[2], w[3]))
|
|
|
|
result: dict[str, DeltaChannelHistory] = {}
|
|
for ch in channels:
|
|
chain_cids = chain_by_ch.get(ch, [])
|
|
cid_writes = writes_by_ch_by_cid.get(ch, {})
|
|
collected: list[PendingWrite] = []
|
|
# Chain is newest-first; iterate oldest-first for the public order.
|
|
for cid in reversed(chain_cids):
|
|
for type_tag, value_blob, task_id, _idx, _path in cid_writes.get(cid, []):
|
|
collected.append(
|
|
(task_id, ch, serde.loads_typed((type_tag, value_blob)))
|
|
)
|
|
entry: DeltaChannelHistory = {"writes": collected}
|
|
if ch in seeded:
|
|
entry["seed"] = seed_val_by_ch[ch]
|
|
result[ch] = entry
|
|
return result
|