feat: 装配切换——main/服务构造线/conftest 迁至 Registry(含默认租户过渡兜底)

- main.py: 删全局 engine/async_session;lifespan 装配 TenantRegistry
  (seed_profiles → 仓库根 tenants/,路径锚定),预热默认租户 jiancai 挂
  app.state.default_runtime,逐 active 租户(库文件已存在)跑
  sweep_stale_pending,退出 dispose_all。app.state.registry 预置即沿用
  (测试注入缝)
- routes/runtime.py(过渡,Task 7 删): get_runtime——state.tenant 优先,
  缺省落 default_runtime;chat.py/chat_rest.py 全部改走它,消除
  main↔routes 循环依赖
- ConversationManager 构造线: 新增 runtime 可选参(持 session_factory/
  compiled_wall/config 引用;prompts 仍模块级,Task 6;业务接线 Task 9),
  fire_pending_pushes 随任务传 session_factory(后台写回派发租户的库)
- lead_push_service: 去 app.main import;写库入口显式收 session_factory,
  模块级 async_session=None 仅作测试注入兜底
- conftest: client 夹具 = 每测试独立 Registry(tmp data_dir)+ 真实
  lifespan 在 TestClient 循环内预热;删 _dispose_main_engine;
  async_db 保持 NullPool(跨 asyncio.run 循环安全,刻意不换 Registry 引擎)
- 测试仅动夹具/桩签名(断言零改动): wiring 桩补 session_factory 形参;
  history-404 用例改经真实 WS 路由造 widget 会话(引擎常驻连接绑定
  TestClient 循环,测试线程不得直开 session_factory)

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-10-12 19:15:46 +08:00
parent 0c296ca1ba
commit d041077bb6
11 changed files with 198 additions and 137 deletions
+6 -3
View File
@@ -3,8 +3,8 @@ import logging
from fastapi import APIRouter, WebSocket, WebSocketDisconnect from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from app.main import async_session
from app.engine.models import ConversationSource, ConversationStatus from app.engine.models import ConversationSource, ConversationStatus
from app.engine.routes.runtime import get_runtime
from app.engine.services.conversation import ConversationManager from app.engine.services.conversation import ConversationManager
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -17,8 +17,11 @@ async def ws_chat(websocket: WebSocket):
session_id = None session_id = None
try: try:
async with async_session() as db: # Task 5b: 租户运行时经 state/默认租户兜底解析(过渡期无中间件);
manager = ConversationManager(db) # manager 由装配线从 runtime 造(db 会话 + runtime 引用)
runtime = get_runtime(websocket)
async with runtime.session_factory() as db:
manager = ConversationManager(db, runtime=runtime)
# Collect client info # Collect client info
client_ip = websocket.client.host if websocket.client else "" client_ip = websocket.client.host if websocket.client else ""
+13 -8
View File
@@ -17,8 +17,8 @@ from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy import select from sqlalchemy import select
from app.main import async_session
from app.engine.models import ConversationSource, ConversationStatus, Message from app.engine.models import ConversationSource, ConversationStatus, Message
from app.engine.routes.runtime import get_runtime
from app.engine.services.conversation import ConversationManager from app.engine.services.conversation import ConversationManager
from app.engine.services.rate_limit import check as rate_check from app.engine.services.rate_limit import check as rate_check
@@ -45,8 +45,11 @@ async def chat_session(req: SessionRequest, request: Request):
if xf: if xf:
client_info["ip"] = xf client_info["ip"] = xf
async with async_session() as db: # Task 5b: 租户运行时经 state/默认租户兜底解析(过渡期无中间件);
manager = ConversationManager(db) # manager 由装配线从 runtime 造(db 会话 + runtime 引用)
runtime = get_runtime(request)
async with runtime.session_factory() as db:
manager = ConversationManager(db, runtime=runtime)
if req.session_id: if req.session_id:
conv = await manager.get_by_session(req.session_id) conv = await manager.get_by_session(req.session_id)
if conv and conv.status == ConversationStatus.active: if conv and conv.status == ConversationStatus.active:
@@ -68,12 +71,13 @@ async def chat_session(req: SessionRequest, request: Request):
@router.get("/{session_id}/messages") @router.get("/{session_id}/messages")
async def chat_history(session_id: str): async def chat_history(session_id: str, request: Request):
"""Public history endpoint for session replay. App-source only, to narrow """Public history endpoint for session replay. App-source only, to narrow
the read surface; an unknown or non-app session yields 404. the read surface; an unknown or non-app session yields 404.
""" """
async with async_session() as db: runtime = get_runtime(request)
manager = ConversationManager(db) async with runtime.session_factory() as db:
manager = ConversationManager(db, runtime=runtime)
conv = await manager.get_by_session(session_id) conv = await manager.get_by_session(session_id)
if not conv or conv.source != ConversationSource.app: if not conv or conv.source != ConversationSource.app:
raise HTTPException(status_code=404, detail="session not found") raise HTTPException(status_code=404, detail="session not found")
@@ -115,8 +119,9 @@ async def chat_message(req: MessageRequest, request: Request):
if not rate_check(ip, dev): if not rate_check(ip, dev):
raise HTTPException(status_code=429, detail="rate limit exceeded") raise HTTPException(status_code=429, detail="rate limit exceeded")
async with async_session() as db: runtime = get_runtime(request)
manager = ConversationManager(db) async with runtime.session_factory() as db:
manager = ConversationManager(db, runtime=runtime)
conv = await manager.get_by_session(req.session_id) conv = await manager.get_by_session(req.session_id)
if not conv: if not conv:
raise HTTPException(status_code=404, detail="session not found") raise HTTPException(status_code=404, detail="session not found")
+26
View File
@@ -0,0 +1,26 @@
"""路由取当前租户运行时——Task 5b→7 的过渡兜底。
Task 7 的 Host 中间件上线前,没有人为请求挂 ``state.tenant``。本助手的
兜底规则 = state 里有租户用 state 的(中间件灰度接入即生效);没有则用
``app.state.default_runtime``(lifespan 预热的默认租户 jiancai),API 测试
与直连(无 Host 语义)在 5b→7 过渡期不断。
已知过渡限制(记录在案,Task 7 一并解决):默认租户经 registry_reload
热切换后 default_runtime 仍指向旧 runtime——过渡期不修,Task 7 中间件
按请求 Host 重新 resolve 后本模块整体删除。
"""
from fastapi import Request, WebSocket
from app.platform.registry import TenantRuntime
def get_runtime(request_or_ws: Request | WebSocket) -> TenantRuntime:
"""state.tenant 优先,缺省落 app.state.default_runtime(Task 7 删)。"""
runtime = getattr(request_or_ws.state, "tenant", None)
if runtime is not None:
return runtime
runtime = getattr(request_or_ws.app.state, "default_runtime", None)
if runtime is None or runtime.session_factory is None:
raise RuntimeError(
"默认租户运行时未就绪——lifespan 未运行或未预热 default_runtime")
return runtime
+19 -3
View File
@@ -3,6 +3,7 @@ import re
import uuid import uuid
from collections.abc import AsyncGenerator from collections.abc import AsyncGenerator
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from typing import TYPE_CHECKING
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -26,6 +27,9 @@ from app.engine.services.contact_pipeline import (
normalize_contacts, resolve_by_recency, scan_user_messages, split_validate, normalize_contacts, resolve_by_recency, scan_user_messages, split_validate,
) )
if TYPE_CHECKING:
from app.platform.registry import TenantRuntime
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# 职能边界触发词(Task 13.2, 评审#1/#4 修订): 命中即注入红线提醒。精修说明: # 职能边界触发词(Task 13.2, 评审#1/#4 修订): 命中即注入红线提醒。精修说明:
@@ -323,9 +327,20 @@ def _boundary_rotate() -> int:
class ConversationManager: class ConversationManager:
def __init__(self, db: AsyncSession, ai_service: AIService | None = None): def __init__(self, db: AsyncSession, ai_service: AIService | None = None,
runtime: "TenantRuntime | None" = None):
self.db = db self.db = db
self.ai = ai_service or AIService() self.ai = ai_service or AIService()
# Task 5b 装配线注入: 持有租户运行时引用(session_factory/compiled_wall/
# config 就位;prompts 仍为模块级,Task 6 才 PromptProvider 化;email/push
# 等 settings→config 业务接线是 Task 9)。None = 无租户上下文(直连测试)。
self.runtime = runtime
@property
def session_factory(self):
"""推送后台任务写结果用的工厂(写回派发租户的库);无 runtime 时
None,lead_push_service 回退模块级兜底。"""
return self.runtime.session_factory if self.runtime is not None else None
async def create_conversation( async def create_conversation(
self, self,
@@ -777,8 +792,9 @@ class ConversationManager:
await self.db.commit() await self.db.commit()
# 询盘推送(设计 8.1): 主事务提交后才派生后台任务——payload 内存 # 询盘推送(设计 8.1): 主事务提交后才派生后台任务——payload 内存
# 传递,任务不读库只写结果;fire 为同步函数,纳秒级返回不阻塞 # 传递,任务不读库只写结果;fire 为同步函数,纳秒级返回不阻塞。
lead_push_service.fire_pending_pushes(pending_pushes) # session_factory 随任务传递: 写回本租户的库(Task 5b)
lead_push_service.fire_pending_pushes(pending_pushes, self.session_factory)
yield { yield {
"type": "evaluated", "type": "evaluated",
+34 -16
View File
@@ -7,7 +7,8 @@
- sweep_stale_pending: lifespan 启动清扫(崩溃遗留的 pending → failed)。 - sweep_stale_pending: lifespan 启动清扫(崩溃遗留的 pending → failed)。
后台任务红线(设计 8.1): 不读库只写结果,payload 内存传递; 后台任务红线(设计 8.1): 不读库只写结果,payload 内存传递;
结果写入用独立 session(from app.main import async_session,照 chat_rest 模式)。 结果写入用独立 session——session_factory 由派发方传入(Task 5b 起无全局
单例,写回派发它的那个租户的库)。
""" """
import asyncio import asyncio
import logging import logging
@@ -18,7 +19,6 @@ from sqlalchemy import func, select, update
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.engine.config import settings from app.engine.config import settings
from app.main import async_session
from app.engine.models import ( from app.engine.models import (
Conversation, CustomerProfile, Evaluation, IntentLevel, LeadPushLog, Conversation, CustomerProfile, Evaluation, IntentLevel, LeadPushLog,
Message, MessageRole, Message, MessageRole,
@@ -28,6 +28,12 @@ from app.engine.services.ip_service import resolve_country
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Task 5b 装配切换: app.main 全局单例已删,写库入口改为显式传 session_factory
# (租户正确: 后台任务写结果必须落回派发租户的库)。模块级 async_session 仅作
# 无工厂上下文时的兜底(测试直接驱动 _run_push/sweep_stale_pending 时注入),
# 生产装配不设值——装配线(main.lifespan/ConversationManager)总是显式传。
async_session = None
# 对方契约(docs/AI_INQUIRY_PUSH_API.md): phone/whatsapp/email 至少一个有效 # 对方契约(docs/AI_INQUIRY_PUSH_API.md): phone/whatsapp/email 至少一个有效
_TRIO = ("phone", "whatsapp", "email") _TRIO = ("phone", "whatsapp", "email")
_CST = timezone(timedelta(hours=8)) _CST = timezone(timedelta(hours=8))
@@ -207,9 +213,10 @@ def _retryable(resp: httpx.Response | None) -> bool:
return resp is None or resp.status_code >= 500 return resp is None or resp.status_code >= 500
async def _update_log(push_log_id: str, **fields) -> None: async def _update_log(session_factory, push_log_id: str, **fields) -> None:
"""后台任务写结果——独立 session + 小事务(设计 6.3)。""" """后台任务写结果——独立 session + 小事务(设计 6.3)。
async with async_session() as session: session_factory: 派发租户的工厂(Task 5b),写回同一租户库。"""
async with session_factory() as session:
await session.execute( await session.execute(
update(LeadPushLog) update(LeadPushLog)
.where(LeadPushLog.id == push_log_id) .where(LeadPushLog.id == push_log_id)
@@ -218,11 +225,14 @@ async def _update_log(push_log_id: str, **fields) -> None:
await session.commit() await session.commit()
async def _run_push(push_log_id: str, payload: dict) -> None: async def _run_push(push_log_id: str, payload: dict,
session_factory=None) -> None:
"""后台任务体: 分类重试 + 200 逐条结果解析(设计 6.3/§10)。 """后台任务体: 分类重试 + 200 逐条结果解析(设计 6.3/§10)。
红线: 全套 try/except,异常只记日志绝不逃逸(fire-and-forget)。 红线: 全套 try/except,异常只记日志绝不逃逸(fire-and-forget)。
session_factory 缺省回退模块级 async_session(测试注入缝)。
""" """
factory = session_factory or async_session
try: try:
attempts = 0 attempts = 0
while True: while True:
@@ -230,7 +240,7 @@ async def _run_push(push_log_id: str, payload: dict) -> None:
resp, transport_error = await _do_post(payload) resp, transport_error = await _do_post(payload)
if _retryable(resp) and attempts < settings.PUSH_MAX_ATTEMPTS: if _retryable(resp) and attempts < settings.PUSH_MAX_ATTEMPTS:
await _update_log( await _update_log(
push_log_id, attempts=attempts, factory, push_log_id, attempts=attempts,
error_message=transport_error error_message=transport_error
or (f"http {resp.status_code}" if resp is not None else "unknown"), or (f"http {resp.status_code}" if resp is not None else "unknown"),
) )
@@ -245,7 +255,8 @@ async def _run_push(push_log_id: str, payload: dict) -> None:
except ValueError: except ValueError:
# 非 JSON 的 200(如网关重定向到 HTML 登录页)—— # 非 JSON 的 200(如网关重定向到 HTML 登录页)——
# 无法确认对方已接受,诚实落 failed(幂等键保证补推安全) # 无法确认对方已接受,诚实落 failed(幂等键保证补推安全)
await _update_log(push_log_id, attempts=attempts, status="failed", await _update_log(factory, push_log_id, attempts=attempts,
status="failed",
response_status=200, response_status=200,
error_message="non-JSON 200 body") error_message="non-JSON 200 body")
return return
@@ -257,7 +268,7 @@ async def _run_push(push_log_id: str, payload: dict) -> None:
] ]
if failed_mine: if failed_mine:
await _update_log( await _update_log(
push_log_id, attempts=attempts, status="failed", factory, push_log_id, attempts=attempts, status="failed",
response_status=200, response_status=200,
error_message=str(failed_mine[0].get("reason") or "rejected"), error_message=str(failed_mine[0].get("reason") or "rejected"),
) )
@@ -266,7 +277,7 @@ async def _run_push(push_log_id: str, payload: dict) -> None:
outcome = ("duplicated" outcome = ("duplicated"
if (data.get("duplicated") or 0) > 0 else "accepted") if (data.get("duplicated") or 0) > 0 else "accepted")
await _update_log( await _update_log(
push_log_id, attempts=attempts, status="success", factory, push_log_id, attempts=attempts, status="success",
response_status=200, outcome=outcome, response_status=200, outcome=outcome,
) )
return return
@@ -277,7 +288,7 @@ async def _run_push(push_log_id: str, payload: dict) -> None:
"密钥/报文问题需人工介入,不重试", "密钥/报文问题需人工介入,不重试",
push_log_id, resp.status_code) push_log_id, resp.status_code)
await _update_log( await _update_log(
push_log_id, attempts=attempts, status="failed", factory, push_log_id, attempts=attempts, status="failed",
response_status=resp.status_code if resp is not None else None, response_status=resp.status_code if resp is not None else None,
error_message=transport_error error_message=transport_error
or (f"http {resp.status_code}" if resp is not None else "unknown"), or (f"http {resp.status_code}" if resp is not None else "unknown"),
@@ -290,27 +301,34 @@ async def _run_push(push_log_id: str, payload: dict) -> None:
_tasks: set[asyncio.Task] = set() _tasks: set[asyncio.Task] = set()
def fire_pending_pushes(pushes: list[LeadPushLog]) -> None: def fire_pending_pushes(pushes: list[LeadPushLog], session_factory=None) -> None:
"""主事务 commit 之后调用(设计 8.1): payload 内存传递,任务不读库。 """主事务 commit 之后调用(设计 8.1): payload 内存传递,任务不读库。
同步函数、纳秒级返回——这就是"不阻塞"的派发点。 同步函数、纳秒级返回——这就是"不阻塞"的派发点。session_factory =
派发租户的工厂,随任务传递(后台写结果落回同一租户库);缺省回退
模块级 async_session(测试注入缝)。
""" """
factory = session_factory or async_session
for log in pushes: for log in pushes:
if log.status != "pending": if log.status != "pending":
continue continue
task = asyncio.create_task(_run_push(log.id, log.request_payload)) task = asyncio.create_task(
_run_push(log.id, log.request_payload, factory))
_tasks.add(task) _tasks.add(task)
task.add_done_callback(_tasks.discard) task.add_done_callback(_tasks.discard)
async def sweep_stale_pending(stale_minutes: int = 30) -> int: async def sweep_stale_pending(stale_minutes: int = 30, session_factory=None) -> int:
"""启动清扫(设计 8.2): 崩溃遗留的 pending → failed。 """启动清扫(设计 8.2): 崩溃遗留的 pending → failed。
session_factory: 目标租户的工厂(lifespan 逐租户调用);缺省回退
模块级 async_session(测试注入缝)。
已知限制(设计 §14): 推送成功但结果写回失败也会被标 failed—— 已知限制(设计 §14): 推送成功但结果写回失败也会被标 failed——
幂等键保证将来补推安全(对方返回 duplicated),概率极低,接受。 幂等键保证将来补推安全(对方返回 duplicated),概率极低,接受。
""" """
factory = session_factory or async_session
cutoff = datetime.now(timezone.utc) - timedelta(minutes=stale_minutes) cutoff = datetime.now(timezone.utc) - timedelta(minutes=stale_minutes)
async with async_session() as session: async with factory() as session:
result = await session.execute( result = await session.execute(
update(LeadPushLog).where( update(LeadPushLog).where(
LeadPushLog.status == "pending", LeadPushLog.status == "pending",
+34 -15
View File
@@ -1,30 +1,49 @@
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from app.engine.config import settings, ensure_dirs, STATIC_DIR from app.engine.config import STATIC_DIR
from app.engine.models import ensure_schema from app.platform.registry import TenantRegistry
# 路径锚定(不随 CWD 漂移):seed_profiles 与 tenants 都在仓库根
# 后台推送任务的独立连接写结果可能撞上 SMTP 窗口内持有的写锁,默认 5s BASE_DIR = Path(__file__).resolve().parents[1]
# 忙等上限低于 SMTP 最坏时长,抬到 30s;等待发生在 aiosqlite 工作线程,不阻塞事件循环 SEED_DIR = BASE_DIR / "seed_profiles"
engine = create_async_engine(settings.DATABASE_URL, echo=settings.APP_DEBUG, TENANTS_DIR = BASE_DIR / "tenants" # .gitignore 已忽略 tenants/*.db
connect_args={"timeout": 30}) # 过渡兜底(Task 5b→7,中间件上线后随 routes/runtime.py 一并删除):
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) # Host 中间件落地前请求 state 里没有租户,一律落默认租户 jiancai
DEFAULT_TENANT_HOST = "chat2.agent.hnxinyouhe.com"
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
ensure_dirs() # 测试注入缝:conftest 预置过 app.state.registry(临时 data_dir 的独立
await ensure_schema(engine) # Registry)则沿用,不再加载仓库 seed——测试租户库与生产互不污染
# 询盘推送(设计 8.2): 清扫崩溃遗留的 stale pending → failed registry = getattr(app.state, "registry", None)
# 函数内导入: lead_push_service 顶部 from app.main import async_session, if registry is None:
# 顶层互相导入会循环 registry = TenantRegistry.load_from_dir(SEED_DIR, data_dir=TENANTS_DIR)
app.state.registry = registry
# 预热默认租户(过渡兜底读 app.state.default_runtime)。engine 建在本
# lifespan 的事件循环里——连接与 loop 绑定,别处另起 asyncio.run 复用
# 同一 engine 会跨 loop 死锁(测试直连造数走 conftest.async_db)
app.state.default_runtime = await registry.resolve(DEFAULT_TENANT_HOST)
# 询盘推送(设计 8.2): 清扫崩溃遗留的 stale pending → failed。逐租户、
# 用该租户自己的 session_factory;db 文件不存在的租户跳过——全新库
# 无遗留行,保持 engine 懒建纪律(预热的默认租户文件已建,照常扫)。
from app.engine.services.lead_push_service import sweep_stale_pending from app.engine.services.lead_push_service import sweep_stale_pending
await sweep_stale_pending() for rt in registry.runtimes():
if rt.config.status != "active" or not rt.db_path.exists():
continue
ready = await registry.ensure_ready(rt)
await sweep_stale_pending(session_factory=ready.session_factory)
try:
yield yield
finally:
await registry.dispose_all() # Windows 文件锁: 不关则库文件删不掉
app = FastAPI(title="HESUPIPE AI 客服", lifespan=lifespan) app = FastAPI(title="HESUPIPE AI 客服", lifespan=lifespan)
+13
View File
@@ -216,6 +216,19 @@ class TenantRegistry:
"""resolve_site 的便捷形式(双 API 一层实现)。""" """resolve_site 的便捷形式(双 API 一层实现)。"""
return (await self.resolve_site(host))[0] return (await self.resolve_site(host))[0]
# ---- 装配期辅助(Task 5b: main.lifespan 逐租户启动清扫)------------------
def runtimes(self) -> tuple[TenantRuntime, ...]:
"""全部租户运行时的加载期快照(engine 仍懒建)——装配期遍历用
(lifespan 启动清扫逐租户跑)。返回快照 tuple:遍历中 registry_reload
换条目不影响本轮迭代。"""
return tuple(self._by_slug.values())
async def ensure_ready(self, rt: TenantRuntime) -> TenantRuntime:
"""懒建的公开入口(装配期批处理用)——与 resolve 走同一条就绪路径,
现势检查/双检/建库失败半开语义完全一致。"""
return await self._ensure_ready(rt)
# ---- 懒建 / 热加载 / 释放 --------------------------------------------- # ---- 懒建 / 热加载 / 释放 ---------------------------------------------
async def _ensure_ready(self, rt: TenantRuntime) -> TenantRuntime: async def _ensure_ready(self, rt: TenantRuntime) -> TenantRuntime:
+34 -69
View File
@@ -1,27 +1,23 @@
import os
import tempfile
import asyncio import asyncio
import os
import shutil
import tempfile
from pathlib import Path
import pytest import pytest
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.pool import NullPool from sqlalchemy.pool import NullPool
from app.engine.models import Base from app.engine.models import Base
# `chat_rest` is created in Task A4. Tolerate its absence so earlier tasks # Task 5b 装配切换: app 不再有全局 engine/async_session——旧 conftest 里
# (e.g. A0's /health smoke) can use the `client` fixture without it. # "先 import app.main 再 import chat_rest"的循环导入次序坑随全局单例一并
# Import `app.main` FIRST so the natural main -> chat_rest load order resolves # 消失(routes 不再反向 import app.main),直接导入即可。
# (chat_rest does `from app.main import async_session`; if conftest imports from app.main import app
# chat_rest before main is loaded, main's `from chat_rest import router` hits from app.platform.registry import TenantRegistry
# a partially-initialized module -> circular ImportError).
_HAS_CHAT_REST = False REPO_ROOT = Path(__file__).resolve().parents[1]
chat_rest_mod = None SEED_DIR = REPO_ROOT / "seed_profiles"
try:
import app.main # noqa: F401
import app.engine.routes.chat_rest as chat_rest_mod
_HAS_CHAT_REST = True
except ModuleNotFoundError:
pass
async def _create_all(engine): async def _create_all(engine):
@@ -29,34 +25,15 @@ async def _create_all(engine):
await conn.run_sync(Base.metadata.create_all) await conn.run_sync(Base.metadata.create_all)
@pytest.fixture(scope="session", autouse=True)
def _dispose_main_engine():
"""Dispose `app.main.engine` at session end.
Best-effort cleanup: explicitly disposing the app-level engine before
interpreter shutdown reduces (but does NOT eliminate) the noisy
`Windows fatal exception: access violation` traces that aiosqlite worker
threads emit when interrupted mid-cleanup at interpreter exit.
Reality check on Windows:
- All tests pass and pytest exits 0; the traces are written to stderr
AFTER the run completes and do not affect pass/fail or the exit code.
- They originate from aiosqlite background threads being torn down by the
interpreter and are harmless.
- They do not occur on Linux/CI.
"""
yield
from app.main import engine as main_engine
try:
asyncio.run(main_engine.dispose())
except Exception:
pass
@pytest.fixture @pytest.fixture
def async_db(): def async_db():
"""Fresh temp-file SQLite sessionmaker for direct async-DB tests """Fresh temp-file SQLite sessionmaker for direct async-DB tests
(e.g. testing ConversationManager without HTTP). Isolated per test. (e.g. testing ConversationManager without HTTP). Isolated per test.
刻意不走 Registry 引擎: 本夹具被各测试反复 asyncio.run(每次全新事件
循环),NullPool 连接即借即还才能跨循环安全;Registry 的 pool=2 常驻
连接与创建它的循环绑定,只能活在单一事件循环里(client 夹具的
TestClient 循环,见下)。
""" """
fd, path = tempfile.mkstemp(suffix=".db") fd, path = tempfile.mkstemp(suffix=".db")
os.close(fd) os.close(fd)
@@ -84,37 +61,25 @@ def _reset_rate_limit():
@pytest.fixture @pytest.fixture
def client(monkeypatch): def client(tmp_path):
"""Temporary file-SQLite TestClient with `chat_rest.async_session` overridden. """TestClient + 每测试独立 Registry(tmp_path 作 data_dir)。
Design notes: - 预置 app.state.registry(测试注入缝): lifespan 见 registry 已挂则
- File DB (not :memory:) avoids the StaticPool cross-loop reuse pitfall. 沿用,不再加载仓库 seed_profiles/tenants——每测试拿到全新租户库,
- `NullPool` closes each connection when the session returns it, so no 与生产数据互不污染。
aiosqlite worker thread outlives the event loop that created it. - 默认租户(jiancai)的 engine 由 lifespan 在 TestClient 的 portal 事件
Together with `_dispose_main_engine`, this REDUCES but does NOT 循环内预热,后续请求也在同一循环处理(连接/循环绑定安全)。测试
eliminate the harmless Windows access-violation stderr traces described 线程需要直接造数时不得用该 engine——asyncio.run 另起循环,跨循环
above; it has no effect on test correctness or on Linux/CI. 复用常驻连接会死锁;直连断言走 async_db,或经真实路由(WS/REST)。
- Monkeypatching the module-level `async_session` binding in `chat_rest` - 退出: lifespan finally 里 registry.dispose_all()(Windows 文件锁,
works because the route handlers resolve it as a module global at call. 不关则 tmp 目录删不掉),随后清理 tmp 目录。
""" """
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from app.main import app
fd, path = tempfile.mkstemp(suffix=".db")
os.close(fd)
engine = create_async_engine(
f"sqlite+aiosqlite:///{path}", poolclass=NullPool
)
asyncio.run(_create_all(engine))
sm = async_sessionmaker(engine, expire_on_commit=False)
if _HAS_CHAT_REST:
monkeypatch.setattr(chat_rest_mod, "async_session", sm)
registry = TenantRegistry.load_from_dir(SEED_DIR, data_dir=tmp_path)
app.state.registry = registry
try:
with TestClient(app) as c: with TestClient(app) as c:
yield c yield c
finally:
asyncio.run(engine.dispose()) shutil.rmtree(tmp_path, ignore_errors=True)
try:
os.remove(path)
except PermissionError:
pass
+10 -14
View File
@@ -1,4 +1,7 @@
# `client` fixture from conftest (temp SQLite + overrides chat_rest.async_session). # `client` fixture from conftest (per-test Registry on tmp data_dir; default
# tenant runtime pre-warmed by the app lifespan inside the TestClient loop).
import json
def test_session_creates_new(client): def test_session_creates_new(client):
@@ -26,17 +29,10 @@ def test_history_404_unknown_sid(client):
def test_history_rejects_non_app_session(client): def test_history_rejects_non_app_session(client):
# widget-source conversations are not readable via this public endpoint # widget-source conversations are not readable via this public endpoint
# (narrow the read surface to app only). # (narrow the read surface to app only). 经真实 WS 路由造 widget 会话:
import asyncio # 租户 engine 的常驻连接绑定 TestClient 的事件循环,测试线程不得另起
from app.engine.routes import chat_rest # asyncio.run 直开其 session_factory(跨循环复用会死锁)。
from app.engine.services.conversation import ConversationManager with client.websocket_connect("/ws/chat") as ws:
from app.engine.models import ConversationSource ws.send_text(json.dumps({"source": "widget"}))
sid = ws.receive_json()["session_id"]
async def _make_widget():
async with chat_rest.async_session() as db:
mgr = ConversationManager(db)
c = await mgr.create_conversation(source=ConversationSource.widget)
return c.session_id
sid = asyncio.run(_make_widget())
assert client.get(f"/api/chat/{sid}/messages").status_code == 404 assert client.get(f"/api/chat/{sid}/messages").status_code == 404
+1 -1
View File
@@ -482,7 +482,7 @@ def test_fire_pending_creates_tasks(monkeypatch):
from app.engine.services import lead_push_service as svc from app.engine.services import lead_push_service as svc
pushed = [] pushed = []
async def fake_run(log_id, payload): async def fake_run(log_id, payload, session_factory=None):
pushed.append((log_id, payload)) pushed.append((log_id, payload))
monkeypatch.setattr(svc, "_run_push", fake_run) monkeypatch.setattr(svc, "_run_push", fake_run)
+6 -6
View File
@@ -50,7 +50,7 @@ def _setup(async_db, monkeypatch, ai, lead_succeeds=True):
fired = [] fired = []
monkeypatch.setattr(lead_push_service, "fire_pending_pushes", monkeypatch.setattr(lead_push_service, "fire_pending_pushes",
lambda logs: fired.extend(logs)) lambda logs, session_factory=None: fired.extend(logs))
monkeypatch.setattr(lead_push_service.settings, "PUSH_ENABLED", True) monkeypatch.setattr(lead_push_service.settings, "PUSH_ENABLED", True)
async def run(): async def run():
@@ -165,7 +165,7 @@ def test_push_disabled_writes_nothing(async_db, monkeypatch):
monkeypatch.setattr(email_service, "send_lead_email", fake_lead) monkeypatch.setattr(email_service, "send_lead_email", fake_lead)
fired = [] fired = []
monkeypatch.setattr(lead_push_service, "fire_pending_pushes", monkeypatch.setattr(lead_push_service, "fire_pending_pushes",
lambda logs: fired.extend(logs)) lambda logs, session_factory=None: fired.extend(logs))
async def run(): async def run():
async with async_db() as db: async with async_db() as db:
@@ -183,7 +183,7 @@ def test_fire_end_to_end_single_loop(async_db, monkeypatch):
"""真 fire + 假 _run_push: 同一事件循环内 stream 完成后任务确实被派发执行。""" """真 fire + 假 _run_push: 同一事件循环内 stream 完成后任务确实被派发执行。"""
pushed = [] pushed = []
async def fake_run(log_id, payload): async def fake_run(log_id, payload, session_factory=None):
pushed.append(log_id) pushed.append(log_id)
monkeypatch.setattr(lead_push_service, "_run_push", fake_run) monkeypatch.setattr(lead_push_service, "_run_push", fake_run)
@@ -234,7 +234,7 @@ def test_fire_happens_only_after_commit(async_db, monkeypatch):
sent.append("lead") sent.append("lead")
return True return True
async def fake_run(log_id, payload): async def fake_run(log_id, payload, session_factory=None):
async with async_db() as db: async with async_db() as db:
try: try:
row = (await db.execute( row = (await db.execute(
@@ -249,9 +249,9 @@ def test_fire_happens_only_after_commit(async_db, monkeypatch):
real_fire = lead_push_service.fire_pending_pushes real_fire = lead_push_service.fire_pending_pushes
def recording_fire(logs): def recording_fire(logs, session_factory=None):
fired.append(True) fired.append(True)
real_fire(logs) real_fire(logs, session_factory)
monkeypatch.setattr(email_service, "send_lead_email", fake_lead) monkeypatch.setattr(email_service, "send_lead_email", fake_lead)
monkeypatch.setattr(lead_push_service, "_run_push", fake_run) monkeypatch.setattr(lead_push_service, "_run_push", fake_run)