-
Notifications
You must be signed in to change notification settings - Fork 4.5k
fix(source-chat): emit SSE keepalives during generation and add stop-… #1332
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
AugustoSandim
wants to merge
19
commits into
lfnovo:main
Choose a base branch
from
AugustoSandim:fix/source-chat-sse-keepalive-cancel
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from 11 commits
Commits
Show all changes
19 commits
Select commit
Hold shift + click to select a range
5f4f592
fix(source-chat): emit SSE keepalives during generation and add stop-…
AugustoSandim 2b47837
fix(source-chat): make streaming cancellable on client disconnect and…
AugustoSandim f5eb59a
Merge branch 'main' into fix/source-chat-sse-keepalive-cancel
AugustoSandim 7314f1a
fix(source-chat): avoid duplicate user messages on retry and clean up…
AugustoSandim cee97ca
fix(source-chat): deduplicate retries by message id and serialize per…
AugustoSandim 852c1c9
fix(source-chat): resolve TypeScript errors in use-source-chat test
AugustoSandim dd19c0a
fix(source-chat): refcount per-session locks and align optimistic mes…
AugustoSandim 9e1d675
fix(source-chat): poll disconnects every second and clean up cancelle…
AugustoSandim 0db8fc0
fix(source-chat): adopt in-flight session on stop, preserve retry bub…
AugustoSandim 182e135
refactor(source-chat): extract turn coordination service and fix fron…
AugustoSandim 2ba4c5c
fix(source-chat): prevent auth token logging and isolate streaming me…
AugustoSandim 8ebd58f
fix(source-chat): adopt session transcript
AugustoSandim 301225d
fix(source-chat): queue turn lock with polling keepalives and harden …
AugustoSandim 56bde41
Update tests/test_chat_routers_characterization.py
AugustoSandim f1be87a
Update frontend/src/lib/hooks/use-source-chat.test.tsx
AugustoSandim fac35bb
Update frontend/src/lib/hooks/use-source-chat.ts
AugustoSandim acd34d3
fix(source-chat): drop duplicated hydration-failure block left by a b…
AugustoSandim c213853
fix(source-chat): simplify redundant session-created branch in pre-se…
AugustoSandim 833ab82
test(source-chat): advance the fake clock monotonically in the keepal…
AugustoSandim File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,133 @@ | ||
| """Turn coordination for source chat. | ||
|
|
||
| Owns the policy a source-chat turn needs before generation can start: the | ||
| per-session lock that serializes the snapshot -> append -> invoke sequence, and | ||
| the decision to persist the pending human turn. The graph is passed in so the | ||
| caller's module attribute stays authoritative and this module is usable | ||
| standalone. | ||
| """ | ||
|
|
||
| import asyncio | ||
| from contextlib import asynccontextmanager | ||
| from typing import Any, AsyncIterator, Optional, Protocol | ||
|
|
||
| from langchain_core.messages import HumanMessage | ||
| from langchain_core.runnables import RunnableConfig | ||
|
|
||
|
|
||
| class CheckpointGraph(Protocol): | ||
| """The slice of a compiled LangGraph a turn needs.""" | ||
|
|
||
| def get_state(self, config: RunnableConfig) -> Any: ... | ||
|
|
||
| async def aupdate_state(self, config: RunnableConfig, values: Any) -> Any: ... | ||
|
|
||
|
|
||
| # Per-session locks serialize the read-modify-write sequence (snapshot -> append | ||
| # user message -> invoke). Without them, two concurrent requests for the same | ||
| # thread could both read the same trailing message and each start a generation. | ||
| # Created lazily; refcounted so the entry is evicted once the last holder | ||
| # releases — a long-lived process must not keep one lock per session it has ever | ||
| # seen. | ||
| class _SessionLock: | ||
| __slots__ = ("lock", "holders") | ||
|
|
||
| def __init__(self) -> None: | ||
| self.lock = asyncio.Lock() | ||
| self.holders = 0 | ||
|
|
||
|
|
||
| _session_locks: dict[str, _SessionLock] = {} | ||
|
|
||
|
|
||
| def _register_holder(session_id: str) -> _SessionLock: | ||
| # No `await` between these dict ops, so on a single event loop the | ||
| # read/create/increment is atomic. Registering the caller as a holder before | ||
| # it awaits `acquire` keeps the entry alive until it releases. | ||
| entry = _session_locks.get(session_id) | ||
| if entry is None: | ||
| entry = _SessionLock() | ||
| _session_locks[session_id] = entry | ||
| entry.holders += 1 | ||
| return entry | ||
|
|
||
|
|
||
| def _drop_holder(session_id: str, entry: _SessionLock) -> None: | ||
| entry.holders -= 1 | ||
| # The `is entry` guard is defensive: the entry is only evicted when this was | ||
| # the last holder, so `session_id` must still map to this same entry. | ||
| if entry.holders == 0 and _session_locks.get(session_id) is entry: | ||
| _session_locks.pop(session_id, None) | ||
|
|
||
|
|
||
| @asynccontextmanager | ||
| async def session_turn_lock(session_id: str) -> AsyncIterator[None]: | ||
| """Hold the session's turn lock for the duration of the block.""" | ||
| entry = _register_holder(session_id) | ||
| acquired = False | ||
| try: | ||
| await entry.lock.acquire() | ||
| acquired = True | ||
| yield | ||
| finally: | ||
| # Cancelled while waiting to acquire: drop the holder registered above | ||
| # without releasing an unheld lock. | ||
| if acquired: | ||
| entry.lock.release() | ||
| _drop_holder(session_id, entry) | ||
|
|
||
|
|
||
| async def persist_pending_human_turn( | ||
| graph: CheckpointGraph, | ||
| config: RunnableConfig, | ||
| message: str, | ||
| message_id: Optional[str] = None, | ||
| ) -> bool: | ||
| """Append the user message to the checkpoint unless it is already pending. | ||
|
|
||
| Persisting up front makes the message survive a mid-generation disconnect | ||
| (the frontend refetches the checkpoint on cancel/complete and would | ||
| otherwise drop it). The guard keys on the client message id, not content: a | ||
| retry that reuses the same id is deduplicated, while two distinct identical | ||
| messages get distinct ids and are both kept. A completed exchange always | ||
| ends with an AI message, so a trailing human turn is necessarily still | ||
| pending. | ||
|
|
||
| Returns whether the message was appended. | ||
| """ | ||
| # SqliteSaver has no async read, so snapshot off the event loop. | ||
| current_state = await asyncio.to_thread(graph.get_state, config=config) | ||
| already_pending = False | ||
| if current_state and current_state.values and "messages" in current_state.values: | ||
| existing_messages = current_state.values["messages"] | ||
| last_message = existing_messages[-1] if existing_messages else None | ||
| already_pending = ( | ||
| isinstance(last_message, HumanMessage) | ||
| and message_id is not None | ||
| and getattr(last_message, "id", None) == message_id | ||
| ) | ||
| if already_pending: | ||
| return False | ||
| await graph.aupdate_state( | ||
| config, {"messages": [HumanMessage(content=message, id=message_id)]} | ||
| ) | ||
| return True | ||
|
|
||
|
|
||
| @asynccontextmanager | ||
| async def source_chat_turn( | ||
| graph: CheckpointGraph, | ||
| session_id: str, | ||
| config: RunnableConfig, | ||
| message: str, | ||
| message_id: Optional[str] = None, | ||
| ) -> AsyncIterator[None]: | ||
| """Serialize and persist one source-chat turn, then run the caller's block. | ||
|
|
||
| The lock is held for the whole block so a concurrent request for the same | ||
| thread cannot snapshot the same trailing message and start a second | ||
| generation. | ||
| """ | ||
| async with session_turn_lock(session_id): | ||
| await persist_pending_human_turn(graph, config, message, message_id) | ||
| yield |
29 changes: 29 additions & 0 deletions
29
docs/7-DEVELOPMENT/decisions/ADR-009-hybrid-sqlite-checkpointer-bridge.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,29 @@ | ||
| # ADR-009: Async checkpointer via a sync SqliteSaver thread bridge | ||
|
|
||
| - **Status**: Accepted | ||
| - **Date**: 2026-09 | ||
| - **Related**: #1264 (source-chat SSE keepalive + server-side cancellation), [ADR-004](ADR-004-background-workers.md) (worker-isolated long-running work) | ||
|
|
||
| ## Context | ||
|
|
||
| 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. | ||
|
|
||
| ## Decision | ||
|
|
||
| **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. | ||
|
|
||
| ## Alternatives considered | ||
|
|
||
| - **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.Lock` around all checkpointer calls** — rejected as redundant: `SqliteSaver` already 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 across `get_state` + `aupdate_state` + `ainvoke`), and it lives there rather than in the saver. | ||
|
|
||
| ## Consequences | ||
|
|
||
| - 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`/`alist` delegation in sync with `SqliteSaver`'s sync surface; if langgraph adds new async methods, the bridge raises `NotImplementedError` until extended. | ||
| - Correctness depends on `SqliteSaver`'s internal `threading.Lock` continuing to guard every connection access. If that upstream guarantee ever changed, this bridge would need its own lock or a real async saver. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.