Skip to content

checkpoint: preserve recency semantics for checkpoint-only before cursors#7105

Open
David Ahmann (davidahmann) wants to merge 3 commits intolangchain-ai:mainfrom
davidahmann:codex/issue-6922-memory-recency
Open

checkpoint: preserve recency semantics for checkpoint-only before cursors#7105
David Ahmann (davidahmann) wants to merge 3 commits intolangchain-ai:mainfrom
davidahmann:codex/issue-6922-memory-recency

Conversation

@davidahmann
Copy link

@davidahmann David Ahmann (davidahmann) commented Mar 10, 2026

Problem

Checkpoint pagination/lookup could drift from recency semantics when before cursors are checkpoint-only and non-lexicographic IDs are used. Additionally, checkpoint-only before bounds from a different namespace in the same thread were not consistently honored across memory/sqlite/postgres paths.

Why now

This is a contract consistency issue for checkpoint history traversal (get_tuple, list, and before handling) and affects replay-style callers that pass checkpoint-only bounds.

What changed

  • Preserved timestamp-first recency semantics for non-lexicographic IDs across memory/sqlite/postgres saver paths.
  • Added cross-namespace fallback for checkpoint-only before resolution in memory saver.
  • Updated sqlite/postgres before predicates to resolve checkpoint-only bounds by thread + checkpoint ID (not only current namespace), including safe fallback when bound checkpoint is absent.
  • Added/updated regression coverage in memory/sqlite/aiosqlite/postgres tests for checkpoint-only cursors and cross-namespace bound behavior.

Validation

  • cd libs/checkpoint && TEST=tests/test_memory.py make test
  • cd libs/checkpoint-sqlite && TEST='tests/test_sqlite.py tests/test_aiosqlite.py' make test
  • cd libs/checkpoint-postgres && uv run pytest tests/test_search_where.py
  • cd libs/checkpoint && make format && make lint
  • cd libs/checkpoint-sqlite && make format && make lint
  • cd libs/checkpoint-postgres && make format && make lint
  • cd libs/checkpoint-postgres && TEST=tests/test_search_where.py make test (blocked locally: Docker daemon unavailable for the Postgres test harness)

Refs #6922

@davidahmann
Copy link
Author

User impact: This keeps latest-checkpoint lookup and cursor pagination deterministic even when checkpoint IDs are not lexicographically ordered.
Change: The fix switches memory and SQLite recency selection away from raw ID ordering, updates Postgres recency predicates, and adds regression coverage for non-lexicographic IDs.
Validation:

  • uv run pytest tests/test_memory.py -k 'latest_uses_checkpoint_timestamp_not_id or list_before_uses_checkpoint_timestamp_not_id' passed
  • uv run pytest tests/test_sqlite.py -k 'latest_checkpoint_uses_timestamp_not_id or test_search_where' passed
  • uv run pytest tests/test_aiosqlite.py -k latest_checkpoint_uses_timestamp_not_id passed
    Risk: I syntax-checked the Postgres path locally, but I did not run it against a live Postgres instance here.

Inspired by research context: CAISI publishes independent, reproducible AI agent governance research: https://caisi.dev

Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4acb4c7a56

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "codex (@codex) review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "codex (@codex) address that feedback".

if checkpoint_id is None:
return None

thread_id = config["configurable"]["thread_id"]

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Handle checkpoint-only before cursor in memory saver

InMemorySaver.list() now routes through _before_sort_key(), which unconditionally indexes before["configurable"]["thread_id"]. Existing callers pass a checkpoint-only cursor (for example ReplayCursor.get_checkpoint uses before={"configurable": {"checkpoint_id": ...}} in libs/langgraph/langgraph/_internal/_replay.py), so this change raises KeyError instead of returning prior checkpoints and breaks replay flows that previously worked.

Useful? React with 👍 / 👎.

wheres.append("checkpoint_id < ?")
param_values.append(get_checkpoint_id(before))
if before is not None and (before_checkpoint_id := get_checkpoint_id(before)):
before_thread_id = before["configurable"]["thread_id"]

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Allow SQLite before filters without thread metadata

search_where() now requires before["configurable"]["thread_id"] and checkpoint_ns, but before cursors are commonly provided with only checkpoint_id (including replay calls and existing examples). In that case SqliteSaver.list() / AsyncSqliteSaver.alist() now fail with KeyError instead of applying the cursor filter, which is a backward-incompatible runtime regression.

Useful? React with 👍 / 👎.

wheres.append("checkpoint_id < %s ")
param_values.append(get_checkpoint_id(before))
if before is not None and (before_checkpoint_id := get_checkpoint_id(before)):
before_thread_id = before["configurable"]["thread_id"]

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve Postgres support for checkpoint-only before cursors

BasePostgresSaver._search_where() now assumes before always contains thread_id/checkpoint_ns, but callers can legally pass only checkpoint_id (for example the replay path in libs/langgraph/langgraph/_internal/_replay.py). With this change, list()/alist() raise KeyError for that input instead of returning paged history, causing replay/history queries to crash for Postgres-backed checkpointing.

Useful? React with 👍 / 👎.

@davidahmann
Copy link
Author

User impact: Replay-style callers that pass before={"configurable": {"checkpoint_id": ...}} work again instead of raising KeyError during checkpoint history listing.
Change: This follow-up resolves checkpoint-only before cursors against the active list config in memory, SQLite, and Postgres paths, and adds regressions for the checkpoint-only cursor shape.
Validation:

  • cd libs/checkpoint && TEST='tests/test_memory.py' make test passed
  • cd libs/checkpoint && make format && make lint passed
  • cd libs/checkpoint-sqlite && TEST='tests/test_sqlite.py tests/test_aiosqlite.py' make test passed
  • cd libs/checkpoint-sqlite && make format && make lint passed
  • cd libs/checkpoint-postgres && make format && make lint passed
  • cd libs/checkpoint-postgres && uv run pytest tests/test_search_where.py passed
    Risk: cd libs/checkpoint-postgres && POSTGRES_VERSIONS=16 TEST='tests/test_sync.py -k checkpoint_only_before' make test is still blocked locally because the Docker daemon is unavailable here.

Inspired by research context: CAISI publishes independent, reproducible AI agent governance research: https://caisi.dev

@davidahmann David Ahmann (davidahmann) force-pushed the codex/issue-6922-memory-recency branch from 821d266 to 5712fae Compare March 11, 2026 14:28
@davidahmann David Ahmann (davidahmann) changed the title Preserve checkpoint recency for non-lexicographic IDs checkpoint: preserve recency semantics for checkpoint-only before cursors Mar 11, 2026
@davidahmann
Copy link
Author

This change addresses operator-facing recency drift when checkpoint-only before cursors are used with non-lexicographic checkpoint IDs.
The patch stays minimal to checkpoint saver query/order paths plus regression tests across memory/sqlite/postgres and conformance coverage.
Validation: TEST=tests/test_memory.py make test in libs/checkpoint (pass); TEST='tests/test_sqlite.py tests/test_aiosqlite.py' make test in libs/checkpoint-sqlite (pass); TEST=tests/test_search_where.py make test in libs/checkpoint-postgres (blocked locally because Docker daemon is unavailable).
Blockers/Risks: postgres-targeted validation is environment-blocked locally due missing Docker daemon.
Inspired by research context: CAISI publishes independent, reproducible AI agent governance research: https://caisi.dev

Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 5712faec92

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "codex (@codex) review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "codex (@codex) address that feedback".

Comment on lines +164 to +165
if saved is None:
return None

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve before bound when cursor checkpoint is missing

ReplayState uses before={"configurable": {"checkpoint_id": ...}} as an upper-bound cursor for nested replay (libs/langgraph/langgraph/_internal/_replay.py and _loop.py around the replay-state setup), and that checkpoint ID can belong to the parent namespace rather than the current subgraph namespace. In this path _before_sort_key() returns None when the ID is not found locally, and list() then skips before filtering entirely, which allows memory-backed replay to load checkpoints created after the replay point instead of only historical ones.

Useful? React with 👍 / 👎.

Comment on lines +125 to +129
rowid < (
SELECT rowid
FROM checkpoints
WHERE thread_id = ? AND checkpoint_ns = ? AND checkpoint_id = ?
)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Handle absent before row in SQLite cursor predicate

Nested replay passes a checkpoint-only before cursor as a time bound (libs/langgraph/langgraph/_internal/_replay.py:66-69), and that ID is not guaranteed to exist in the target subgraph namespace (_loop.py replay comments explain this bound behavior). This new predicate requires finding an exact (thread_id, checkpoint_ns, checkpoint_id) row first; when it is absent, the subquery yields NULL and rowid < NULL excludes all rows, so SqliteSaver.list()/AsyncSqliteSaver.alist() can return no checkpoint and incorrectly reinitialize subgraph state.

Useful? React with 👍 / 👎.

Comment on lines +324 to +328
checkpoint->>'ts' < (
SELECT checkpoint->>'ts'
FROM checkpoints
WHERE thread_id = %s AND checkpoint_ns = %s AND checkpoint_id = %s
)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Handle absent before row in Postgres cursor predicate

The replay flow uses before as an upper-bound checkpoint ID that may come from the parent graph rather than the current namespace (libs/langgraph/langgraph/_internal/_replay.py and _loop.py replay setup), but this predicate now depends on a subquery for that exact row in the current (thread_id, checkpoint_ns). If the row does not exist, the subquery returns NULL, both timestamp comparisons evaluate false, and PostgresSaver.list()/AsyncPostgresSaver.alist() return no historical checkpoint, breaking nested replay restoration.

Useful? React with 👍 / 👎.

@davidahmann David Ahmann (davidahmann) force-pushed the codex/issue-6922-memory-recency branch from 5712fae to 167068d Compare March 11, 2026 23:54
@davidahmann
Copy link
Author

This update addresses the unresolved checkpoint-only before cursor edge case when the bound checkpoint exists in a different namespace in the same thread.
The patch is still scoped to checkpoint saver filtering logic and adds regression coverage for memory/sqlite/aiosqlite/postgres query-path behavior under cross-namespace bounds.
Validation: cd libs/checkpoint && TEST=tests/test_memory.py make test (pass), cd libs/checkpoint-sqlite && TEST='tests/test_sqlite.py tests/test_aiosqlite.py' make test (pass), cd libs/checkpoint-postgres && uv run pytest tests/test_search_where.py (pass), make format && make lint in each touched library (pass).
Blockers/Risks: full Postgres Docker-backed matrix remains locally blocked because Docker daemon is unavailable.
Inspired by research context: CAISI publishes independent, reproducible AI agent governance research: https://caisi.dev

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant