fix: 装配切换审查修复(启动异常路径韧性/清扫隔离/文案与夹具卫生)

- main.lifespan: 预热+清扫挪进 try,finally dispose_all 覆盖启动半途
  失败(幂等于更少引擎,堵 engine 泄漏);清扫循环体 per-tenant
  try/except + logger.exception 后继续——一个租户坏库不炸平台启动,
  resolve/load_from_dir 保持 fail-fast
- routes/runtime.py: RuntimeError 文案补 registry_reload dispose 旧
  runtime 成因;TenantRuntime 注解挪 TYPE_CHECKING(engine 包运行期
  零 import platform)
- conftest client: teardown 置空 app.state.registry(关残留窗);
  rmtree 先正常删,失败 warnings.warn 留痕后再 ignore_errors 兜底

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-10-12 19:37:51 +08:00
parent d041077bb6
commit 20dddc151e
3 changed files with 48 additions and 21 deletions
+29 -16
View File
@@ -1,5 +1,6 @@
from contextlib import asynccontextmanager
from pathlib import Path
import logging
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
@@ -7,6 +8,8 @@ from fastapi.staticfiles import StaticFiles
from app.engine.config import STATIC_DIR
from app.platform.registry import TenantRegistry
logger = logging.getLogger(__name__)
# 路径锚定(不随 CWD 漂移):seed_profiles 与 tenants 都在仓库根
BASE_DIR = Path(__file__).resolve().parents[1]
SEED_DIR = BASE_DIR / "seed_profiles"
@@ -25,25 +28,35 @@ async def lifespan(app: FastAPI):
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
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:
# 预热默认租户(过渡兜底读 app.state.default_runtime)。engine 建在
# 本 lifespan 的事件循环里——连接与 loop 绑定,别处另起 asyncio.run
# 复用同一 engine 会跨 loop 死锁(测试直连造数走 conftest.async_db)。
# 预热/加载保持 fail-fast:默认租户起不来 = 过渡期全站无租户。
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
for rt in registry.runtimes():
if rt.config.status != "active" or not rt.db_path.exists():
continue
try:
ready = await registry.ensure_ready(rt)
await sweep_stale_pending(session_factory=ready.session_factory)
except Exception:
logger.exception(
"启动清扫失败,跳过该租户(slug=%s, db=%s)",
rt.config.slug, rt.db_path)
yield
finally:
await registry.dispose_all() # Windows 文件锁: 不关则库文件删不掉
# 幂等于"更少的引擎":启动半途失败也把已建好的 engine 关掉
# (Windows 文件锁: 不关则库文件删不掉)
await registry.dispose_all()
app = FastAPI(title="HESUPIPE AI 客服", lifespan=lifespan)