Files
ZNJJ-api-server/test/services/test_app_service.py
T
Eric Wang f04e5ff612 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)
2026-10-22 22:53:13 +08:00

324 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""作业 6:应用服务四接口组合测试。
验收演示对应(计划作业 6):
创建会话 -> set_info 写车牌 -> get_info 读回 -> chat 使用车牌继续核实
-> 分组读值 -> 删除 -> 再读与再次聊天均不泄漏旧数据。
spy 断言:set_info / get_info / delete_session 全程零模型调用。
"""
import json
import pytest
from src.backends.chat import ChatResult, TextDelta, FormUpdate
from src.services.app_service import AccidentAppService, SET_INFO_INVALID_KEY_MSG
from src.services.repository import InMemorySessionRepository
class SpyBackend:
"""记录调用的 ChatBackend 替身;form_update 模拟图产出的字段差量。"""
def __init__(self, form_update=None):
self.calls = [] # complete 收到的 ChatInput
self.stream_calls = []
self._form_update = form_update or {}
# 用调用次数当“模型调用”spy:真实 backend 每次 complete 至少一次 LLM
self.llm_calls = 0
async def complete(self, chat_input):
self.calls.append(chat_input)
self.llm_calls += 1
return ChatResult(
content="<state>1002</state>收到",
status_code=None,
form_update=dict(self._form_update),
)
async def stream(self, chat_input):
self.stream_calls.append(chat_input)
self.llm_calls += 1
yield TextDelta("<state>1002</state>")
if self._form_update:
yield FormUpdate(dict(self._form_update))
yield TextDelta("收到")
def make_service(form_update=None):
backend = SpyBackend(form_update=form_update)
service = AccidentAppService(backend, InMemorySessionRepository())
return service, backend
# ---- set/get:白名单、分组、外部表示 ----
def test_set_then_get_single_key_roundtrip():
service, backend = make_service()
code, msg = service.set_info("s1", "hphm1", "苏B12345")
assert (code, msg) == ("200", "")
assert json.loads(service.get_info("s1", "hphm1")) == "苏B12345"
# set/get 零模型调用
assert backend.llm_calls == 0
def test_get_groups_match_existing_contract():
service, _ = make_service()
service.set_info("s1", "ywrysw", "0") # False
service.set_info("s1", "jdcsl", "0") # int 0
service.set_info("s1", "xm2", "张三")
acd = json.loads(service.get_info("s1", "acdinfo"))
assert acd["ywrysw"] == "0" # False -> "0"
assert acd["jdcsl"] == "0" # int 0 -> "0"(内部类型不同)
assert acd["sgyy"] == "" # 未知 -> 空串
human2 = json.loads(service.get_info("s1", "acdhuman2"))
assert human2["xm2"] == "张三"
assert human2["sfzmwh2"] == "" # 全字段齐全,未填为空
all_groups = json.loads(service.get_info("s1", "all"))
assert set(all_groups) == {"acdinfo", "acdhuman1", "acdhuman2"}
def test_get_unknown_key_returns_json_empty_string():
"""兼容现状:未知 key 返回 JSON 空串 '""'。"""
service, _ = make_service()
service.set_info("s1", "hphm1", "苏B12345")
assert service.get_info("s1", "no_such_key") == '""'
assert service.get_info("ghost-session", "hphm1") == '""'
def test_set_unknown_or_internal_key_rejected():
service, backend = make_service()
code, msg = service.set_info("s1", "no_such_key", "x")
assert code == "500" and msg == SET_INFO_INVALID_KEY_MSG
code, _ = service.set_info("s1", "stage_code", "1002")
assert code == "500" # 内部字段保护
assert service.get_info("s1", "hphm1") == '""' # 未被污染
assert backend.llm_calls == 0
def test_set_rejects_unparsable_value():
service, _ = make_service()
code, msg = service.set_info("s1", "ywrysw", "maybe")
assert code == "500" and "无法解析" in msg
# ---- chat:注入 + 写回 ----
@pytest.mark.asyncio
async def test_chat_sees_set_info_values_and_writes_back_patch():
"""set_info 写入 -> chat 输入注入 -> 图差量写回 -> get_info 读到。"""
service, backend = make_service(form_update={"ywrysw": False})
service.set_info("s1", "hphm1", "苏B12345")
service.set_info("s1", "xm2", "李四")
result = await service.chat(
_input("s1", "我是刚才报警的驾驶员")
)
assert result.content == "<state>1002</state>收到"
# 图输入必须携带 repository 最新业务字段(车牌供核实使用)
injected = backend.calls[0].injected_fields
assert injected["person_fields"]["hphm1"] == "苏B12345"
assert injected["person_fields"]["xm2"] == "李四"
# 图产出的差量写回 repository,get_info 立即可读
assert json.loads(service.get_info("s1", "acdinfo"))["ywrysw"] == "0"
assert json.loads(service.get_info("s1", "hphm1")) == "苏B12345"
@pytest.mark.asyncio
async def test_chat_form_patch_mapped_to_business_keys():
"""图表单键差量经映射写回业务键(作业 6 映射的端到端)。"""
service, backend = make_service(form_update={
"has_injury": False, "vehicle_count": 2, "accident_cause": "追尾",
})
await service.chat(_input("s1", "两辆汽车追尾,没有人受伤"))
acd = json.loads(service.get_info("s1", "acdinfo"))
assert acd["ywrysw"] == "0"
assert acd["jdcsl"] == "2"
assert acd["sgyy"] == "追尾"
# 表单键本身不落 repository(未知业务键 -> JSON 空串)
assert json.loads(service.get_info("s1", "has_injury")) == ""
@pytest.mark.asyncio
async def test_set_info_acd_value_mapped_into_form_injection():
"""set_info 写业务键 -> chat 注入为图表单键(反向映射)。"""
service, backend = make_service()
service.set_info("s1", "ywrysw", "0")
service.set_info("s1", "jdcsl", "2")
service.set_info("s1", "bjrjs", "1") # 无表单来源,不注入
await service.chat(_input("s1", "继续办理"))
injected = backend.calls[0].injected_fields
assert injected["accident_fields"] == {"has_injury": False, "vehicle_count": 2}
@pytest.mark.asyncio
async def test_chat_without_business_state_passes_no_injection():
service, backend = make_service()
await service.chat(_input("fresh-session", "你好"))
assert backend.calls[0].injected_fields is None
@pytest.mark.asyncio
async def test_stream_injects_fields_writes_back_form_update():
"""流式:注入同 chat;FormUpdate 事件差量捕获写回后透传。"""
service, backend = make_service(form_update={"has_injury": False})
service.set_info("s1", "xm1", "王五")
events = [
event async for event in service.stream(_input("s1", "继续办理"))
]
kinds = [type(event).__name__ for event in events]
assert kinds == ["TextDelta", "FormUpdate", "TextDelta"]
assert backend.stream_calls[0].injected_fields["person_fields"]["xm1"] == "王五"
# FormUpdate 差量已写回 repository(表单键 -> 业务键)
assert json.loads(service.get_info("s1", "acdinfo"))["ywrysw"] == "0"
# ---- delete:清理 + 幂等 ----
@pytest.mark.asyncio
async def test_delete_clears_business_state_and_is_idempotent():
drops = []
async def drop_checkpoint(session_id):
drops.append(session_id)
service, backend = make_service()
service.set_info("s1", "hphm1", "苏B12345")
await service.chat(_input("s1", "你好"))
await service.delete_session("s1", drop_checkpoint=drop_checkpoint)
assert drops == ["s1"]
# 再读为空、再聊天不注入旧数据
assert service.get_info("s1", "hphm1") == '""'
await service.chat(_input("s1", "还在吗"))
assert backend.calls[-1].injected_fields is None
# 重复删除固定行为:成功返回;checkpoint 清理会再次触发,
# 但缺失时静默容忍(幂等语义由 drop_checkpoint 内部保证)
await service.delete_session("s1", drop_checkpoint=drop_checkpoint)
assert drops == ["s1", "s1"]
assert backend.llm_calls == 2 # 两次 chat 各一次;set/get/delete 零调用
@pytest.mark.asyncio
async def test_delete_tolerates_missing_checkpoint():
async def missing(_session_id):
raise RuntimeError("no such thread")
service, _ = make_service()
service.set_info("s1", "xm1", "王五")
# checkpoint 不存在视为删除成功的一部分,不向上抛
await service.delete_session("s1", drop_checkpoint=missing)
assert service.get_info("s1", "xm1") == '""'
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", "")