Description
BasePostgresSaver._try_advance_walks (and its async twin in aio.py) permanently poisons a channel's walk cursor to None when the target checkpoint hasn't been loaded yet by the current pagination page, causing get_delta_channel_history to silently return an empty history ({"writes": []}, no "seed") for DeltaChannels on checkpoints that aren't within the first _DELTA_PAGE_SIZE (1024) rows of the thread.
Where
libs/checkpoint-postgres/langgraph/checkpoint/postgres/base.py, _try_advance_walks (~line 392-394):
# 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)
get_delta_channel_history (libs/checkpoint-postgres/langgraph/checkpoint/postgres/__init__.py, ~line 476-522) pages the checkpoints table newest-first in chunks of _DELTA_PAGE_SIZE = 1024, starting from cursor=None, and calls _try_advance_walks(checkpoint_id, ...) after each page, where checkpoint_id is the target checkpoint being hydrated (which can be any checkpoint in the thread's history, not just the latest — e.g. via get_state({"configurable": {"checkpoint_id": X}}), update_state, or get_state_history).
Why it's wrong
walk_cursor_by_ch[ch] is initialized exactly once, guarded by if ch not in walk_cursor_by_ch. On the first page, it's seeded from parent_of.get(target_id) — but parent_of only contains rows fetched so far. If target_id is older than the newest 1024 checkpoints in the thread, it isn't in parent_of yet, so .get(target_id) returns None, and the code stores walk_cursor_by_ch[ch] = None as if the target had no parent (i.e. as if it were the root).
On every subsequent page, the guard ch not in walk_cursor_by_ch is now False (the key already exists, mapped to None), so the cursor is never re-derived even after target_id's real row — and its real parent — is loaded on a later page. The walk for that channel is stuck permanently at cur_cid = None; chain_by_ch[ch] stays [] and the channel is never added to seeded. Pagination stops once it reaches the true end of the thread's checkpoints, and the method returns with no "seed" and empty "writes" for that channel — no exception, no warning.
Downstream, channels_from_checkpoint (libs/langgraph/langgraph/pregel/_checkpoint.py) does:
history = histories[k]
replay_ch = delta_spec.from_checkpoint(history.get("seed", MISSING))
replay_ch.replay_writes(history["writes"])
With seed absent and writes empty, the channel silently hydrates as empty — e.g. a messages DeltaChannel reconstructs to [] even though the real thread has hundreds of real messages.
Repro scenario
- Use
PostgresSaver/AsyncPostgresSaver with a graph whose reducer uses a DeltaChannel (e.g. messages), on a thread long enough to have accumulated more than 1024 checkpoints after some earlier checkpoint X (common in long-running agent loops / supervisor patterns).
- Call anything that hydrates channels from that older checkpoint —
graph.get_state({"configurable": {"thread_id": t, "checkpoint_id": X}}), get_state_history, or update_state targeting X.
- Because
X isn't within the first paginated 1024-row window, its DeltaChannel value (e.g. messages) comes back empty instead of the real accumulated history — silent, incorrect state with no error raised.
This class of bug is avoided in the sqlite counterpart (_delta.py's step_walk_with_row), which streams starting from target_id itself (checkpoint_id <= ?) and only marks a channel "started" once the target row is actually observed, rather than overloading None to mean both "target not loaded yet" and "target has no parent". The postgres fast path should adopt the same distinction, e.g.:
if ch not in walk_cursor_by_ch:
if target_id not in parent_of:
continue # target row not loaded yet; retry on next page
walk_cursor_by_ch[ch] = parent_of.get(target_id)
Notes
Description
BasePostgresSaver._try_advance_walks(and its async twin inaio.py) permanently poisons a channel's walk cursor toNonewhen the target checkpoint hasn't been loaded yet by the current pagination page, causingget_delta_channel_historyto silently return an empty history ({"writes": []}, no"seed") forDeltaChannels on checkpoints that aren't within the first_DELTA_PAGE_SIZE(1024) rows of the thread.Where
libs/checkpoint-postgres/langgraph/checkpoint/postgres/base.py,_try_advance_walks(~line 392-394):get_delta_channel_history(libs/checkpoint-postgres/langgraph/checkpoint/postgres/__init__.py, ~line 476-522) pages thecheckpointstable newest-first in chunks of_DELTA_PAGE_SIZE = 1024, starting fromcursor=None, and calls_try_advance_walks(checkpoint_id, ...)after each page, wherecheckpoint_idis the target checkpoint being hydrated (which can be any checkpoint in the thread's history, not just the latest — e.g. viaget_state({"configurable": {"checkpoint_id": X}}),update_state, orget_state_history).Why it's wrong
walk_cursor_by_ch[ch]is initialized exactly once, guarded byif ch not in walk_cursor_by_ch. On the first page, it's seeded fromparent_of.get(target_id)— butparent_ofonly contains rows fetched so far. Iftarget_idis older than the newest 1024 checkpoints in the thread, it isn't inparent_ofyet, so.get(target_id)returnsNone, and the code storeswalk_cursor_by_ch[ch] = Noneas if the target had no parent (i.e. as if it were the root).On every subsequent page, the guard
ch not in walk_cursor_by_chis nowFalse(the key already exists, mapped toNone), so the cursor is never re-derived even aftertarget_id's real row — and its real parent — is loaded on a later page. The walk for that channel is stuck permanently atcur_cid = None;chain_by_ch[ch]stays[]and the channel is never added toseeded. Pagination stops once it reaches the true end of the thread's checkpoints, and the method returns with no"seed"and empty"writes"for that channel — no exception, no warning.Downstream,
channels_from_checkpoint(libs/langgraph/langgraph/pregel/_checkpoint.py) does:With
seedabsent andwritesempty, the channel silently hydrates as empty — e.g. amessagesDeltaChannelreconstructs to[]even though the real thread has hundreds of real messages.Repro scenario
PostgresSaver/AsyncPostgresSaverwith a graph whose reducer uses aDeltaChannel(e.g.messages), on a thread long enough to have accumulated more than 1024 checkpoints after some earlier checkpointX(common in long-running agent loops / supervisor patterns).graph.get_state({"configurable": {"thread_id": t, "checkpoint_id": X}}),get_state_history, orupdate_statetargetingX.Xisn't within the first paginated 1024-row window, itsDeltaChannelvalue (e.g.messages) comes back empty instead of the real accumulated history — silent, incorrect state with no error raised.This class of bug is avoided in the sqlite counterpart (
_delta.py'sstep_walk_with_row), which streams starting fromtarget_iditself (checkpoint_id <= ?) and only marks a channel "started" once the target row is actually observed, rather than overloadingNoneto mean both "target not loaded yet" and "target has no parent". The postgres fast path should adopt the same distinction, e.g.:Notes
main(libs/checkpoint-postgres/langgraph/checkpoint/postgres/base.py/__init__.py/aio.py).libs/langgraph/tests/test_delta_channel_migration.pyhas no test coverage that exercises pagination across multiple stage-1 pages, so this gap wouldn't be caught by the existing test suite.DeltaChannelissues (DeltaChannel replay order diverges from live execution order for parallel-superstep writes, corrupting continued-thread state #8382 replay order, InMemorySaver silently and permanently drops the first write after migrating a channel to DeltaChannel #8384 InMemorySaver first-write drop, DeltaChannel: forking a thread replays the abandoned branch's writes into the fork #8443 fork replay) — this one is specific to the Postgres paginated fast-path cursor logic.