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:
@@ -9,12 +9,17 @@ Task 7 的 Host 中间件上线前,没有人为请求挂 ``state.tenant``。本
|
|||||||
热切换后 default_runtime 仍指向旧 runtime——过渡期不修,Task 7 中间件
|
热切换后 default_runtime 仍指向旧 runtime——过渡期不修,Task 7 中间件
|
||||||
按请求 Host 重新 resolve 后本模块整体删除。
|
按请求 Host 重新 resolve 后本模块整体删除。
|
||||||
"""
|
"""
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from fastapi import Request, WebSocket
|
from fastapi import Request, WebSocket
|
||||||
|
|
||||||
from app.platform.registry import TenantRuntime
|
if TYPE_CHECKING:
|
||||||
|
# 仅类型注解用: engine 包运行期零 import platform(依赖方向保持
|
||||||
|
# platform → engine,反向只在类型层)
|
||||||
|
from app.platform.registry import TenantRuntime
|
||||||
|
|
||||||
|
|
||||||
def get_runtime(request_or_ws: Request | WebSocket) -> TenantRuntime:
|
def get_runtime(request_or_ws: Request | WebSocket) -> "TenantRuntime":
|
||||||
"""state.tenant 优先,缺省落 app.state.default_runtime(Task 7 删)。"""
|
"""state.tenant 优先,缺省落 app.state.default_runtime(Task 7 删)。"""
|
||||||
runtime = getattr(request_or_ws.state, "tenant", None)
|
runtime = getattr(request_or_ws.state, "tenant", None)
|
||||||
if runtime is not None:
|
if runtime is not None:
|
||||||
@@ -22,5 +27,6 @@ def get_runtime(request_or_ws: Request | WebSocket) -> TenantRuntime:
|
|||||||
runtime = getattr(request_or_ws.app.state, "default_runtime", None)
|
runtime = getattr(request_or_ws.app.state, "default_runtime", None)
|
||||||
if runtime is None or runtime.session_factory is None:
|
if runtime is None or runtime.session_factory is None:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
"默认租户运行时未就绪——lifespan 未运行或未预热 default_runtime")
|
"默认租户运行时未就绪——lifespan 未运行、未预热 default_runtime,"
|
||||||
|
"或 registry_reload 已 dispose 旧 runtime(过渡期限制,Task 7 解决)")
|
||||||
return runtime
|
return runtime
|
||||||
|
|||||||
+29
-16
@@ -1,5 +1,6 @@
|
|||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
import logging
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
@@ -7,6 +8,8 @@ from fastapi.staticfiles import StaticFiles
|
|||||||
from app.engine.config import STATIC_DIR
|
from app.engine.config import STATIC_DIR
|
||||||
from app.platform.registry import TenantRegistry
|
from app.platform.registry import TenantRegistry
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# 路径锚定(不随 CWD 漂移):seed_profiles 与 tenants 都在仓库根
|
# 路径锚定(不随 CWD 漂移):seed_profiles 与 tenants 都在仓库根
|
||||||
BASE_DIR = Path(__file__).resolve().parents[1]
|
BASE_DIR = Path(__file__).resolve().parents[1]
|
||||||
SEED_DIR = BASE_DIR / "seed_profiles"
|
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)
|
registry = TenantRegistry.load_from_dir(SEED_DIR, data_dir=TENANTS_DIR)
|
||||||
app.state.registry = registry
|
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:
|
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
|
yield
|
||||||
finally:
|
finally:
|
||||||
await registry.dispose_all() # Windows 文件锁: 不关则库文件删不掉
|
# 幂等于"更少的引擎":启动半途失败也把已建好的 engine 关掉
|
||||||
|
# (Windows 文件锁: 不关则库文件删不掉)
|
||||||
|
await registry.dispose_all()
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(title="HESUPIPE AI 客服", lifespan=lifespan)
|
app = FastAPI(title="HESUPIPE AI 客服", lifespan=lifespan)
|
||||||
|
|||||||
+10
-2
@@ -2,6 +2,7 @@ import asyncio
|
|||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import tempfile
|
import tempfile
|
||||||
|
import warnings
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -72,7 +73,9 @@ def client(tmp_path):
|
|||||||
线程需要直接造数时不得用该 engine——asyncio.run 另起循环,跨循环
|
线程需要直接造数时不得用该 engine——asyncio.run 另起循环,跨循环
|
||||||
复用常驻连接会死锁;直连断言走 async_db,或经真实路由(WS/REST)。
|
复用常驻连接会死锁;直连断言走 async_db,或经真实路由(WS/REST)。
|
||||||
- 退出: lifespan finally 里 registry.dispose_all()(Windows 文件锁,
|
- 退出: lifespan finally 里 registry.dispose_all()(Windows 文件锁,
|
||||||
不关则 tmp 目录删不掉),随后清理 tmp 目录。
|
不关则 tmp 目录删不掉);随后置空 app.state.registry(关残留窗——
|
||||||
|
不让已 dispose 的测试 Registry 被后续无夹具的 lifespan 误用)并
|
||||||
|
清理 tmp 目录(删不掉告警留痕,不静默吞)。
|
||||||
"""
|
"""
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
@@ -82,4 +85,9 @@ def client(tmp_path):
|
|||||||
with TestClient(app) as c:
|
with TestClient(app) as c:
|
||||||
yield c
|
yield c
|
||||||
finally:
|
finally:
|
||||||
shutil.rmtree(tmp_path, ignore_errors=True)
|
app.state.registry = None
|
||||||
|
try:
|
||||||
|
shutil.rmtree(tmp_path)
|
||||||
|
except OSError as exc:
|
||||||
|
warnings.warn(f"client 夹具清理 tmp 目录失败(文件锁?): {exc}")
|
||||||
|
shutil.rmtree(tmp_path, ignore_errors=True)
|
||||||
|
|||||||
Reference in New Issue
Block a user