"""T3.8 回归:并行审记账并发安全(add-only,无 flush 重入)。 旧 bug(T3.7 真 pg E2E 暴露):三审作为同一 LangGraph superstep 的并行分支,各自 `gateway.run()` → 共用**同一请求 session** 的 `SqlAlchemyLedgerSink.record` (`session.add` + `await session.flush()`)。`AsyncSession` 非并发安全:`await flush()` 是并行路径里唯一让出控制权的 DB-IO,第二/三审的 flush 撞上「Session is already flushing」(flush 重入)→ 被 `run_review` 失败隔离吞成 `incomplete` → foreshadow/pace 结果静默丢失。 本测试用一个会**侦测 flush 重入**的假 AsyncSession:`flush` 异步(`await asyncio.sleep` 让出控制权),若两协程同时在 flush 中即抛 `RuntimeError("Session is already flushing")` ——精确复刻 SQLAlchemy 的报错。在旧 add-then-flush 实现下,`asyncio.gather` 三并发 `record` 会触发该重入;修复后 `record` 只 `add`(同步、不让出),不再调 flush,故三行 全部入 session 且零重入。 """ from __future__ import annotations import asyncio import uuid import pytest from ww_llm_gateway.ledger import SqlAlchemyLedgerSink from ww_llm_gateway.types import Scope, Usage class _FlushReentryDetectingSession: """假 AsyncSession:记录 `add` 的行;`flush` 异步且侦测并发重入。 `add` 同步追加(镜像真 session)。`flush` 进入时若已有协程在 flush 中 → 抛 `RuntimeError("Session is already flushing")`(复刻 SQLAlchemy 行为),否则 `await asyncio.sleep(0)` 让出控制权模拟真实 DB-IO 让步点。 """ def __init__(self) -> None: self.added: list[object] = [] self.flush_calls = 0 self._flushing = False def add(self, row: object) -> None: self.added.append(row) async def flush(self) -> None: self.flush_calls += 1 if self._flushing: raise RuntimeError("Session is already flushing") self._flushing = True try: await asyncio.sleep(0) # 让出控制权——并行协程在此交错 finally: self._flushing = False def _usage(input_tokens: int) -> Usage: return Usage( provider="deepseek", model="deepseek-chat", input_tokens=input_tokens, output_tokens=1, cost_minor=0, currency="USD", ) def _scope() -> Scope: return Scope(user_id=uuid.UUID(int=1), project_id=uuid.UUID(int=2)) async def test_concurrent_record_adds_all_rows_without_flush_reentry() -> None: """三并发 `record`(模拟三审并行)→ 三行全部入 session,绝不抛 flush 重入。 旧 add-then-flush 实现在此会因 `await flush()` 让出控制权而 flush 重入抛错; add-only 实现下 `record` 不让出控制权,三行确定性入 session。 """ session = _FlushReentryDetectingSession() sink = SqlAlchemyLedgerSink(session) # type: ignore[arg-type] await asyncio.gather( sink.record(_scope(), _usage(100)), sink.record(_scope(), _usage(200)), sink.record(_scope(), _usage(300)), ) # 三审用量全部入 session(一次调用一行,记账不丢) assert len(session.added) == 3 input_tokens = sorted(row.input_tokens for row in session.added) # type: ignore[attr-defined] assert input_tokens == [100, 200, 300] # add-only:sink 不在并行路径 flush(持久化靠端点/事务 commit) assert session.flush_calls == 0 async def test_record_does_not_flush_so_caller_commit_persists() -> None: """单次 `record` 只 add 不 flush——契约「网关 ledger 只 add、调用方负责 commit」。""" session = _FlushReentryDetectingSession() sink = SqlAlchemyLedgerSink(session) # type: ignore[arg-type] await sink.record(_scope(), _usage(42)) assert len(session.added) == 1 assert session.flush_calls == 0 @pytest.mark.parametrize("n_concurrent", [2, 5, 10]) async def test_concurrent_record_scales_without_loss(n_concurrent: int) -> None: """N 并发 `record` 全部落 session,无丢失、无 flush 重入(不论并发度)。""" session = _FlushReentryDetectingSession() sink = SqlAlchemyLedgerSink(session) # type: ignore[arg-type] await asyncio.gather(*(sink.record(_scope(), _usage(i + 1)) for i in range(n_concurrent))) assert len(session.added) == n_concurrent assert session.flush_calls == 0