feat: TenantSource 抽象——File源(测试/档案)与DB源(生产),registry 数据源可切换+reload upsert

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-10-13 08:50:16 +08:00
parent 386b5eb90a
commit c7fd533320
9 changed files with 720 additions and 85 deletions
+77 -72
View File
@@ -1,9 +1,12 @@
"""TenantRegistry——进程内租户注册表(Phase 1 文件加载器)。
"""TenantRegistry——进程内租户注册表(Phase 1 文件加载;Task 4 起数据源
抽象化)。
数据源:seed_profiles/<slug>/tenant.json(站点/品牌/引擎配置)+ 同目录
wall.json(并入 snapshot["wall"],compile_wall 编译期显性炸)。Phase 2 换
platform.db 时本模块的对外接口不变(spec §5.3:后台保存→原子替换条目,
进行中的会话下一条消息即用新配置)。
数据源经 TenantSource 协议注入(app/platform/tenant_source.py):
FileTenantSource(seed_profiles/,测试/出厂档案)与 DbTenantSource
(platform.db,生产 Task 11 起装配)输出同构 LoadedTenant(snapshot =
配置组 ⊕ wall ⊕ prompts),本模块对源差异无感知。load_from_dir 保持
为文件源便捷构造(既有调用零改动)。spec §5.3 的热加载语义不变:后台
保存→原子替换条目,进行中的会话下一条消息即用新配置。
关键行为:
- resolve_site(host):host 小写去端口 → 域名索引 → (TenantRuntime, Site);
@@ -14,16 +17,17 @@ platform.db 时本模块的对外接口不变(spec §5.3:后台保存→原子
per-slug asyncio.Lock 串行,锁内双检防并发双建;持锁后现势检查——条目
已被 reload 换掉则放弃本次建库、按当前条目重走(旧 runtime 永不产生
孤儿 engine);建库失败 dispose 半开 engine,保持未就绪下次重试。
- registry_reload(slug):磁盘读取/校验在锁外,swap+dispose 段与懒建/
dispose_all 取同一把 per-slug 锁。坏配置(无效 JSON/墙编译失败/零站点/
域名冲突)在替换前抛,旧条目原样服务;替换后 dispose 旧 engine
(Windows 下不关=库文件句柄泄漏)。
- registry_reload(slug):经 source 重读(锁外),swap+dispose 段与懒建/
dispose_all 取同一把 per-slug 锁。upsert 语义(Task 4):slug 未加载但
source 已知(DB/文件新建租户)→ 载入插入;source 也不知道 →
UnknownTenantError。坏配置(无效 JSON/墙编译失败/零站点/域名冲突)在
替换前抛,旧条目原样服务;替换后 dispose 旧 engine(Windows 下不关=
库文件句柄泄漏)。
测试/teardown 纪律:凡建过 engine 的 Registry 用完必须 await dispose_all()
(Windows 文件锁,tmp 目录删不掉)。
"""
import asyncio
import json
import pathlib
from dataclasses import dataclass
@@ -39,6 +43,11 @@ from app.engine.models import ensure_schema
from app.engine.services.ai_service import PromptProvider
from app.engine.wall_schema import CompiledWall, compile_wall
from app.platform.tenant_config import Site, TenantConfig
from app.platform.tenant_source import (
FileTenantSource,
LoadedTenant,
TenantSource,
)
class UnknownTenantError(LookupError):
@@ -50,19 +59,6 @@ class TenantDisabledError(LookupError):
Task 7 中间件负责映射)。"""
def _read_json(path: pathlib.Path):
"""读 JSON 文件;读不了/解析不了都带路径抛 ValueError——哪份文件
坏了一目了然(裸 FileNotFoundError/JSONDecodeError 不含路径上下文)。"""
try:
text = path.read_text(encoding="utf-8")
except OSError as exc:
raise ValueError(f"{path}: 无法读取: {exc}") from exc
try:
return json.loads(text)
except ValueError as exc: # JSONDecodeError 等
raise ValueError(f"{path}: 无效 JSON: {exc}") from exc
def make_tenant_engine(db_path: pathlib.Path) -> AsyncEngine:
"""每租户 SQLite engine:pool=2 无溢出(单租户低并发),忙等 30s
(对齐 jc 现状:后台推送连接可能撞 SMTP 窗口写锁),打开即 WAL
@@ -93,16 +89,17 @@ class TenantRuntime:
"""单租户运行时。
加载期就绪:config(TenantConfig:data=快照⊕出厂默认,含 sites)/
db_path(该租户 SQLite 文件路径)/seed_dir(seed_profiles/<slug>/)/
compiled_wall(墙编译产物)/prompts(Task 6 PromptProvider,from_dir
(seed_dir/prompts) 四件套——$DEFLECTION_POOL 话术池注入的单一消费口)。
db_path(该租户 SQLite 文件路径)/seed_dir(文件源=租户目录;DB 源
None——avatar 解析链对其回退静态兜底)/compiled_wall(墙编译产物)/
prompts(PromptProvider.from_config(snapshot["prompts"]),两源统一
入口——$DEFLECTION_POOL 话术池注入的单一消费口)。
懒建:engine/session_factory(Registry 首次 resolve,ensure_schema 已跑)。
manager(Task 5b)由装配线另行构造注入,不在本类持有。
"""
config: TenantConfig
db_path: pathlib.Path
seed_dir: pathlib.Path
seed_dir: pathlib.Path | None
compiled_wall: CompiledWall
prompts: PromptProvider
engine: AsyncEngine | None = None
@@ -120,9 +117,9 @@ class TenantRegistry:
"""tenant slug → TenantRuntime;域名 → (tenant_slug, site_slug) 索引。
reload 重建索引而非增量维护——租户数是个位数,整建最简且无漂移。"""
def __init__(self):
self._seed_dir: pathlib.Path | None = None
self._data_dir: pathlib.Path | None = None
def __init__(self, source: TenantSource, data_dir):
self._source = source
self._data_dir = pathlib.Path(data_dir)
self._by_slug: dict[str, TenantRuntime] = {}
self._domain_index: dict[str, tuple[str, str]] = {}
self._locks: dict[str, asyncio.Lock] = {}
@@ -131,52 +128,52 @@ class TenantRegistry:
@classmethod
def load_from_dir(cls, seed_dir, data_dir) -> "TenantRegistry":
"""seed_profiles/<slug>/tenant.json(+同目录 wall.json)→ Registry。
"""文件源便捷构造(既有调用零改动):seed_profiles/<slug>/
tenant.json + wall.json + prompts/ → Registry,同步完成。
加载期显性失败(不流入运行期):目录无任何 tenant.json(seed_dir
路径拼错→静默空注册表→全量 404,必须炸)、无效 JSON、slug 与目录
名不一致、零站点、wall 编译错误、域名重复绑定、prompts 读取失败
(Task 6: prompts/ 目录缺失或四件套不齐 → PromptProvider.from_dir
的 FileNotFoundError,加载即炸,不流入运行期)。
(prompts/ 目录缺失或四件套不齐 → read_text 的 FileNotFoundError,
加载即炸,不流入运行期)。
"""
reg = cls()
reg._seed_dir = pathlib.Path(seed_dir)
reg._data_dir = pathlib.Path(data_dir)
tenant_jsons = sorted(reg._seed_dir.glob("*/tenant.json"))
if not tenant_jsons:
raise ValueError(f"{reg._seed_dir} 下没有任何 <slug>/tenant.json")
for tenant_json in tenant_jsons:
rt = reg._load_tenant(tenant_json.parent)
if rt.config.slug in reg._by_slug:
raise ValueError(f"租户 slug 重复: {rt.config.slug}")
reg._by_slug[rt.config.slug] = rt
reg._domain_index = reg._build_domain_index(reg._by_slug)
source = FileTenantSource(seed_dir)
reg = cls(source, data_dir)
reg._install(source.read_all())
return reg
def _load_tenant(self, tenant_dir: pathlib.Path) -> TenantRuntime:
slug = tenant_dir.name
tenant_json = _read_json(tenant_dir / "tenant.json")
if tenant_json.get("slug") != slug:
raise ValueError(
f"{tenant_dir / 'tenant.json'}: slug 字段 "
f"{tenant_json.get('slug')!r} 与目录名 {slug!r} 不一致")
sites = tenant_json.get("sites") or []
if not sites:
raise ValueError(f"{slug}: 每租户至少一个站点(sites 为空)")
# wall 并入 snapshot["wall"]:data["wall"] 保留原始词表(后台编辑/
# 回滚用),编译产物挂 runtime.compiled_wall(引擎消费,单一编译)
wall_cfg = _read_json(tenant_dir / "wall.json")
snapshot = dict(tenant_json.get("config") or {})
snapshot["wall"] = wall_cfg
async def load_all(self) -> None:
"""初始异步加载(DB 源等 async 源的装配入口;文件源走
load_from_dir 同步路径,不经过这里)。空源由 source 自己显性炸。"""
self._install(await self._source.load_all())
def _install(self, loaded: list[LoadedTenant]) -> None:
"""LoadedTenant 列表 → 全量替换 _by_slug + 域名索引(整体重建,
无半更新状态;冲突在赋值前抛,旧状态原样)。"""
if not loaded:
raise ValueError(f"数据源未返回任何租户: {self._source!r}")
by_slug: dict[str, TenantRuntime] = {}
for lt in loaded:
rt = self._build_runtime(lt)
if rt.config.slug in by_slug:
raise ValueError(f"租户 slug 重复: {rt.config.slug}")
by_slug[rt.config.slug] = rt
self._by_slug = by_slug
self._domain_index = self._build_domain_index(by_slug)
def _build_runtime(self, lt: LoadedTenant) -> TenantRuntime:
"""LoadedTenant → TenantRuntime(两源同构快照的单一消费点:
from_snapshot 出厂默认合并 / compile_wall 墙编译 /
PromptProvider.from_config 四件套)。"""
config = TenantConfig.from_snapshot(
slug, tenant_json.get("name", slug), snapshot,
sites=sites, status=tenant_json.get("status", "active"))
lt.slug, lt.name, lt.snapshot,
sites=lt.sites, status=lt.status)
return TenantRuntime(
config=config,
db_path=self._data_dir / f"{slug}.db",
seed_dir=tenant_dir,
compiled_wall=compile_wall(wall_cfg),
prompts=PromptProvider.from_dir(tenant_dir / "prompts"))
db_path=self._data_dir / f"{lt.slug}.db",
seed_dir=lt.seed_dir,
compiled_wall=compile_wall(lt.snapshot.get("wall")),
prompts=PromptProvider.from_config(lt.snapshot.get("prompts")))
def _build_domain_index(
self, by_slug: dict[str, TenantRuntime]
@@ -272,23 +269,31 @@ class TenantRegistry:
return rt
async def registry_reload(self, slug: str) -> None:
"""重读磁盘,原子替换条目;旧 runtime 若已建 engine,dispose 掉。
"""经 source 重读单租户,原子替换条目;旧 runtime 若已建 engine,
dispose 掉。
磁盘读取/校验在锁外,swap+dispose 段与 _ensure_ready/dispose_all
upsert 语义(Task 4,评审修订):slug 不在 _by_slug 但 source 已知
(新建租户已落库/落盘)→ 载入插入——插入同样走 per-slug 锁 + 整建
域名索引,与替换完全同路;source 也不知道(slug 拼错/未保存)→
UnknownTenantError。
source 读取/校验在锁外,swap+dispose 段与 _ensure_ready/dispose_all
取同一把 per-slug 锁:懒建要么完整发生在替换前(dispose 得到旧
engine),要么持锁后发现条目已换而放弃——不存在"建到一半被换"
的孤儿窗口。坏配置在替换前抛,旧条目原样服务,不留半更新状态。
"""
if slug not in self._by_slug:
raise UnknownTenantError(f"租户不存在: {slug!r}")
rt = self._load_tenant(self._seed_dir / slug)
try:
loaded = await self._source.load_tenant(slug)
except LookupError as exc:
raise UnknownTenantError(f"租户不存在: {slug!r}") from exc
rt = self._build_runtime(loaded)
lock = self._locks.setdefault(slug, asyncio.Lock())
async with lock:
old = self._by_slug.get(slug)
candidate = dict(self._by_slug)
candidate[slug] = rt
new_index = self._build_domain_index(candidate) # 冲突在此抛,状态未动
self._by_slug[slug] = rt # 原子替换(锁内,无 await)
self._by_slug[slug] = rt # 原子替换/插入(锁内,无 await)
self._domain_index = new_index
if old is not None and old.engine is not None:
await old.engine.dispose()