Files
langgraph/libs/checkpoint-postgres/langgraph/checkpoint/postgres/base.py
T
Elior Nataf LackritzandGitHub d569e18f4b fix(checkpoint-postgres): find plain-value seeds when walking delta history (#8535)
Fixes langchain-ai/langgraph#8534

`put` splits stored values in two: primitives stay inline in the
checkpoint's `channel_values`, everything else moves to
`checkpoint_blobs`, and only `_DeltaSnapshot` leaves an inline marker
behind when it moves. Stage-1 seed detection tested for that marker, so
a plain value — what a thread migrated from `BinaryOperatorAggregate`
leaves behind — was invisible to the walk.

### Effect

Migrated threads found no seed, walked to the root, and replayed every
write on every read. Values still came out correct, because replaying an
additive reducer from empty rebuilds the same list, which is why nothing
looked wrong. What was lost is early termination — the entire point of
`DeltaChannel`:

<!-- linear:table-colwidths:266,266,266 -->
| thread length | writes replayed, before | after |
| -- | -- | -- |
| 2 turns | 3 | 1 |
| 6 turns | 7 | 1 |
| 20 turns | 21 | 1 |

Read latency is flat at \~0.6ms across all three after the change.

### Approach

Stage 1 now checks both places a value can live rather than trusting the
marker. It probes `checkpoint_blobs`:

```sql
EXISTS (SELECT 1 FROM checkpoint_blobs b0
        WHERE b0.thread_id     = checkpoints.thread_id
          AND b0.checkpoint_ns = checkpoints.checkpoint_ns
          AND b0.channel       = %s
          AND b0.version       = checkpoint -> 'channel_versions' ->> %s
          AND b0.type         <> 'empty') AS hb_0
```

and selects the inline value alongside it, since `None`, `str`, `int`,
`float` and `bool` stay in `channel_values` with no blob row:

```sql
checkpoint -> 'channel_values' -> %s AS inline_0
```

The blob predicate matches `checkpoint_blobs`' primary key `(thread_id,
checkpoint_ns, channel, version)` exactly, so it is one index lookup per
row per channel, bounded by the 1024-row page.

I picked reading storage over the cheaper alternative — also writing the
marker for plain values — because **that would not fix any thread
already on disk.** Existing checkpoints have no marker and there is
nowhere to add one retroactively.

The seed resolves to the blob when one exists and the inline value
otherwise. That ordering is also what keeps a genuine inline `true` — a
`bool` channel holding `True` — distinguishable from the literal `true`
marker `put` inlines for a `_DeltaSnapshot`: only the snapshot has a
blob.

`None` is deliberately not treated as a seed; a JSON null is
indistinguishable from "nothing stored" at this layer, so the walk
continues and replay from empty is correct.

Params go from two to four per channel; both callers updated.

The inline half came out of review on this PR — a blob-only probe would
have left scalar-aggregate migrations (an integer sum, say) still
replaying their full history.

### On the `type <> 'empty'` predicate

Being upfront since it isn't demonstrable with a test: `put` does not
currently produce `empty` rows on this path — `blob_versions` is
filtered to keys present in `channel_values`, so `_dump_blobs`' empty
branch is unreachable from it. I confirmed there are no `empty` rows in
a populated test database.

I kept it because stage 2 already applies the same check when resolving
the seed blob. Without it the two stages could disagree: stage 1
terminates the walk on a row stage 2 then discards, producing no seed
*and* a truncated write chain — the same failure shape this function
exists to avoid. Rationale is in the docstring so the next reader
doesn't have to ask. Happy to drop it if you'd rather not carry an
unexercised predicate.

### Tests

`libs/checkpoint-postgres/tests/test_delta_plain_value_seed.py` —
blob-stored plain-value seed, `_DeltaSnapshot` seed, a version bump with
nothing stored (which must not stop the walk short of an older real
value), inline primitives (`int`, `str`, `float`, `None`), and inline
`True` versus the snapshot marker. Each fails against the behaviour it
fixes.

Verified: postgres suite 269 passed on PG 15 and 16; delta-channel
conformance against `AsyncPostgresSaver` went from 6 of 8 to 8 of 8,
including the pre-existing `test_history_migration_plain_value_as_seed`
failure this was causing; `make lint` clean.

### Not included

I wanted a Postgres conformance runner alongside `checkpoint-sqlite`'s,
but it needs `langgraph-checkpoint-conformance` as a dev dependency and
the contributing guide asks for maintainer sign-off before adding one.
The direct tests above cover the same ground without it.

Worth flagging separately: **conformance effectively runs against**
`InMemorySaver` **only today.** `libs/checkpoint-conformance/tests/`
contains just `test_validate_memory.py`, and `checkpoint-sqlite`'s
`test_conformance_delta.py` silently skips because the package isn't
installed in its test environment (`importorskip`). Wiring it up for
sqlite and postgres is what would have caught this bug, and
langchain-ai/langgraph#8534 notes it.

Sqlite is unaffected by the bug itself — it stores `channel_values`
inline and inspects them directly. `langgraph-api` already resolves
seeds by version rather than by marker.
2026-08-07 09:07:04 -04:00

667 lines
26 KiB
Python

from __future__ import annotations
import random
import warnings
from collections.abc import Mapping, Sequence
from importlib.metadata import version as get_version
from typing import Any, TypedDict, cast
from langchain_core.runnables import RunnableConfig
from langgraph.checkpoint.base import (
WRITES_IDX_MAP,
BaseCheckpointSaver,
ChannelVersions,
DeltaChannelHistory,
PendingWrite,
get_checkpoint_id,
)
from langgraph.checkpoint.serde.types import TASKS
from psycopg.types.json import Jsonb
# Page size for stage-1 paged scan in `get_delta_channel_history`. Internal
# constant — exposing this as a kwarg is left as a follow-up.
_DELTA_PAGE_SIZE = 1024
MetadataInput = dict[str, Any] | None
try:
major, minor = get_version("langgraph").split(".")[:2]
if int(major) == 0 and int(minor) < 5:
warnings.warn(
"You're using incompatible versions of langgraph and checkpoint-postgres. Please upgrade langgraph to avoid unexpected behavior.",
DeprecationWarning,
stacklevel=2,
)
except Exception:
# skip version check if running from source
pass
"""
To add a new migration, add a new string to the MIGRATIONS list.
The position of the migration in the list is the version number.
"""
MIGRATIONS = [
"""CREATE TABLE IF NOT EXISTS checkpoint_migrations (
v INTEGER PRIMARY KEY
);""",
"""CREATE TABLE IF NOT EXISTS checkpoints (
thread_id TEXT NOT NULL,
checkpoint_ns TEXT NOT NULL DEFAULT '',
checkpoint_id TEXT NOT NULL,
parent_checkpoint_id TEXT,
type TEXT,
checkpoint JSONB NOT NULL,
metadata JSONB NOT NULL DEFAULT '{}',
PRIMARY KEY (thread_id, checkpoint_ns, checkpoint_id)
);""",
"""CREATE TABLE IF NOT EXISTS checkpoint_blobs (
thread_id TEXT NOT NULL,
checkpoint_ns TEXT NOT NULL DEFAULT '',
channel TEXT NOT NULL,
version TEXT NOT NULL,
type TEXT NOT NULL,
blob BYTEA,
PRIMARY KEY (thread_id, checkpoint_ns, channel, version)
);""",
"""CREATE TABLE IF NOT EXISTS checkpoint_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,
blob BYTEA NOT NULL,
PRIMARY KEY (thread_id, checkpoint_ns, checkpoint_id, task_id, idx)
);""",
"ALTER TABLE checkpoint_blobs ALTER COLUMN blob DROP not null;",
# NOTE: this is a no-op migration to ensure that the versions in the migrations table are correct.
# This is necessary due to an empty migration previously added to the list.
"SELECT 1;",
"""
CREATE INDEX CONCURRENTLY IF NOT EXISTS checkpoints_thread_id_idx ON checkpoints(thread_id);
""",
"""
CREATE INDEX CONCURRENTLY IF NOT EXISTS checkpoint_blobs_thread_id_idx ON checkpoint_blobs(thread_id);
""",
"""
CREATE INDEX CONCURRENTLY IF NOT EXISTS checkpoint_writes_thread_id_idx ON checkpoint_writes(thread_id);
""",
"""ALTER TABLE checkpoint_writes ADD COLUMN IF NOT EXISTS task_path TEXT NOT NULL DEFAULT '';""",
]
SELECT_SQL = """
select
thread_id,
checkpoint,
checkpoint_ns,
checkpoint_id,
parent_checkpoint_id,
metadata,
(
select array_agg(array[bl.channel::bytea, bl.type::bytea, bl.blob])
from jsonb_each_text(checkpoint -> 'channel_versions')
inner join checkpoint_blobs bl
on bl.thread_id = checkpoints.thread_id
and bl.checkpoint_ns = checkpoints.checkpoint_ns
and bl.channel = jsonb_each_text.key
and bl.version = jsonb_each_text.value
) as channel_values,
(
select
array_agg(array[cw.task_id::text::bytea, cw.channel::bytea, cw.type::bytea, cw.blob] order by cw.task_id, cw.idx)
from checkpoint_writes cw
where cw.thread_id = checkpoints.thread_id
and cw.checkpoint_ns = checkpoints.checkpoint_ns
and cw.checkpoint_id = checkpoints.checkpoint_id
) as pending_writes
from checkpoints """
SELECT_PENDING_SENDS_SQL = f"""
select
checkpoint_id,
array_agg(array[type::bytea, blob] order by task_path, task_id, idx) as sends
from checkpoint_writes
where thread_id = %s
and checkpoint_id = any(%s)
and channel = '{TASKS}'
group by checkpoint_id
"""
UPSERT_CHECKPOINT_BLOBS_SQL = """
INSERT INTO checkpoint_blobs (thread_id, checkpoint_ns, channel, version, type, blob)
VALUES (%s, %s, %s, %s, %s, %s)
ON CONFLICT (thread_id, checkpoint_ns, channel, version) DO NOTHING
"""
UPSERT_CHECKPOINTS_SQL = """
INSERT INTO checkpoints (thread_id, checkpoint_ns, checkpoint_id, parent_checkpoint_id, checkpoint, metadata)
VALUES (%s, %s, %s, %s, %s, %s)
ON CONFLICT (thread_id, checkpoint_ns, checkpoint_id)
DO UPDATE SET
checkpoint = EXCLUDED.checkpoint,
metadata = EXCLUDED.metadata;
"""
UPSERT_CHECKPOINT_WRITES_SQL = """
INSERT INTO checkpoint_writes (thread_id, checkpoint_ns, checkpoint_id, task_id, task_path, idx, channel, type, blob)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
ON CONFLICT (thread_id, checkpoint_ns, checkpoint_id, task_id, idx) DO UPDATE SET
channel = EXCLUDED.channel,
type = EXCLUDED.type,
blob = EXCLUDED.blob;
"""
INSERT_CHECKPOINT_WRITES_SQL = """
INSERT INTO checkpoint_writes (thread_id, checkpoint_ns, checkpoint_id, task_id, task_path, idx, channel, type, blob)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
ON CONFLICT (thread_id, checkpoint_ns, checkpoint_id, task_id, idx) DO NOTHING
"""
class _DeltaStage2Row(TypedDict, total=False):
"""One row from `_build_delta_stage2_sql` (a UNION ALL of writes and blobs)."""
_kind: str # "w" or "b"
checkpoint_id: str | None # "w" rows only
channel: str | None # set on both "w" and "b" rows
type: str | None
blob: bytes | None
task_id: str | None # "w" rows only
idx: int | None # "w" rows only
version: str | None # "b" rows only
# Multi-channel two-stage DeltaChannel reconstruction.
#
# Stage 1 scans checkpoint metadata (no blob bytes) and emits one row per
# checkpoint with K parallel JSONB key lookups (one column pair per
# requested delta channel: ver_i / hs_i). No subqueries, no aggregation.
# Python walks the parent chain once across all channels.
#
# Stage 2 fetches all writes and the seed blobs for ALL channels in a
# single roundtrip via `channel = ANY(%s)` and chain/seed-version
# filtering.
#
# Empirical comparison vs an alternative "ship full channel_versions /
# channel_values JSONB and let Python pick" form (1000 checkpoints,
# 8 total channels in graph, 3 delta channels requested):
#
# Postgres execution: A=0.24ms vs B=0.38ms (both negligible)
# End-to-end latency: A=6.83ms vs B=2.28ms (B is 3.0x faster)
# Wire payload: A=836KB vs B=330KB (61% smaller)
# Buffer hits: identical (167 blocks)
#
# B (this dynamic-columns design) wins because it avoids JSONB
# serialization on the wire and JSONB-to-dict deserialization in
# psycopg. Even at K=8 (8 delta channels = 16 dynamic columns), B
# still beats A end-to-end (4.2ms vs 6.8ms).
def _build_delta_stage1_sql(channels: Sequence[str], *, paged: bool) -> str:
"""Build stage 1 SQL with K parallel version lookups + seed probes.
For channels=["messages", "files"] (with `paged=True`) the result is::
SELECT checkpoint_id, parent_checkpoint_id,
checkpoint -> 'channel_versions' ->> %s AS ver_0,
EXISTS (SELECT 1 FROM checkpoint_blobs b0
WHERE b0.thread_id = checkpoints.thread_id
AND b0.checkpoint_ns = checkpoints.checkpoint_ns
AND b0.channel = %s
AND b0.version = checkpoint -> 'channel_versions' ->> %s
AND b0.type <> 'empty') AS hb_0,
checkpoint -> 'channel_values' -> %s AS inline_0,
checkpoint -> 'channel_versions' ->> %s AS ver_1,
EXISTS (...) AS hb_1,
checkpoint -> 'channel_values' -> %s AS inline_1
FROM checkpoints
WHERE thread_id = %s AND checkpoint_ns = %s
AND (%s::text IS NULL OR checkpoint_id < %s)
ORDER BY checkpoint_id DESC
LIMIT %s
A stored value for a channel lives in one of two places, because `put`
splits them:
* **blob** — non-primitive values (and `_DeltaSnapshot`) are moved to
`checkpoint_blobs`. `hb_i` ("has blob") probes for one. The probe hits
that table's primary key `(thread_id, checkpoint_ns, channel, version)`
exactly, so it is an index lookup per row per channel.
* **inline** — `None`, `str`, `int`, `float` and `bool` stay in the
checkpoint's own `channel_values` and get no blob row at all. `inline_i`
returns that value.
Testing only for a key in `channel_values` (the previous approach) missed
blob-stored plain values, since `put` leaves an inline marker there for
`_DeltaSnapshot` but not for a plain value — which is what a thread
migrated from a pre-delta channel type leaves behind. Probing only the
blobs table would conversely miss inline primitives. Both are needed, and
the caller treats "either present" as the seed.
`hb_i` also disambiguates the two: for a `_DeltaSnapshot`, `inline_i` is the
literal `true` marker rather than the value, so a blob must win over an
inline reading whenever one exists. That ordering is what makes a genuine
inline `true` (a bool channel) distinguishable from the marker.
The `type <> 'empty'` predicate mirrors the check stage 2 already applies
when resolving the seed blob. `put` does not currently produce `empty` rows
on this path — `blob_versions` is filtered to keys present in
`channel_values`, so `_dump_blobs`' empty branch is unreachable from it —
but without the predicate the two stages could disagree: stage 1 would
terminate the walk on a row stage 2 then discards, yielding no seed *and* a
truncated write chain, which is the failure this function exists to avoid.
Channel names are passed as `%s` parameters (safe from SQL injection).
Only the column aliases `ver_i` / `hb_i` / `inline_i` and the subquery alias
`b{i}` are interpolated into the SQL string (i is bounded by len(channels)
and uses safe identifiers).
Caller must extend params with `[ch_0 x4, ch_1 x4, ..., thread_id, ns,
cursor, cursor, page_size]` when `paged=True` — four per channel: the
version lookup, the blob's channel, the version the blob must match, and the
inline lookup.
When `paged=False`, the WHERE has no cursor predicate and there's no
LIMIT/ORDER BY — kept as a non-public helper for tests/diagnostics.
"""
cols = []
for i in range(len(channels)):
cols.append(
f"checkpoint -> 'channel_versions' ->> %s AS ver_{i}, "
f"EXISTS (SELECT 1 FROM checkpoint_blobs b{i} "
f"WHERE b{i}.thread_id = checkpoints.thread_id "
f"AND b{i}.checkpoint_ns = checkpoints.checkpoint_ns "
f"AND b{i}.channel = %s "
f"AND b{i}.version = checkpoint -> 'channel_versions' ->> %s "
f"AND b{i}.type <> 'empty') AS hb_{i}, "
f"checkpoint -> 'channel_values' -> %s AS inline_{i}"
)
sql = (
"SELECT checkpoint_id, parent_checkpoint_id, "
+ ", ".join(cols)
+ " FROM checkpoints WHERE thread_id = %s AND checkpoint_ns = %s"
)
if paged:
sql += (
" AND (%s::text IS NULL OR checkpoint_id < %s)"
" ORDER BY checkpoint_id DESC LIMIT %s"
)
return sql
def _build_delta_stage2_sql(
*,
channels_with_chain: Sequence[str],
channels_with_seed: Sequence[str],
) -> str:
"""Build stage 2 SQL as a per-channel UNION ALL.
For each channel with a non-empty chain, emit one branch reading
`checkpoint_writes` for that specific channel + chain_cids. For each
channel with a seed_version, emit one branch reading `checkpoint_blobs`
for that channel + version. This avoids the over-fetch of the prior
`channel = ANY(channels) AND checkpoint_id = ANY(union)` form when
channels have different chain depths.
The caller must pass parameters in matching order:
for ch in channels_with_chain:
params += [thread_id, checkpoint_ns, ch, chain_cids[ch]]
for ch in channels_with_seed:
params += [thread_id, checkpoint_ns, ch, seed_version[ch]]
Returns an empty SQL string if both channel lists are empty (caller
must skip executing in that case).
"""
branches: list[str] = []
for _ in channels_with_chain:
branches.append(
"SELECT 'w'::text AS _kind, "
"checkpoint_id, channel, "
"type, blob, task_id, idx, NULL::text AS version "
"FROM checkpoint_writes "
"WHERE thread_id = %s AND checkpoint_ns = %s AND channel = %s "
"AND checkpoint_id = ANY(%s)"
)
for _ in channels_with_seed:
branches.append(
"SELECT 'b'::text AS _kind, NULL::text AS checkpoint_id, channel, "
"type, blob, NULL::text AS task_id, NULL::int AS idx, version "
"FROM checkpoint_blobs "
"WHERE thread_id = %s AND checkpoint_ns = %s AND channel = %s "
"AND version = %s"
)
return " UNION ALL ".join(branches)
# Stage 1 rows are dynamic-shape dicts: {checkpoint_id, parent_checkpoint_id,
# ver_0, hs_0, ver_1, hs_1, ...}. Walking is parameterized by the channel
# list to map indices back to channel names — no static TypedDict here.
# `dict[str, Any]` is the practical signature.
class BasePostgresSaver(BaseCheckpointSaver[str]):
SELECT_SQL = SELECT_SQL
SELECT_PENDING_SENDS_SQL = SELECT_PENDING_SENDS_SQL
MIGRATIONS = MIGRATIONS
UPSERT_CHECKPOINT_BLOBS_SQL = UPSERT_CHECKPOINT_BLOBS_SQL
UPSERT_CHECKPOINTS_SQL = UPSERT_CHECKPOINTS_SQL
UPSERT_CHECKPOINT_WRITES_SQL = UPSERT_CHECKPOINT_WRITES_SQL
INSERT_CHECKPOINT_WRITES_SQL = INSERT_CHECKPOINT_WRITES_SQL
supports_pipeline: bool
def _migrate_pending_sends(
self,
pending_sends: list[tuple[bytes, bytes]],
checkpoint: dict[str, Any],
channel_values: list[tuple[bytes, bytes, bytes]],
) -> None:
if not pending_sends:
return
# add to values
enc, blob = self.serde.dumps_typed(
[self.serde.loads_typed((c.decode(), b)) for c, b in pending_sends],
)
channel_values.append((TASKS.encode(), enc.encode(), blob))
# add to versions
checkpoint["channel_versions"][TASKS] = (
max(checkpoint["channel_versions"].values())
if checkpoint["channel_versions"]
else self.get_next_version(None, None)
)
def _load_blobs(
self, blob_values: list[tuple[bytes, bytes, bytes]]
) -> dict[str, Any]:
if not blob_values:
return {}
return {
k.decode(): self.serde.loads_typed((t.decode(), v))
for k, t, v in blob_values
if t.decode() != "empty"
}
@staticmethod
def _ingest_stage1_page(
stage1_rows: Sequence[Mapping[str, Any]],
channels: Sequence[str],
parent_of: dict[str, str | None],
ver_by_i_by_cid: list[dict[str, str | None]],
hb_by_i_by_cid: list[dict[str, bool]],
inline_by_i_by_cid: list[dict[str, Any]],
) -> str | None:
"""Fold one stage-1 page into the running walk-state mappings.
Returns the oldest checkpoint_id seen on this page (smallest, since
pages come back DESC). Caller uses it as the cursor for the next
page (`AND checkpoint_id < cursor`).
"""
oldest: str | None = None
for r in stage1_rows:
cid = cast(str, r["checkpoint_id"])
parent_of[cid] = cast("str | None", r["parent_checkpoint_id"])
for i in range(len(channels)):
ver_by_i_by_cid[i][cid] = cast("str | None", r.get(f"ver_{i}"))
hb_by_i_by_cid[i][cid] = bool(r.get(f"hb_{i}"))
inline_by_i_by_cid[i][cid] = r.get(f"inline_{i}")
# Rows are DESC; the last one is the smallest cid in the page.
oldest = cid
return oldest
@staticmethod
def _try_advance_walks(
target_id: str,
channels: Sequence[str],
parent_of: Mapping[str, str | None],
ver_by_i_by_cid: Sequence[Mapping[str, str | None]],
hb_by_i_by_cid: Sequence[Mapping[str, bool]],
inline_by_i_by_cid: Sequence[Mapping[str, Any]],
chain_by_ch: dict[str, list[str]],
seed_ver_by_ch: dict[str, str | None],
seed_inline_by_ch: dict[str, Any],
walk_cursor_by_ch: dict[str, str | None],
seeded: set[str],
) -> None:
"""Advance each not-yet-seeded channel's walk as far as possible.
Uses the partial `parent_of` map accumulated so far. A walk stops
either because:
(a) it found a stored value for its channel — a blob or an inline
primitive (channel becomes seeded),
(b) it reached a real root (parent_of[cid] is None — fully
materialized at this point), or
(c) the next ancestor cid isn't in `parent_of` yet (waiting for
a later page; the cursor stays put).
Mutates `chain_by_ch`, `seed_ver_by_ch`, `seed_inline_by_ch`,
`walk_cursor_by_ch`, and `seeded` in place.
"""
for i, ch in enumerate(channels):
if ch in seeded:
continue
# First-time entry: cursor starts at the target's parent.
if ch not in walk_cursor_by_ch:
walk_cursor_by_ch[ch] = parent_of.get(target_id)
cur_cid = walk_cursor_by_ch[ch]
ch_chain = chain_by_ch[ch]
hb_i = hb_by_i_by_cid[i]
inline_i = inline_by_i_by_cid[i]
ver_i = ver_by_i_by_cid[i]
while cur_cid is not None:
if cur_cid not in parent_of:
# Need more pages to continue this walk.
break
ch_chain.append(cur_cid)
has_blob = hb_i.get(cur_cid, False)
inline = inline_i.get(cur_cid)
if has_blob or inline is not None:
# A blob wins: for a `_DeltaSnapshot` the inline reading is
# the `true` marker, not the value.
seed_ver_by_ch[ch] = ver_i.get(cur_cid)
if not has_blob:
seed_inline_by_ch[ch] = inline
seeded.add(ch)
cur_cid = None
break
cur_cid = parent_of[cur_cid]
walk_cursor_by_ch[ch] = cur_cid
def _build_delta_channels_writes_history(
self,
*,
channels: Sequence[str],
chain_by_ch: Mapping[str, list[str]],
seed_ver_by_ch: Mapping[str, str | None],
seed_inline_by_ch: Mapping[str, Any],
stage2_rows: Sequence[_DeltaStage2Row],
) -> dict[str, DeltaChannelHistory]:
"""Demux stage 2 rows per channel; produce per-channel histories.
stage2_rows carry `channel` on every row. We build per-channel
`writes_by_cid` and per-channel `seed_blob` dicts, then assemble
a `DeltaChannelHistory` per requested channel.
A seed comes from the blobs table when the walk found one there, and
otherwise from `seed_inline_by_ch` — `put` keeps `None`, `str`, `int`,
`float` and `bool` values in the checkpoint's own `channel_values` with
no blob row, so those never appear in `stage2_rows`.
The `seed` key is omitted when the walk reached root without finding a
stored value, or when the seed blob is sentinel "empty" — in both cases
the consumer treats absence as "start empty".
"""
# writes_by_ch_by_cid[channel][cid] = list of (type, blob, task_id, idx)
writes_by_ch_by_cid: dict[str, dict[str, list[tuple[str, bytes, str, int]]]] = {
ch: {} for ch in channels
}
# seed_blob_by_ver[(channel, version)] = (type, blob)
seed_blob_by_ver: dict[tuple[str, str], tuple[str, bytes]] = {}
for r in stage2_rows:
ch = cast(str, r["channel"])
kind = r["_kind"]
if kind == "w":
cid = cast(str, r["checkpoint_id"])
writes_by_ch_by_cid.setdefault(ch, {}).setdefault(cid, []).append(
cast(
"tuple[str, bytes, str, int]",
(r["type"], r["blob"], r["task_id"], r["idx"]),
)
)
else: # kind == "b"
ver = cast(str, r["version"])
seed_blob_by_ver[(ch, ver)] = cast(
"tuple[str, bytes]", (r["type"], r["blob"])
)
# Sort writes per (channel, cid) newest-first by (task_id, idx)
for cid_map in writes_by_ch_by_cid.values():
for ws in cid_map.values():
ws.sort(key=lambda w: (w[2], w[3]), reverse=True)
result: dict[str, DeltaChannelHistory] = {}
for ch in channels:
chain_cids = chain_by_ch.get(ch, [])
seed_version = seed_ver_by_ch.get(ch)
collected: list[PendingWrite] = []
cid_writes = writes_by_ch_by_cid.get(ch, {})
for cid in chain_cids:
for type_tag, write_blob, task_id, _idx in cid_writes.get(cid, []):
val = self.serde.loads_typed((type_tag, write_blob))
collected.append((task_id, ch, val))
collected.reverse()
entry: DeltaChannelHistory = {"writes": collected}
if seed_version is not None:
blob = seed_blob_by_ver.get((ch, seed_version))
if blob is not None and blob[0] != "empty":
entry["seed"] = self.serde.loads_typed(blob)
elif ch in seed_inline_by_ch:
# Inline primitive: stored in the checkpoint, not the blobs
# table, so stage 2 never returned a row for it.
entry["seed"] = seed_inline_by_ch[ch]
result[ch] = entry
return result
def _dump_blobs(
self,
thread_id: str,
checkpoint_ns: str,
values: dict[str, Any],
versions: ChannelVersions,
) -> list[tuple[str, str, str, str, str, bytes | None]]:
if not versions:
return []
return [
(
thread_id,
checkpoint_ns,
k,
cast(str, ver),
*(
self.serde.dumps_typed(values[k])
if k in values
else ("empty", None)
),
)
for k, ver in versions.items()
]
def _load_writes(
self, writes: list[tuple[bytes, bytes, bytes, bytes]]
) -> list[tuple[str, str, Any]]:
return (
[
(
tid.decode(),
channel.decode(),
self.serde.loads_typed((t.decode(), v)),
)
for tid, channel, t, v in writes
]
if writes
else []
)
def _dump_writes(
self,
thread_id: str,
checkpoint_ns: str,
checkpoint_id: str,
task_id: str,
task_path: str,
writes: Sequence[tuple[str, Any]],
) -> list[tuple[str, str, str, str, str, int, str, str, bytes]]:
return [
(
thread_id,
checkpoint_ns,
checkpoint_id,
task_id,
task_path,
WRITES_IDX_MAP.get(channel, idx),
channel,
*self.serde.dumps_typed(value),
)
for idx, (channel, value) in enumerate(writes)
]
def get_next_version(self, current: str | None, channel: None) -> str:
if current is None:
current_v = 0
elif isinstance(current, int):
current_v = current
else:
current_v = int(current.split(".")[0])
next_v = current_v + 1
next_h = random.random()
return f"{next_v:032}.{next_h:016}"
def _search_where(
self,
config: RunnableConfig | None,
filter: MetadataInput,
before: RunnableConfig | None = None,
) -> tuple[str, list[Any]]:
"""Return WHERE clause predicates for alist() given config, filter, before.
This method returns a tuple of a string and a tuple of values. The string
is the parametered WHERE clause predicate (including the WHERE keyword):
"WHERE column1 = $1 AND column2 IS $2". The list of values contains the
values for each of the corresponding parameters.
"""
wheres = []
param_values = []
# construct predicate for config filter
if config:
wheres.append("thread_id = %s ")
param_values.append(config["configurable"]["thread_id"])
checkpoint_ns = config["configurable"].get("checkpoint_ns")
if checkpoint_ns is not None:
wheres.append("checkpoint_ns = %s")
param_values.append(checkpoint_ns)
if checkpoint_id := get_checkpoint_id(config):
wheres.append("checkpoint_id = %s ")
param_values.append(checkpoint_id)
# construct predicate for metadata filter
if filter:
wheres.append("metadata @> %s ")
param_values.append(Jsonb(filter))
# construct predicate for `before`
if before is not None:
wheres.append("checkpoint_id < %s ")
param_values.append(get_checkpoint_id(before))
return (
"WHERE " + " AND ".join(wheres) if wheres else "",
param_values,
)