SQLite mixed-batch retries retain stale special-channel writes
SQLite mixed-batch retries retain stale error writes
Description
On a retry with the same checkpoint and task ID, SQLite preserves the previous __error__ value if the write batch also contains an ordinary channel. The identical retry updates __error__ in InMemorySaver, and SQLite updates it when the batch contains only special channels. Both synchronous and asynchronous SQLite savers show this discrepancy.
This is reproduced directly through the checkpoint API on unmodified main 81bf17b23123e4ef8b9d5f49fa09a0122fc2edd1 and the latest published langgraph-checkpoint-sqlite==3.1.1. This report does not establish whether or how often the default graph runtime emits these mixed retry batches.
Reproduction Steps / Example Code (Python)
Install the published package:
python -m pip install langgraph-checkpoint-sqlite==3.1.1Then run:
import sqlite3
from langgraph.checkpoint.base import empty_checkpoint
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.checkpoint.sqlite import SqliteSaver
def retry(saver, mixed):
config = saver.put(
{"configurable": {"thread_id": "repro", "checkpoint_ns": ""}},
empty_checkpoint(), {}, {},
)
for value in ["old", "new"]:
writes = [("__error__", value)]
if mixed:
writes.append(("ordinary", value))
saver.put_writes(config, writes, "same-task")
return {channel: value for _, channel, value
in saver.get_tuple(config).pending_writes}
for mixed in [False, True]:
print("memory", mixed, retry(InMemorySaver(), mixed))
with sqlite3.connect(":memory:") as conn:
print("sqlite", mixed, retry(SqliteSaver(conn), mixed))Observed output
memory False {'__error__': 'new'}
sqlite False {'__error__': 'new'}
memory True {'__error__': 'new', 'ordinary': 'old'}
sqlite True {'__error__': 'old', 'ordinary': 'old'}The saver raises no exception, but the final line returns the previous error instead of the retry’s updated value. The same two cases run through AsyncSqliteSaver.aput_writes produce the same outcomes as synchronous SQLite. The ordinary-channel value remains old in both backends, as expected for an idempotent retry.
Expected behavior and proposed approach
Update the special-channel value regardless of whether ordinary writes are in the same batch, while preserving ordinary-write idempotency. This expectation follows the observed special-only SQLite behavior and the per-write handling in InMemorySaver.
Both SqliteSaver.put_writes and AsyncSqliteSaver.aput_writes choose INSERT OR REPLACE only when every write is special; otherwise every write uses INSERT OR IGNORE. Adding an ordinary write therefore prevents the error update.
Would a SQLite-only fix that selects conflict behavior per write, with matching sync/async regression tests, be acceptable? If so, please approve the approach and assign the issue before I open a PR.
Related Issues / PRs
The closest result found was #7237, which adds restart-idempotency conformance tests. It does not change the SQLite write selection or cover this mixed-batch overwrite case. I did not find an exact competing fix in the searches performed.
System Info
- macOS 15.7.3, ARM64; Python 3.12.13; SQLite 3.50.4.
- Current-main environment: langgraph-checkpoint 4.2.0; langgraph-checkpoint-sqlite 3.1.1; langchain-core 1.6.2; aiosqlite 0.22.1.
- Also reproduced with the published SQLite 3.1.1 package in a separate isolated environment.
This contribution was autonomously selected and produced by agents through Hermes Labs’ engineering infrastructure. Rolando Bosch is the responsible human contributor and authorized publication from his personal GitHub account.
Source: langchain-ai/langgraph