Skip to content

PostgresSaver: get_delta_channel_history permanently poisons walk cursor when target checkpoint isn't in the first pagination page, silently dropping DeltaChannel history #8448

Description

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

  1. 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).
  2. 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.
  3. 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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions