- Status: Accepted
- Date: 2026-09
- Related: #1264 (source-chat SSE keepalive + server-side cancellation), ADR-004 (worker-isolated long-running work)
To cancel source-chat generation when a client disconnects (#1264), the graph had to run on langgraph's async path (ainvoke/aupdate_state), which requires an async checkpointer. The existing checkpointer is SqliteSaver, which is sync-only: its async methods raise NotImplementedError. Switching to a genuinely async SQLite checkpointer would add a dependency and a second persistence format to reason about, while a second kind of checkpointer for one graph invites drift between the chat and source-chat graphs.
Wrap the existing sync SqliteSaver in a thin HybridSqliteSaver that delegates each async method to the corresponding sync method on a worker thread (asyncio.to_thread), and keep the module-level sqlite3.connect(..., check_same_thread=False) connection and all sync get_state callers unchanged.
Concurrency safety comes from SqliteSaver itself, not from this bridge: it holds an internal threading.Lock and funnels every read/write through a cursor() context manager that acquires that lock. So even though the to_thread delegates can run on different worker threads at once, only one thread ever touches the single shared SQLite connection at a time — the serialization invariant the async path needs.
The async/event-loop and thread boundaries stay clean: the model node runs as a cancellable asyncio task on the loop; only the checkpoint I/O crosses to a worker thread, and the per-session asyncio.Lock in the router serializes the snapshot → append → invoke sequence per conversation.
- Dedicated async checkpointer (
AsyncSqliteSaver/aio-sqlite) — rejected: adds a dependency, a second checkpoint format, and a second persistence path to test, for no benefit over the sync saver since the DB work is already lock-serialized and short. - Run the whole graph synchronously in a thread — the pre-existing approach — rejected: it made the model call uncancellable, which is exactly the gap #1264's follow-up needed to close.
- A single global
asyncio.Lockaround all checkpointer calls — rejected as redundant:SqliteSaveralready serializes access internally; a second lock in front would add contention without adding correctness. The per-session lock in the router is a different concern (read-modify-write atomicity acrossget_state+aupdate_state+ainvoke), and it lives there rather than in the saver.
- One checkpointer type and one SQLite file serve both the chat and source-chat graphs; the async path is a small delegation layer, not a second persistence implementation.
- Any future checkpointer API surface must keep the
aget/aget_tuple/aput/aput_writes/alistdelegation in sync withSqliteSaver's sync surface; if langgraph adds new async methods, the bridge raisesNotImplementedErroruntil extended. - Correctness depends on
SqliteSaver's internalthreading.Lockcontinuing to guard every connection access. If that upstream guarantee ever changed, this bridge would need its own lock or a real async saver.