feat: TenantRuntime/Registry——每租户懒建 engine(WAL/pool2)+文件加载器+站点解析
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,107 @@
|
||||
"""TenantRegistry 单元契约(Task 5a 文件加载器)。
|
||||
|
||||
seed_profiles/<slug>/tenant.json + 同目录 wall.json → Registry;
|
||||
Host(小写/去端口)→站点→租户;首次 resolve 懒建 engine(WAL/pool=2)+
|
||||
ensure_schema;reload 原子换条目并 dispose 旧 engine。
|
||||
|
||||
Windows 纪律:每个建过 engine 的用例结尾 await reg.dispose_all()——
|
||||
连接不关,文件锁着,tmp_path 清理 PermissionError。
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from app.platform.registry import (
|
||||
TenantRegistry,
|
||||
UnknownTenantError,
|
||||
make_tenant_engine,
|
||||
)
|
||||
|
||||
SEED_DIR = "seed_profiles"
|
||||
|
||||
|
||||
async def test_resolve_by_host(tmp_path):
|
||||
reg = TenantRegistry.load_from_dir(SEED_DIR, data_dir=tmp_path)
|
||||
rt = await reg.resolve("chat2.agent.hnxinyouhe.com")
|
||||
assert rt.config.slug == "jiancai"
|
||||
assert rt.config.data["push"]["prefix"] == "ai-kefu-jc"
|
||||
await reg.dispose_all()
|
||||
|
||||
|
||||
async def test_unknown_host_404_semantics(tmp_path):
|
||||
reg = TenantRegistry.load_from_dir(SEED_DIR, data_dir=tmp_path)
|
||||
with pytest.raises(UnknownTenantError):
|
||||
await reg.resolve("nope.example.com")
|
||||
await reg.dispose_all()
|
||||
|
||||
|
||||
async def test_tenant_db_isolated_per_slug(tmp_path):
|
||||
reg = TenantRegistry.load_from_dir(SEED_DIR, data_dir=tmp_path)
|
||||
a = await reg.resolve("chat2.agent.hnxinyouhe.com")
|
||||
b = await reg.resolve("chat.agent.hnxinyouhe.com")
|
||||
assert a.db_path != b.db_path and a.db_path.name == "jiancai.db"
|
||||
await reg.dispose_all()
|
||||
|
||||
|
||||
async def test_reload_swaps_config_atomically(tmp_path):
|
||||
reg = TenantRegistry.load_from_dir(SEED_DIR, data_dir=tmp_path)
|
||||
rt = await reg.resolve("chat2.agent.hnxinyouhe.com")
|
||||
await reg.registry_reload("jiancai") # 重新读盘
|
||||
assert await reg.resolve("chat2.agent.hnxinyouhe.com") is not rt
|
||||
assert rt.engine is None # 旧 runtime 的 engine 必须已 dispose(Windows 文件锁)
|
||||
await reg.dispose_all()
|
||||
|
||||
|
||||
async def test_site_brand_overlay_and_inheritance(tmp_path):
|
||||
reg = TenantRegistry.load_from_dir(SEED_DIR, data_dir=tmp_path)
|
||||
rt, site = await reg.resolve_site("chat2.agent.hnxinyouhe.com")
|
||||
assert site.slug == "main"
|
||||
es = rt.site_by_slug("es") # 空域名站点按 slug 取
|
||||
assert es.brand["title"] == "HESUPIPE Soporte" # 覆盖生效
|
||||
assert es.brand["color"] == rt.config.data["brand"]["color"] # 未覆盖项继承租户默认
|
||||
await reg.dispose_all()
|
||||
|
||||
|
||||
async def test_host_normalization_case_and_port(tmp_path):
|
||||
"""真实 Host 头带端口/可能大写(spec §4:Host 识别必须先归一)。"""
|
||||
reg = TenantRegistry.load_from_dir(SEED_DIR, data_dir=tmp_path)
|
||||
rt = await reg.resolve("CHAT2.Agent.hNxinyouHe.com:8018")
|
||||
assert rt.config.slug == "jiancai"
|
||||
with pytest.raises(UnknownTenantError):
|
||||
await reg.resolve("NOPE.example.com:8018")
|
||||
await reg.dispose_all()
|
||||
|
||||
|
||||
async def test_concurrent_first_resolve_builds_engine_once(tmp_path, monkeypatch):
|
||||
"""两个协程同时首 resolve 同一租户:engine 只建一次(asyncio.Lock 串行)。"""
|
||||
import app.platform.registry as registry_mod
|
||||
|
||||
calls = []
|
||||
real_make = registry_mod.make_tenant_engine
|
||||
|
||||
def counting_make(db_path):
|
||||
calls.append(db_path)
|
||||
return real_make(db_path)
|
||||
|
||||
monkeypatch.setattr(registry_mod, "make_tenant_engine", counting_make)
|
||||
|
||||
reg = TenantRegistry.load_from_dir(SEED_DIR, data_dir=tmp_path)
|
||||
rts = await asyncio.gather(
|
||||
*[reg.resolve("chat2.agent.hnxinyouhe.com") for _ in range(5)])
|
||||
assert len(calls) == 1 # 无锁则各协程各建一个,前 4 个泄漏
|
||||
assert all(rt is rts[0] for rt in rts) # 同一 runtime 实例
|
||||
assert rts[0].engine is not None and rts[0].session_factory is not None
|
||||
await reg.dispose_all()
|
||||
|
||||
|
||||
async def test_make_tenant_engine_enables_wal(tmp_path):
|
||||
"""spec §6.1 硬纪律:每租户库打开即 PRAGMA journal_mode=WAL(读写不互斥)。"""
|
||||
from sqlalchemy import text
|
||||
|
||||
engine = make_tenant_engine(tmp_path / "wal_check.db")
|
||||
try:
|
||||
async with engine.connect() as conn:
|
||||
mode = (await conn.execute(text("PRAGMA journal_mode"))).scalar()
|
||||
assert str(mode).lower() == "wal"
|
||||
finally:
|
||||
await engine.dispose()
|
||||
Reference in New Issue
Block a user