feat(services): per-session serialization for chat/stream/delete (A2 concurrency isolation)
FastGPT serializes requests per chatId server-side; the LangGraph backend held all state in memory with no locking, so two concurrent requests on one session could interleave (checkpoint write races, garbled no_reply/turn bookkeeping). - AccidentAppService holds per-session asyncio locks; chat(), stream() and delete_session() execute under the session lock (LLM call included - same-session turns are strictly serialized, different sessions never contend) - set_info/get_info stay synchronous and lock-free: repository load/apply_update are atomic dict operations with no mixed intermediate state; a racing set vs chat yields one complete value - delete pops the lock entry after cleanup; a chat that arrived concurrently executes afterwards against the fresh (deleted) session - matching FastGPT's delete-then-chat new-conversation semantics - tests: same-session concurrent chats serialize (interval pairing probe), different sessions run parallel, delete waits for in-flight chat, chat-vs-set interleave leaves no torn values Verified: 219 unit tests (4 new concurrency tests)
This commit is contained in:
@@ -220,3 +220,104 @@ def _input(session_id, text):
|
||||
from src.backends.chat import ChatInput
|
||||
|
||||
return ChatInput(session_id=session_id, text=text, client_mode="direct")
|
||||
|
||||
|
||||
# ---- 并发隔离(A2):同会话串行、不同会话并行、delete 与 chat 互斥 ----
|
||||
|
||||
import asyncio
|
||||
|
||||
|
||||
class SerialProbeBackend:
|
||||
"""记录 complete 执行区间的 backend:start/end 事件对可判断是否交错。"""
|
||||
|
||||
def __init__(self, delay: float = 0.03):
|
||||
self.events: list[tuple[str, str]] = []
|
||||
self._delay = delay
|
||||
|
||||
async def complete(self, chat_input):
|
||||
self.events.append(("start", chat_input.text))
|
||||
await asyncio.sleep(self._delay)
|
||||
self.events.append(("end", chat_input.text))
|
||||
return ChatResult(content="<state>1002</state>收到")
|
||||
|
||||
async def stream(self, chat_input):
|
||||
self.events.append(("start", chat_input.text))
|
||||
await asyncio.sleep(self._delay)
|
||||
self.events.append(("end", chat_input.text))
|
||||
yield TextDelta("<state>1002</state>收到")
|
||||
|
||||
|
||||
def _paired(events: list[tuple[str, str]]) -> bool:
|
||||
"""每个 start 与其 end 相邻配对(无交错)。顺序任意。"""
|
||||
stack: list[str] = []
|
||||
for kind, tag in events:
|
||||
if kind == "start":
|
||||
if stack:
|
||||
return False
|
||||
stack.append(tag)
|
||||
else:
|
||||
if not stack or stack.pop() != tag:
|
||||
return False
|
||||
return not stack
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_concurrent_chats_same_session_serialize():
|
||||
"""同会话两个并发 chat 必须串行(区间不交错)。"""
|
||||
backend = SerialProbeBackend()
|
||||
service = AccidentAppService(backend, InMemorySessionRepository())
|
||||
await asyncio.gather(
|
||||
service.chat(_input("s1", "a")),
|
||||
service.chat(_input("s1", "b")),
|
||||
)
|
||||
assert sorted(e[1] for e in backend.events) == ["a", "a", "b", "b"]
|
||||
assert _paired(backend.events)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_concurrent_chats_different_sessions_run_parallel():
|
||||
"""不同会话的 chat 互不阻塞(第二个 start 在第一个 end 之前)。"""
|
||||
backend = SerialProbeBackend(delay=0.05)
|
||||
service = AccidentAppService(backend, InMemorySessionRepository())
|
||||
await asyncio.gather(
|
||||
service.chat(_input("s1", "a")),
|
||||
service.chat(_input("s2", "b")),
|
||||
)
|
||||
starts_before_first_end = [
|
||||
tag for kind, tag in backend.events[:2] if kind == "start"
|
||||
]
|
||||
assert sorted(starts_before_first_end) == ["a", "b"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_concurrent_delete_serialization():
|
||||
"""在途 chat 与 delete 互斥:delete 等 chat 完成后才执行。"""
|
||||
backend = SerialProbeBackend()
|
||||
service = AccidentAppService(backend, InMemorySessionRepository())
|
||||
|
||||
async def noop_drop(_session_id):
|
||||
backend.events.append(("drop", "s1"))
|
||||
|
||||
await asyncio.gather(
|
||||
service.chat(_input("s1", "a")),
|
||||
service.delete_session("s1", drop_checkpoint=noop_drop),
|
||||
)
|
||||
# drop 必须出现在 chat 的 end 之后
|
||||
kinds = [e for e in backend.events]
|
||||
drop_index = next(i for i, e in enumerate(kinds) if e[0] == "drop")
|
||||
end_index = next(i for i, e in enumerate(kinds) if e == ("end", "a"))
|
||||
assert drop_index > end_index
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_concurrent_chat_and_set_info_no_corruption():
|
||||
"""set_info(同步无锁)与并发 chat 交错:repository 键值保持完整。"""
|
||||
backend = SerialProbeBackend()
|
||||
service = AccidentAppService(backend, InMemorySessionRepository())
|
||||
results = await asyncio.gather(
|
||||
service.chat(_input("s1", "a")),
|
||||
asyncio.to_thread(service.set_info, "s1", "hphm1", "苏B12345"),
|
||||
)
|
||||
assert results[1] == ("200", "")
|
||||
# 无论时序如何,最终两个写者之一的车牌值完整存在(无撕裂)
|
||||
assert json.loads(service.get_info("s1", "hphm1")) in ("苏B12345", "")
|
||||
|
||||
Reference in New Issue
Block a user