Skip to content

Commit 182e135

Browse files
committed
refactor(source-chat): extract turn coordination service and fix frontend stale-state races
- Move the per-session lock and pending human-turn persistence policy from `api/routers/source_chat.py` into a new `api/source_chat_service.py` module, exposing `source_chat_turn` so the router delegates serialization and persistence. - Add focused unit tests for the refcounted session lock (serialization, eviction, and cancelled-waiter cleanup) and the message-id-based pending-turn deduplication; update characterization tests to import from the new module. - In the frontend `useSourceChat` hook, use refs and a per-send generation token so stale session snapshots cannot overwrite messages from a newer stream, resolve authoritative state before choosing the message id, and skip session adoption when the abort comes from unmount rather than Stop. - Add tests for authoritative state resolution, stale-refetch suppression, and unmount abort behavior.
1 parent 0db8fc0 commit 182e135

6 files changed

Lines changed: 832 additions & 204 deletions

File tree

api/routers/source_chat.py

Lines changed: 75 additions & 141 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55

66
from fastapi import APIRouter, HTTPException, Path, Request
77
from fastapi.responses import StreamingResponse
8-
from langchain_core.messages import HumanMessage
98
from langchain_core.runnables import RunnableConfig
109
from loguru import logger
1110
from pydantic import BaseModel, Field
@@ -17,6 +16,7 @@
1716
get_source_or_404,
1817
get_verified_source_session,
1918
)
19+
from api.source_chat_service import source_chat_turn
2020
from open_notebook.database.repository import ensure_record_id, repo_query
2121
from open_notebook.domain.notebook import ChatSession
2222
from open_notebook.exceptions import (
@@ -37,43 +37,6 @@
3737
# generation promptly instead of waiting for the next SSE comment interval.
3838
DISCONNECT_POLL_INTERVAL_SECONDS = 1.0
3939

40-
# Per-session locks serialize the read-modify-write sequence (snapshot -> append
41-
# user message -> invoke) in `stream_source_chat_response`. Without them, two
42-
# concurrent requests for the same thread could both read the same trailing
43-
# message and each start a generation. Created lazily; refcounted so the entry is
44-
# evicted once the last holder releases — a long-lived process must not keep one
45-
# lock per session it has ever seen.
46-
class _SessionLock:
47-
__slots__ = ("lock", "holders")
48-
49-
def __init__(self) -> None:
50-
self.lock = asyncio.Lock()
51-
self.holders = 0
52-
53-
54-
_session_locks: dict[str, _SessionLock] = {}
55-
56-
57-
def _get_session_lock(session_id: str) -> _SessionLock:
58-
# No `await` between these dict ops, so on a single event loop the
59-
# read/create/increment is atomic. Registering the caller as a holder before
60-
# it awaits `acquire` keeps the entry alive until it releases.
61-
entry = _session_locks.get(session_id)
62-
if entry is None:
63-
entry = _SessionLock()
64-
_session_locks[session_id] = entry
65-
entry.holders += 1
66-
return entry
67-
68-
69-
def _release_session_lock(session_id: str, entry: _SessionLock) -> None:
70-
entry.lock.release()
71-
entry.holders -= 1
72-
# The `is entry` guard is defensive: the entry is only evicted when this was
73-
# the last holder, so `session_id` must still map to this same entry.
74-
if entry.holders == 0 and _session_locks.get(session_id) is entry:
75-
_session_locks.pop(session_id, None)
76-
7740

7841
# Request/Response models
7942
class CreateSourceChatSessionRequest(BaseModel):
@@ -396,95 +359,83 @@ async def stream_source_chat_response(
396359
config = RunnableConfig(
397360
configurable={"thread_id": session_id, "model_id": model_override}
398361
)
399-
invoke_task: Optional[asyncio.Task] = None
400-
# Serialize snapshot -> append -> invoke per session. Two concurrent requests
401-
# for the same thread would otherwise both read the same trailing message and
402-
# each start a generation. Held for the whole stream; released in finally.
403-
lock_entry = _get_session_lock(session_id)
404-
acquired = False
405362
try:
406-
await lock_entry.lock.acquire()
407-
acquired = True
408-
# Persist the user message to the checkpoint up front so it survives a
409-
# mid-generation disconnect (the frontend refetches the checkpoint on
410-
# cancel/complete and would otherwise drop the user's message). Skip the
411-
# append when this turn is already the trailing (unanswered) one. The
412-
# guard keys on the client message id, not content: a retry that reuses
413-
# the same id is deduplicated, while two distinct identical messages get
414-
# distinct ids and are both kept. A completed exchange always ends with an
415-
# AI message, so a trailing human turn is necessarily still pending.
416-
current_state = await asyncio.to_thread(
417-
source_chat_graph.get_state, config=config
418-
)
419-
already_pending = False
420-
if current_state and current_state.values and "messages" in current_state.values:
421-
existing_messages = current_state.values["messages"]
422-
last_message = existing_messages[-1] if existing_messages else None
423-
already_pending = (
424-
isinstance(last_message, HumanMessage)
425-
and message_id is not None
426-
and getattr(last_message, "id", None) == message_id
427-
)
428-
if not already_pending:
429-
await source_chat_graph.aupdate_state(
430-
config, {"messages": [HumanMessage(content=message, id=message_id)]}
431-
)
432-
433-
# Send user message event
434-
user_event = {"type": "user_message", "content": message, "timestamp": None}
435-
yield f"data: {json.dumps(user_event)}\n\n"
436-
437-
# Run the async graph with ainvoke so generation is cancellable. Only the
438-
# per-message config is passed as input; the messages (incl. the user
439-
# message above) are read from the checkpoint. The ignore is a langgraph
440-
# typing limitation: it accepts a partial state dict at runtime, but the
441-
# signature requires the full state type.
442-
invoke_task = asyncio.create_task(
443-
source_chat_graph.ainvoke(
444-
input={"source_id": source_id, "model_override": model_override}, # type: ignore[call-overload]
445-
config=config,
446-
)
447-
)
448-
last_keepalive = time.monotonic()
449-
while True:
450-
done, _ = await asyncio.wait(
451-
{invoke_task}, timeout=DISCONNECT_POLL_INTERVAL_SECONDS
363+
# Serializes the turn for this session and persists the pending user
364+
# message before generation starts. Held for the whole stream.
365+
async with source_chat_turn(
366+
graph=source_chat_graph,
367+
session_id=session_id,
368+
config=config,
369+
message=message,
370+
message_id=message_id,
371+
):
372+
# Send user message event
373+
user_event = {"type": "user_message", "content": message, "timestamp": None}
374+
yield f"data: {json.dumps(user_event)}\n\n"
375+
376+
# Run the async graph with ainvoke so generation is cancellable. Only
377+
# the per-message config is passed as input; the messages (incl. the
378+
# user message above) are read from the checkpoint. The ignore is a
379+
# langgraph typing limitation: it accepts a partial state dict at
380+
# runtime, but the signature requires the full state type.
381+
invoke_task = asyncio.create_task(
382+
source_chat_graph.ainvoke(
383+
input={"source_id": source_id, "model_override": model_override}, # type: ignore[call-overload]
384+
config=config,
385+
)
452386
)
453-
if done:
454-
# Re-raises on graph error, caught by the outer try/except below.
455-
result = invoke_task.result()
456-
break
457-
if await request.is_disconnected():
458-
# Client went away — stop generating instead of burning tokens.
459-
return
460-
now = time.monotonic()
461-
if now - last_keepalive >= KEEPALIVE_INTERVAL_SECONDS:
462-
# SSE comment — ignored by clients, keeps the connection alive.
463-
yield ": ping\n\n"
464-
last_keepalive = now
465-
466-
# Stream the complete AI response
467-
if "messages" in result:
468-
for msg in result["messages"]:
469-
if hasattr(msg, "type") and msg.type == "ai":
470-
ai_event = {
471-
"type": "ai_message",
472-
"content": msg.content if hasattr(msg, "content") else str(msg),
473-
"timestamp": None,
387+
try:
388+
last_keepalive = time.monotonic()
389+
while True:
390+
done, _ = await asyncio.wait(
391+
{invoke_task}, timeout=DISCONNECT_POLL_INTERVAL_SECONDS
392+
)
393+
if done:
394+
# Re-raises on graph error, caught by the except below.
395+
result = invoke_task.result()
396+
break
397+
if await request.is_disconnected():
398+
# Client went away — stop generating instead of burning
399+
# tokens.
400+
return
401+
now = time.monotonic()
402+
if now - last_keepalive >= KEEPALIVE_INTERVAL_SECONDS:
403+
# SSE comment — ignored by clients, keeps the connection
404+
# alive.
405+
yield ": ping\n\n"
406+
last_keepalive = now
407+
408+
# Stream the complete AI response
409+
if "messages" in result:
410+
for msg in result["messages"]:
411+
if hasattr(msg, "type") and msg.type == "ai":
412+
ai_event = {
413+
"type": "ai_message",
414+
"content": msg.content
415+
if hasattr(msg, "content")
416+
else str(msg),
417+
"timestamp": None,
418+
}
419+
yield f"data: {json.dumps(ai_event)}\n\n"
420+
421+
# Stream context indicators
422+
if "context_indicators" in result:
423+
context_event = {
424+
"type": "context_indicators",
425+
"data": result["context_indicators"],
474426
}
475-
yield f"data: {json.dumps(ai_event)}\n\n"
476-
477-
# Stream context indicators
478-
if "context_indicators" in result:
479-
context_event = {
480-
"type": "context_indicators",
481-
"data": result["context_indicators"],
482-
}
483-
yield f"data: {json.dumps(context_event)}\n\n"
484-
485-
# Send completion signal
486-
completion_event = {"type": "complete"}
487-
yield f"data: {json.dumps(completion_event)}\n\n"
427+
yield f"data: {json.dumps(context_event)}\n\n"
428+
429+
# Send completion signal
430+
completion_event = {"type": "complete"}
431+
yield f"data: {json.dumps(completion_event)}\n\n"
432+
finally:
433+
# Stop generation if the generator is torn down mid-flight
434+
# (client disconnect or server cancellation) so the model doesn't
435+
# keep running. Runs before the turn lock is released.
436+
if not invoke_task.done():
437+
invoke_task.cancel()
438+
await asyncio.gather(invoke_task, return_exceptions=True)
488439

489440
except Exception as e:
490441
from open_notebook.utils.error_classifier import classify_error
@@ -493,23 +444,6 @@ async def stream_source_chat_response(
493444
logger.error(f"Error in source chat streaming: {str(e)}")
494445
error_event = {"type": "error", "message": error_message}
495446
yield f"data: {json.dumps(error_event)}\n\n"
496-
finally:
497-
# Stop generation if the generator is torn down mid-flight (client
498-
# disconnect or server cancellation) so the model doesn't keep running.
499-
if invoke_task is not None and not invoke_task.done():
500-
invoke_task.cancel()
501-
await asyncio.gather(invoke_task, return_exceptions=True)
502-
if acquired:
503-
_release_session_lock(session_id, lock_entry)
504-
else:
505-
# Cancelled while waiting to acquire — decrement the holder count
506-
# registered in `_get_session_lock` without releasing an unheld lock.
507-
lock_entry.holders -= 1
508-
if (
509-
lock_entry.holders == 0
510-
and _session_locks.get(session_id) is lock_entry
511-
):
512-
_session_locks.pop(session_id, None)
513447

514448

515449
@router.post("/sources/{source_id}/chat/sessions/{session_id}/messages")

api/source_chat_service.py

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
"""Turn coordination for source chat.
2+
3+
Owns the policy a source-chat turn needs before generation can start: the
4+
per-session lock that serializes the snapshot -> append -> invoke sequence, and
5+
the decision to persist the pending human turn. The graph is passed in so the
6+
caller's module attribute stays authoritative and this module is usable
7+
standalone.
8+
"""
9+
10+
import asyncio
11+
from contextlib import asynccontextmanager
12+
from typing import Any, AsyncIterator, Optional, Protocol
13+
14+
from langchain_core.messages import HumanMessage
15+
from langchain_core.runnables import RunnableConfig
16+
17+
18+
class CheckpointGraph(Protocol):
19+
"""The slice of a compiled LangGraph a turn needs."""
20+
21+
def get_state(self, config: RunnableConfig) -> Any: ...
22+
23+
async def aupdate_state(self, config: RunnableConfig, values: Any) -> Any: ...
24+
25+
26+
# Per-session locks serialize the read-modify-write sequence (snapshot -> append
27+
# user message -> invoke). Without them, two concurrent requests for the same
28+
# thread could both read the same trailing message and each start a generation.
29+
# Created lazily; refcounted so the entry is evicted once the last holder
30+
# releases — a long-lived process must not keep one lock per session it has ever
31+
# seen.
32+
class _SessionLock:
33+
__slots__ = ("lock", "holders")
34+
35+
def __init__(self) -> None:
36+
self.lock = asyncio.Lock()
37+
self.holders = 0
38+
39+
40+
_session_locks: dict[str, _SessionLock] = {}
41+
42+
43+
def _register_holder(session_id: str) -> _SessionLock:
44+
# No `await` between these dict ops, so on a single event loop the
45+
# read/create/increment is atomic. Registering the caller as a holder before
46+
# it awaits `acquire` keeps the entry alive until it releases.
47+
entry = _session_locks.get(session_id)
48+
if entry is None:
49+
entry = _SessionLock()
50+
_session_locks[session_id] = entry
51+
entry.holders += 1
52+
return entry
53+
54+
55+
def _drop_holder(session_id: str, entry: _SessionLock) -> None:
56+
entry.holders -= 1
57+
# The `is entry` guard is defensive: the entry is only evicted when this was
58+
# the last holder, so `session_id` must still map to this same entry.
59+
if entry.holders == 0 and _session_locks.get(session_id) is entry:
60+
_session_locks.pop(session_id, None)
61+
62+
63+
@asynccontextmanager
64+
async def session_turn_lock(session_id: str) -> AsyncIterator[None]:
65+
"""Hold the session's turn lock for the duration of the block."""
66+
entry = _register_holder(session_id)
67+
acquired = False
68+
try:
69+
await entry.lock.acquire()
70+
acquired = True
71+
yield
72+
finally:
73+
# Cancelled while waiting to acquire: drop the holder registered above
74+
# without releasing an unheld lock.
75+
if acquired:
76+
entry.lock.release()
77+
_drop_holder(session_id, entry)
78+
79+
80+
async def persist_pending_human_turn(
81+
graph: CheckpointGraph,
82+
config: RunnableConfig,
83+
message: str,
84+
message_id: Optional[str] = None,
85+
) -> bool:
86+
"""Append the user message to the checkpoint unless it is already pending.
87+
88+
Persisting up front makes the message survive a mid-generation disconnect
89+
(the frontend refetches the checkpoint on cancel/complete and would
90+
otherwise drop it). The guard keys on the client message id, not content: a
91+
retry that reuses the same id is deduplicated, while two distinct identical
92+
messages get distinct ids and are both kept. A completed exchange always
93+
ends with an AI message, so a trailing human turn is necessarily still
94+
pending.
95+
96+
Returns whether the message was appended.
97+
"""
98+
# SqliteSaver has no async read, so snapshot off the event loop.
99+
current_state = await asyncio.to_thread(graph.get_state, config=config)
100+
already_pending = False
101+
if current_state and current_state.values and "messages" in current_state.values:
102+
existing_messages = current_state.values["messages"]
103+
last_message = existing_messages[-1] if existing_messages else None
104+
already_pending = (
105+
isinstance(last_message, HumanMessage)
106+
and message_id is not None
107+
and getattr(last_message, "id", None) == message_id
108+
)
109+
if already_pending:
110+
return False
111+
await graph.aupdate_state(
112+
config, {"messages": [HumanMessage(content=message, id=message_id)]}
113+
)
114+
return True
115+
116+
117+
@asynccontextmanager
118+
async def source_chat_turn(
119+
graph: CheckpointGraph,
120+
session_id: str,
121+
config: RunnableConfig,
122+
message: str,
123+
message_id: Optional[str] = None,
124+
) -> AsyncIterator[None]:
125+
"""Serialize and persist one source-chat turn, then run the caller's block.
126+
127+
The lock is held for the whole block so a concurrent request for the same
128+
thread cannot snapshot the same trailing message and start a second
129+
generation.
130+
"""
131+
async with session_turn_lock(session_id):
132+
await persist_pending_human_turn(graph, config, message, message_id)
133+
yield

0 commit comments

Comments
 (0)