fix: Registry 审查修复(JSON定位/校验测试/竞态窗口/TenantDisabledError/路径锚定/seed_dir)

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-10-12 18:54:30 +08:00
parent 607baff83c
commit 0c296ca1ba
3 changed files with 332 additions and 58 deletions
+108 -53
View File
@@ -7,12 +7,16 @@ platform.db 时本模块的对外接口不变(spec §5.3:后台保存→原子
关键行为:
- resolve_site(host):host 小写去端口 → 域名索引 → (TenantRuntime, Site);
未知 Host → UnknownTenantError(spec §4:404,不做兜底租户)。
未知 Host → UnknownTenantError(spec §4:404,不做兜底租户);租户停用 →
TenantDisabledError(Task 7 中间件映射为 503 中性"服务暂停"页,非 404)。
resolve(host) 只是它的便捷形式——双 API 一层实现,防两套解析漂移。
- 懒建:首次 resolve 才建 engine(WAL/pool=2)+ session_factory + ensure_schema,
per-slug asyncio.Lock 串行,双检防并发双建;建库失败不留半开 engine。
- registry_reload(slug):重读磁盘→校验(坏 JSON/墙/域名冲突全在替换前抛,
旧条目不动)→原子替换 _by_slug 条目并重建域名索引→dispose 旧 engine
per-slug asyncio.Lock 串行,锁内双检防并发双建;持锁后现势检查——条目
已被 reload 换掉则放弃本次建库、按当前条目重走(旧 runtime 永不产生
孤儿 engine);建库失败 dispose 半开 engine,保持未就绪下次重试。
- registry_reload(slug):磁盘读取/校验在锁外,swap+dispose 段与懒建/
dispose_all 取同一把 per-slug 锁。坏配置(无效 JSON/墙编译失败/零站点/
域名冲突)在替换前抛,旧条目原样服务;替换后 dispose 旧 engine
(Windows 下不关=库文件句柄泄漏)。
测试/teardown 纪律:凡建过 engine 的 Registry 用完必须 await dispose_all()
@@ -40,6 +44,24 @@ class UnknownTenantError(LookupError):
"""Host/域名无法映射到任何租户(调用方语义:聊天/公开端点 404)。"""
class TenantDisabledError(LookupError):
"""租户 status != "active"(调用方语义:503 中性"服务暂停"页,非 404;
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
@@ -67,12 +89,18 @@ def _normalize_host(host: str) -> str:
@dataclass
class TenantRuntime:
"""单租户运行时。config/db_path/compiled_wall 加载期就绪;engine/
session_factory 由 Registry 首次 resolve 懒建;manager 由装配线注入
(Task 5b),prompts 由 Task 6 注入——本任务不含。"""
"""单租户运行时。
加载期就绪:config(TenantConfig:data=快照⊕出厂默认,含 sites)/
db_path(该租户 SQLite 文件路径)/seed_dir(seed_profiles/<slug>/,
Task 6 PromptProvider 从中读 prompts/*.md)/compiled_wall(墙编译产物)。
懒建:engine/session_factory(Registry 首次 resolve,ensure_schema 已跑)。
manager(Task 5b)由装配线另行构造注入,不在本类持有。
"""
config: TenantConfig
db_path: pathlib.Path
seed_dir: pathlib.Path
compiled_wall: CompiledWall
engine: AsyncEngine | None = None
session_factory: async_sessionmaker | None = None
@@ -103,8 +131,8 @@ class TenantRegistry:
"""seed_profiles/<slug>/tenant.json(+同目录 wall.json)→ Registry。
加载期显性失败(不流入运行期):目录无任何 tenant.json(seed_dir
路径拼错→静默空注册表→全量 404,必须炸)、slug 与目录名不一致、
零站点、wall 编译错误、域名重复绑定。
路径拼错→静默空注册表→全量 404,必须炸)、无效 JSON、slug 与目录
名不一致、零站点、wall 编译错误、域名重复绑定。
"""
reg = cls()
reg._seed_dir = pathlib.Path(seed_dir)
@@ -122,8 +150,7 @@ class TenantRegistry:
def _load_tenant(self, tenant_dir: pathlib.Path) -> TenantRuntime:
slug = tenant_dir.name
tenant_json = json.loads(
(tenant_dir / "tenant.json").read_text(encoding="utf-8"))
tenant_json = _read_json(tenant_dir / "tenant.json")
if tenant_json.get("slug") != slug:
raise ValueError(
f"{tenant_dir / 'tenant.json'}: slug 字段 "
@@ -133,8 +160,7 @@ class TenantRegistry:
raise ValueError(f"{slug}: 每租户至少一个站点(sites 为空)")
# wall 并入 snapshot["wall"]:data["wall"] 保留原始词表(后台编辑/
# 回滚用),编译产物挂 runtime.compiled_wall(引擎消费,单一编译)
wall_cfg = json.loads(
(tenant_dir / "wall.json").read_text(encoding="utf-8"))
wall_cfg = _read_json(tenant_dir / "wall.json")
snapshot = dict(tenant_json.get("config") or {})
snapshot["wall"] = wall_cfg
config = TenantConfig.from_snapshot(
@@ -143,6 +169,7 @@ class TenantRegistry:
return TenantRuntime(
config=config,
db_path=self._data_dir / f"{slug}.db",
seed_dir=tenant_dir,
compiled_wall=compile_wall(wall_cfg))
def _build_domain_index(
@@ -155,27 +182,34 @@ class TenantRegistry:
key = _normalize_host(domain)
if not key:
continue
if index.get(key, (slug, site.slug)) != (slug, site.slug):
if key in index and index[key] != (slug, site.slug):
raise ValueError(
f"域名 {key} 同时绑定 {index[key][0]} 与 "
f"{slug} 的站点(域名不可重复绑定)")
f"域名 {key} 已绑定 (tenant={index[key][0]}, "
f"site={index[key][1]}),不能同时绑定 "
f"(tenant={slug}, site={site.slug})")
index[key] = (slug, site.slug)
return index
# ---- 解析(对外 API)--------------------------------------------------
async def resolve_site(self, host: str) -> tuple[TenantRuntime, Site]:
"""host → (TenantRuntime, Site)。首次 resolve 懒建该租户 engine。"""
"""host → (TenantRuntime, Site)。未知 Host → UnknownTenantError;
租户停用 → TenantDisabledError;首次 resolve 懒建该租户 engine。
返回的 runtime 以 _ensure_ready 的返回值为准(等锁期间条目可能
已被 reload 换成新配置)。"""
mapped = self._domain_index.get(_normalize_host(host))
if mapped is None:
raise UnknownTenantError(f"未知 Host: {host!r}")
tenant_slug, site_slug = mapped
rt = self._by_slug[tenant_slug]
await self._ensure_ready(rt)
if rt.config.status != "active":
raise TenantDisabledError(f"租户已停用: {tenant_slug!r}")
rt = await self._ensure_ready(rt)
site = rt.site_by_slug(site_slug)
if site is None: # 防御:索引与配置同源重建,正常不可达
if site is None: # reload 竞态:域名命中后站点被换掉,视同未知
raise UnknownTenantError(
f"站点 {site_slug!r} 不存在于租户 {tenant_slug!r}")
f"站点 {site_slug!r} 不存在于租户 {tenant_slug!r}"
f"(可能已被 reload 移除)")
return rt, site
async def resolve(self, host: str) -> TenantRuntime:
@@ -187,50 +221,71 @@ class TenantRegistry:
async def _ensure_ready(self, rt: TenantRuntime) -> TenantRuntime:
"""懒建 engine + session_factory + ensure_schema,per-slug Lock 串行。
双检:抢到锁后重看 engine——等锁期间前一个协程可能已建好。
建库失败 dispose 半开 engine 并保持未就绪,下次 resolve 重试。
持锁后现势检查:_by_slug 条目已被 registry_reload 换掉 → 放弃本次
建库(否则旧 runtime 会产出无人 dispose 的孤儿 engine),按当前
条目重走;双检:等锁期间前一个协程可能已建好。建库失败 dispose
半开 engine 并保持未就绪,下次 resolve 重试。
"""
if rt.engine is not None:
return rt
lock = self._locks.setdefault(rt.config.slug, asyncio.Lock())
async with lock:
slug = rt.config.slug
while True:
if rt.engine is not None:
return rt
self._data_dir.mkdir(parents=True, exist_ok=True)
engine = make_tenant_engine(rt.db_path)
try:
await ensure_schema(engine)
except Exception:
await engine.dispose()
raise
rt.engine = engine
rt.session_factory = async_sessionmaker(
engine, class_=AsyncSession, expire_on_commit=False)
return rt
lock = self._locks.setdefault(slug, asyncio.Lock())
async with lock:
current = self._by_slug.get(slug)
if current is None:
raise UnknownTenantError(f"租户已不存在: {slug!r}")
if current is not rt:
rt = current # 条目已被 reload 换掉,按新条目重走
continue
if rt.engine is not None: # 双检:等锁期间已建好
return rt
self._data_dir.mkdir(parents=True, exist_ok=True)
engine = make_tenant_engine(rt.db_path)
try:
await ensure_schema(engine)
except Exception:
await engine.dispose()
raise
rt.engine = engine
rt.session_factory = async_sessionmaker(
engine, class_=AsyncSession, expire_on_commit=False)
return rt
async def registry_reload(self, slug: str) -> None:
"""重读磁盘,原子替换条目;旧 runtime 若已建 engine,dispose 掉。
先全量校验(重读 tenant.json/wall.json + 候选域名索引)再替换——
坏配置抛异常时旧条目原样服务,不留半更新状态。
磁盘读取/校验在锁外,swap+dispose 段与 _ensure_ready/dispose_all
取同一把 per-slug 锁:懒建要么完整发生在替换前(dispose 得到旧
engine),要么持锁后发现条目已换而放弃——不存在"建到一半被换"
的孤儿窗口。坏配置在替换前抛,旧条目原样服务,不留半更新状态。
"""
if slug not in self._by_slug:
raise UnknownTenantError(f"租户不存在: {slug!r}")
old = self._by_slug[slug]
rt = self._load_tenant(self._seed_dir / slug)
candidate = dict(self._by_slug)
candidate[slug] = rt
new_index = self._build_domain_index(candidate)
self._by_slug[slug] = rt # 原子替换(单事件循环内无 await)
self._domain_index = new_index
if old.engine is not None:
await old.engine.dispose()
old.engine = old.session_factory = None
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._domain_index = new_index
if old is not None and old.engine is not None:
await old.engine.dispose()
old.engine = old.session_factory = None
async def dispose_all(self) -> None:
"""关闭全部 engine 并复位为未就绪(测试 teardown 用——Windows 文件锁;
复位后若再 resolve 会重新懒建,可安全复用)。"""
for rt in self._by_slug.values():
if rt.engine is not None:
await rt.engine.dispose()
rt.engine = rt.session_factory = None
复位后若再 resolve 会重新懒建,可安全复用)。
与懒建/reload 取同一把 per-slug 锁:dispose 期间该租户不会有并发
建库(否则新建的 engine 会被本次复位漏掉或反过来被关)。迭代前
快照 _locks——迭代中并发 resolve 可能 setdefault 新锁。
"""
for slug, lock in list(self._locks.items()):
async with lock:
rt = self._by_slug.get(slug)
if rt is not None and rt.engine is not None:
await rt.engine.dispose()
rt.engine = rt.session_factory = None