Files
langgraph/libs/checkpoint-sqlite/langgraph/checkpoint/sqlite/_delta.py
T
2b93e8b79f fix: order delta channel replay by task path (#8544)
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>
2026-10-06 17:55:04 -04:00

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