Files
AgentLab/backend/tests/test_engine_cache.py
T
ThearchyHelios 8059cd5147 Excel 导入改造期 1:止血与版本底座——写错的列名不再静默得 0,导入不再悄悄读错,每次上传生成不可变的版本,运行固定发起时的数据版本
连接层
- 所有 SQLite 数据源关闭双引号字符串兼容(DQS),写错的双引号列名直接报错;设置失败就拒绝连接,启动时自检
- 连接缓存按数据源加版本快照分键、按源整体失效、最多 32 个;版本快照以 immutable 只读打开,建连接前核对文件哈希,并在借出连接时核对文件指纹,被改过就拒绝查询
- 结构探查遇到取不了列的视图时逐个对象重取,只跳过坏的那个并注明原因

解析(新 xlsx_scan + tabular)
- 先流式扫描真实边界和版式(不信 dimension),再按边界读值,不整表进内存;按 Excel 自身上限设硬上限,并发解析最多两个
- 隐藏和深度隐藏的工作表不导入;稀疏区域、无缓存值的公式、同名工作表、DTD 拒收
- 去掉空行和两侧空列;列名表名统一规范化(新 names 模块)并保留原表头,撞名不再互相覆盖
- 前导零、超长数字按文本;千分位按数字;日期不带 T;带单位的数字文本写进回执
- 数字列混入非数字、交叉表和多块结构必须由用户决定(422),可选存空值或按原样导入(未规整);STRICT 建表,整份在一个事务里写完

版本底座(新 table_versions、raw_store)
- 每次上传写新库文件、落盘、0444,事务里写构建、导入记录、快照后才切指针;失败时旧版完整可查
- 原件按哈希存档,不提供下载,可以清除;上传源不许改连接字段、不再重新探查;手工 SQLite 源不许指向上传目录和应用自己的数据文件(按文件身份判断)
- 启动时清理半成品与孤儿文件、给老上传补初始版本;被运行引用的版本不回收,发起运行与回收互斥
- 上传接口的解析移到线程池

运行固定数据版本
- 发起时把图里(含子工作流)用到的上传源的当前快照记进运行记录和 run.started,续跑、审批恢复沿用;数据源工具按固定的快照生成描述、表结构并查询
- 固定的版本没了、源被删除后同名重建,都明确报错,不改用当前版本或同名的新源;查询快照记 data_version

证据层
- 新报告记 entity_syntax: 2,[[t:明细]]、[[c:明细.金额]] 和 SQL 里不加引号的中文表名都认得,名字按 SQLite 的口径查找;老报告按原规则复核,结果不变
- 写作规则改为中文表名、字段名一律用 [[t:]] / [[c:]];未规整的表在工具描述、写作目录和裁判摘录里标出

前端
- 上传弹窗接上导入前的决定(混合列、交叉表)和导入回执(跳过的隐藏表、类型转换、警告、原表头对照、未规整标记),上传前告知原件会保存
- 数据源卡片按 origin 认上传的表格,显示当前版本;上传的表格不再给「探查结构」
2026-10-01 13:42:04 +08:00

509 lines
21 KiB
Python

"""连接缓存按「数据源 + 版本快照」分键;版本快照以 immutable 只读打开,建连接前核对哈希。
上传表格每次发布都是一个新文件,同一个数据源的新旧版本得各用各的连接池,否则钉住
旧版本的运行会查到新文件。快照用一个带 snapshot_id / immutable / expected_sha256 的
简单对象模拟(table_versions.SourceView 的形状),不依赖版本底座的实现。
"""
from __future__ import annotations
import asyncio
import hashlib
import os
import shutil
import sqlite3
import threading
import uuid
from types import SimpleNamespace
from urllib.parse import quote
import pytest
from sqlalchemy import text
from sqlalchemy.exc import OperationalError
from sqlalchemy.ext.asyncio import AsyncEngine
from app.data import engine as engine_mod
from app.data.engine import (
MAX_ENGINES, EngineCache, SnapshotTampered, cache_key, engine_args, engines, run_query,
)
def _db(path, rows=(("分区甲", 120),)) -> str:
conn = sqlite3.connect(path)
conn.execute("CREATE TABLE 时段客流 (分区 TEXT, 客流 INTEGER)")
conn.executemany("INSERT INTO 时段客流 VALUES (?, ?)", list(rows))
conn.commit()
conn.close()
return str(path)
def _sha(path: str) -> str:
with open(path, "rb") as f:
return hashlib.sha256(f.read()).hexdigest()
def _source(path: str, *, sid: str | None = None, readonly: bool = True, **extra):
"""手工源的形状;extra 里给 snapshot_id / immutable / expected_sha256 就是快照视图的形状。"""
return SimpleNamespace(
id=sid or f"src-{uuid.uuid4().hex[:8]}", kind="sqlite", database=path, readonly=readonly,
name="snapshot_probe", options={}, password=None, username=None, host=None, port=None,
description=None, schema_cache=None, enabled=True, **extra,
)
#: _snapshot 的 expected 不传时:文件在就按它现在的内容登记哈希(版本快照一定带哈希,见 S2-7 的用例)
_AUTO = object()
def _snapshot(path: str, *, sid: str, snap: str, expected: object = _AUTO, readonly: bool = True):
if expected is _AUTO:
expected = _sha(path) if os.path.isfile(path) else None
return _source(path, sid=sid, readonly=readonly, snapshot_id=snap, immutable=True,
expected_sha256=expected)
async def _total(engine: AsyncEngine) -> int:
async with engine.connect() as conn:
return (await conn.execute(text("SELECT SUM(客流) FROM 时段客流"))).scalar_one()
@pytest.fixture
async def cache():
c = EngineCache()
yield c
await c.close()
@pytest.fixture
def disposed(monkeypatch):
"""记下哪些 engine 被 dispose 过。"""
seen: list[int] = []
original = AsyncEngine.dispose
async def spy(self, close: bool = True):
seen.append(id(self))
return await original(self, close)
monkeypatch.setattr(AsyncEngine, "dispose", spy)
return seen
# ---------------------------------------------------------------------------
# 缓存键
# ---------------------------------------------------------------------------
def test_cache_key_carries_the_snapshot():
assert cache_key(SimpleNamespace(id="s1")) == "s1:"
assert cache_key(SimpleNamespace(id="s1", snapshot_id=None)) == "s1:"
assert cache_key(SimpleNamespace(id="s1", snapshot_id="")) == "s1:"
assert cache_key(SimpleNamespace(id="s1", snapshot_id="ab12")) == "s1:ab12"
async def test_two_snapshots_of_one_source_get_separate_engines(tmp_path, cache):
"""同一个数据源的两个版本:两个 engine,各查各的文件。"""
old = _db(tmp_path / "v1.db", [("分区甲", 100)])
new = _db(tmp_path / "v2.db", [("分区甲", 100), ("分区乙", 50)])
v1 = _snapshot(old, sid="s1", snap="a" * 64)
v2 = _snapshot(new, sid="s1", snap="b" * 64)
e1, e2 = await cache.get(v1), await cache.get(v2)
assert e1 is not e2
assert (await _total(e1), await _total(e2)) == (100, 150)
# 再取还是同一个
assert await cache.get(v1) is e1
assert set(cache._engines) == {f"s1:{'a' * 64}", f"s1:{'b' * 64}"}
async def test_invalidate_clears_every_version_of_that_source_only(tmp_path, cache, disposed):
path = _db(tmp_path / "x.db")
plain = _source(path, sid="s1")
v1 = _snapshot(path, sid="s1", snap="a" * 64)
v2 = _snapshot(path, sid="s1", snap="b" * 64)
neighbour = _source(path, sid="s10") # 前缀相同的另一个源
neighbour_v = _snapshot(path, sid="s10", snap="a" * 64)
doomed = [await cache.get(s) for s in (plain, v1, v2)]
kept = [await cache.get(s) for s in (neighbour, neighbour_v)]
await cache.invalidate("s1")
assert set(cache._engines) == {"s10:", f"s10:{'a' * 64}"}
assert sorted(disposed) == sorted(id(e) for e in doomed)
assert [await cache.get(s) for s in (neighbour, neighbour_v)] == kept
# 失效之后再取,新建一个
assert await cache.get(v1) is not doomed[1]
async def test_lru_evicts_the_least_recently_used(tmp_path, disposed):
cache = EngineCache(max_engines=3)
try:
path = _db(tmp_path / "x.db")
a, b, c, d = (_source(path, sid=n) for n in "abcd")
ea, eb, ec = await cache.get(a), await cache.get(b), await cache.get(c)
assert await cache.get(a) is ea # a 刚用过,最久没用的是 b
ed = await cache.get(d)
assert list(cache._engines) == ["c:", "a:", "d:"]
assert disposed == [id(eb)]
assert await _total(ea) == 120 and await _total(ed) == 120
# b 被关掉了,再取是新建的
assert await cache.get(b) is not eb
assert len(cache._engines) == 3
finally:
await cache.close()
async def test_default_limit_follows_the_module_constant(tmp_path, monkeypatch):
assert MAX_ENGINES == 32
monkeypatch.setattr(engine_mod, "MAX_ENGINES", 2)
cache = EngineCache()
try:
path = _db(tmp_path / "x.db")
for n in "abc":
await cache.get(_source(path, sid=n))
assert list(cache._engines) == ["b:", "c:"]
finally:
await cache.close()
async def test_global_cache_keeps_versions_apart_for_run_query(tmp_path):
"""经 run_query(全局缓存)查:钉住旧版本的查询拿到旧数,当前版本拿到新数。"""
old = _db(tmp_path / "v1.db", [("分区甲", 100)])
new = _db(tmp_path / "v2.db", [("分区甲", 100), ("分区乙", 50)])
sid = f"g-{uuid.uuid4().hex[:8]}"
v1 = _snapshot(old, sid=sid, snap="1" * 64, expected=_sha(old))
v2 = _snapshot(new, sid=sid, snap="2" * 64, expected=_sha(new))
try:
assert (await run_query(v1, "SELECT SUM(客流) FROM 时段客流")).rows == [[100]]
assert (await run_query(v2, "SELECT SUM(客流) FROM 时段客流")).rows == [[150]]
assert (await run_query(v1, "SELECT SUM(客流) FROM 时段客流")).rows == [[100]]
finally:
await engines.invalidate(sid)
assert not any(k.startswith(f"{sid}:") for k in engines._engines)
# ---------------------------------------------------------------------------
# 不可变快照的打开方式
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("readonly", [True, False], ids=["readonly=True", "readonly=False"])
def test_immutable_snapshot_url_ignores_readonly_field(tmp_path, readonly):
path = str(tmp_path / "分区 快照#1.db")
url, extra = engine_args(_snapshot(path, sid="s1", snap="a" * 64, readonly=readonly))
assert url == f"sqlite+aiosqlite:///file:{quote(path)}?mode=ro&immutable=1&uri=true"
assert extra["connect_args"]["factory"] is engine_mod._DqsOffConnection
def test_immutable_snapshot_needs_a_file():
with pytest.raises(ValueError, match="数据文件"):
engine_args(_snapshot("", sid="s1", snap="a" * 64))
with pytest.raises(ValueError, match="数据文件"):
engine_args(_snapshot(":memory:", sid="s1", snap="a" * 64))
async def test_immutable_snapshot_reads_and_refuses_writes(tmp_path, cache):
"""immutable 打开:查询照常,写由 SQLite 拒绝(标着可写也一样),双引号列名写错照样报错。"""
path = _db(tmp_path / "分区 快照#1.db")
engine = await cache.get(_snapshot(path, sid="s1", snap="a" * 64, readonly=False))
assert await _total(engine) == 120
async with engine.connect() as conn:
with pytest.raises(OperationalError, match="readonly"):
await conn.execute(text("DELETE FROM 时段客流"))
with pytest.raises(OperationalError, match="no such column"):
await conn.execute(text('SELECT SUM("客六") FROM 时段客流'))
assert await _total(engine) == 120
async def test_immutable_snapshot_ignores_locks(tmp_path, cache):
"""immutable=1 的效果:不加锁、不看别的连接的锁。普通只读连接在排它锁下读不了,快照连接能读。
这条钉住 URL 里的 immutable=1 确实传到了 SQLite,而不只是写在字符串里。
"""
path = _db(tmp_path / "locked.db")
holder = sqlite3.connect(path, isolation_level=None)
holder.execute("PRAGMA journal_mode=DELETE")
holder.execute("BEGIN EXCLUSIVE")
try:
plain = sqlite3.connect(f"file:{quote(path)}?mode=ro", uri=True, timeout=0)
try:
with pytest.raises(sqlite3.OperationalError, match="locked"):
plain.execute("SELECT COUNT(*) FROM 时段客流").fetchall()
finally:
plain.close()
engine = await cache.get(_snapshot(path, sid="s1", snap="a" * 64))
assert await _total(engine) == 120
finally:
holder.execute("ROLLBACK")
holder.close()
# ---------------------------------------------------------------------------
# 哈希核对
# ---------------------------------------------------------------------------
async def test_matching_hash_opens(tmp_path, cache):
path = _db(tmp_path / "ok.db")
engine = await cache.get(_snapshot(path, sid="s1", snap="a" * 64, expected=_sha(path).upper()))
assert await _total(engine) == 120
async def test_hash_mismatch_is_refused_and_not_cached(tmp_path, cache):
path = _db(tmp_path / "tampered.db")
view = _snapshot(path, sid="s1", snap="a" * 64, expected="0" * 64)
with pytest.raises(SnapshotTampered) as info:
await cache.get(view)
assert str(info.value) == "数据文件与登记的版本不一致,可能被修改过,已拒绝查询"
assert isinstance(info.value, ValueError)
assert cache._engines == {}
# 没缓存:每次都核对、每次都拒绝
with pytest.raises(SnapshotTampered):
await cache.get(view)
assert cache._engines == {}
async def test_missing_snapshot_file_is_refused(tmp_path, cache):
view = _snapshot(str(tmp_path / "gone.db"), sid="s1", snap="a" * 64, expected="0" * 64)
with pytest.raises(SnapshotTampered, match="无法读取"):
await cache.get(view)
assert cache._engines == {}
async def test_run_query_surfaces_the_refusal(tmp_path):
path = _db(tmp_path / "tampered.db")
sid = f"g-{uuid.uuid4().hex[:8]}"
view = _snapshot(path, sid=sid, snap="a" * 64, expected="0" * 64)
with pytest.raises(SnapshotTampered, match="已拒绝查询"):
await run_query(view, "SELECT COUNT(*) FROM 时段客流")
assert not any(k.startswith(f"{sid}:") for k in engines._engines)
async def test_hash_is_checked_before_each_new_engine(tmp_path, cache):
"""核对发生在这个键新建 engine 之前;失效(或被 LRU 关掉)之后重建,会重新核对。"""
path = _db(tmp_path / "snap.db")
view = _snapshot(path, sid="s1", snap="a" * 64, expected=_sha(path))
engine = await cache.get(view)
assert await cache.get(view) is engine
await cache.invalidate("s1")
assert await cache.get(view) is not engine
def _replace(path: str, rows) -> None:
"""另造一个库,整份 rename 覆盖到 path 上(恢复备份、同步工具都是这么换文件的)。0444 挡不住。"""
other = _db(f"{path}.new", rows)
os.chmod(path, 0o444)
os.replace(other, path)
async def test_in_place_rewrite_after_caching_is_refused(tmp_path, cache):
"""引擎缓存着、文件被原地改写:缓存命中时指纹对不上,摘掉引擎、重新核对哈希,拒绝。"""
path = _db(tmp_path / "snap.db")
view = _snapshot(path, sid="s1", snap="a" * 64)
await cache.get(view)
conn = sqlite3.connect(path)
conn.execute("INSERT INTO 时段客流 VALUES ('分区乙', 1)")
conn.commit()
conn.close()
with pytest.raises(SnapshotTampered, match="可能被修改过"):
await cache.get(view)
assert cache._engines == {} and cache._pins == {}
async def test_replaced_file_is_refused_for_new_pool_connections(tmp_path, cache):
"""核对通过、引擎进了缓存之后文件被整份替换:已开的连接还读着核对过的那份(旧 inode),
逼连接池新开的连接、以及再次取引擎,都必须被拒,不能静默读到替换后的文件。"""
path = _db(tmp_path / "snap.db", [("分区甲", 1)])
view = _snapshot(path, sid="s1", snap="a" * 64)
engine = await cache.get(view)
async with engine.connect() as held:
assert (await held.execute(text("SELECT SUM(客流) FROM 时段客流"))).scalar_one() == 1
_replace(path, [("分区甲", 424242)])
# 占着一条,连接池只能新开一条:按路径打开的是替换后的文件,必须被拒
with pytest.raises(SnapshotTampered, match="替换或改动"):
async with engine.connect() as fresh:
await fresh.execute(text("SELECT SUM(客流) FROM 时段客流"))
assert (await held.execute(text("SELECT SUM(客流) FROM 时段客流"))).scalar_one() == 1
# 还回来的旧连接再借出去也不行:这个引擎已经不可信
with pytest.raises(SnapshotTampered):
async with engine.connect() as again:
await again.execute(text("SELECT 1"))
# 再取引擎:摘掉旧的、重新核对哈希,内容变了就拒绝
with pytest.raises(SnapshotTampered, match="可能被修改过"):
await cache.get(view)
assert cache._engines == {}
async def test_replacement_between_get_and_connect_is_refused(tmp_path, cache):
"""取到引擎之后、第一次连接之前文件被换掉(核对完到建出连接之间的窗口):第一条连接就被拒。"""
path = _db(tmp_path / "snap.db", [("分区甲", 1)])
view = _snapshot(path, sid="s1", snap="a" * 64)
engine = await cache.get(view)
_replace(path, [("分区甲", 424242)])
with pytest.raises(SnapshotTampered):
async with engine.connect() as conn:
await conn.execute(text("SELECT SUM(客流) FROM 时段客流"))
async def test_same_content_restored_under_a_new_inode_is_accepted(tmp_path, cache):
"""内容没变、只是换了 inode(从备份恢复回同一个文件):重新核对哈希通过,用新指纹重建,照常查询。"""
path = _db(tmp_path / "snap.db", [("分区甲", 7)])
view = _snapshot(path, sid="s1", snap="a" * 64)
engine = await cache.get(view)
shutil.copy2(path, f"{path}.bak")
os.replace(f"{path}.bak", path)
fresh = await cache.get(view)
assert fresh is not engine
assert await _total(fresh) == 7
assert await cache.get(view) is fresh
async def test_run_query_refuses_a_replaced_snapshot(tmp_path):
"""经 run_query(全局缓存):第一次查询通过,文件被替换后再查被拒,不会读到替换后的数。"""
path = _db(tmp_path / "snap.db", [("分区甲", 1)])
sid = f"g-{uuid.uuid4().hex[:8]}"
view = _snapshot(path, sid=sid, snap="a" * 64)
try:
assert (await run_query(view, "SELECT SUM(客流) FROM 时段客流")).rows == [[1]]
_replace(path, [("分区甲", 424242)])
with pytest.raises(SnapshotTampered):
await run_query(view, "SELECT SUM(客流) FROM 时段客流")
finally:
await engines.invalidate(sid)
async def test_hash_is_computed_off_the_event_loop(tmp_path, cache, monkeypatch):
"""大文件哈希要几秒,不能在事件循环线程里算。"""
path = _db(tmp_path / "snap.db")
threads: list[int] = []
original = engine_mod._file_sha256
def recording(p: str) -> str:
threads.append(threading.get_ident())
return original(p)
monkeypatch.setattr(engine_mod, "_file_sha256", recording)
await cache.get(_snapshot(path, sid="s1", snap="a" * 64, expected=_sha(path)))
assert threads and threads[0] != threading.get_ident()
async def test_sources_without_expected_hash_are_not_hashed(tmp_path, cache, monkeypatch):
"""手工源:不读整个文件。"""
path = _db(tmp_path / "plain.db")
def boom(p: str) -> str:
raise AssertionError("不该计算哈希")
monkeypatch.setattr(engine_mod, "_file_sha256", boom)
await cache.get(_source(path, sid="s1"))
assert len(cache._engines) == 1
@pytest.mark.parametrize("expected", [None, "", " "], ids=["None", "empty", "blank"])
async def test_snapshot_without_a_registered_hash_is_refused(tmp_path, cache, monkeypatch, expected):
"""版本快照(immutable)却没给期望哈希:拒绝,不退回「不核对照样打开」。哪条新路径漏传哈希都会被测出来。"""
path = _db(tmp_path / "plain.db")
def boom(p: str) -> str:
raise AssertionError("不该计算哈希")
monkeypatch.setattr(engine_mod, "_file_sha256", boom)
with pytest.raises(SnapshotTampered, match="缺少文件哈希"):
await cache.get(_snapshot(path, sid="s1", snap="a" * 64, expected=expected))
assert cache._engines == {}
@pytest.fixture
def hashing(monkeypatch):
"""数一数算了几次哈希;gate 关着时哈希卡在线程里,用来摆出「核对进行中」的局面。"""
state = SimpleNamespace(calls=[], gate=threading.Event(), started=threading.Event())
state.gate.set()
original = engine_mod._file_sha256
def counting(p: str) -> str:
state.calls.append(p)
state.started.set()
assert state.gate.wait(timeout=10), "测试没有放行哈希"
return original(p)
monkeypatch.setattr(engine_mod, "_file_sha256", counting)
yield state
state.gate.set() # 测试中途失败也别让线程一直卡着
async def _until(flag: threading.Event) -> None:
for _ in range(500):
if flag.is_set():
return
await asyncio.sleep(0.01)
raise AssertionError("等不到哈希开始")
async def test_concurrent_first_use_builds_one_engine(tmp_path, cache, hashing):
"""一次运行开头的几次工具调用同时查同一个快照:只建一个 engine,也只读一遍文件。"""
path = _db(tmp_path / "snap.db")
view = _snapshot(path, sid="s1", snap="a" * 64, expected=_sha(path))
got = await asyncio.gather(*(cache.get(view) for _ in range(5)))
assert len({id(e) for e in got}) == 1
assert len(cache._engines) == 1
assert len(hashing.calls) == 1
assert cache._verifying == {}
async def test_concurrent_mismatch_is_shared_but_not_kept(tmp_path, cache, hashing):
"""并发的几个请求共用一次失败的核对,都被拒;失败不留,下一次照常重新核对。"""
path = _db(tmp_path / "tampered.db")
view = _snapshot(path, sid="s1", snap="a" * 64, expected="0" * 64)
got = await asyncio.gather(*(cache.get(view) for _ in range(5)), return_exceptions=True)
assert all(isinstance(e, SnapshotTampered) for e in got)
assert len(hashing.calls) == 1
assert cache._verifying == {} and cache._engines == {}
with pytest.raises(SnapshotTampered):
await cache.get(view)
assert len(hashing.calls) == 2
async def test_a_cancelled_request_does_not_cancel_the_shared_check(tmp_path, cache, hashing):
path = _db(tmp_path / "snap.db")
view = _snapshot(path, sid="s1", snap="a" * 64, expected=_sha(path))
hashing.gate.clear()
first = asyncio.create_task(cache.get(view))
await _until(hashing.started)
second = asyncio.create_task(cache.get(view))
await asyncio.sleep(0)
first.cancel()
hashing.gate.set()
engine = await second
assert first.cancelled()
assert await _total(engine) == 120
assert len(hashing.calls) == 1
async def test_different_registered_hash_is_checked_separately(tmp_path, cache, hashing):
"""同一个键却登记了不同的哈希(不该出现):各核各的,错的那个不能借对的那个的结论放行。"""
path = _db(tmp_path / "snap.db")
good = _snapshot(path, sid="s1", snap="a" * 64, expected=_sha(path))
bad = _snapshot(path, sid="s1", snap="a" * 64, expected="0" * 64)
hashing.gate.clear()
tasks = [asyncio.create_task(cache.get(v)) for v in (good, bad)]
await _until(hashing.started)
hashing.gate.set()
ok, refused = await asyncio.gather(*tasks, return_exceptions=True)
assert isinstance(ok, AsyncEngine)
assert isinstance(refused, SnapshotTampered)
assert len(hashing.calls) == 2
async def test_requests_after_invalidate_check_again(tmp_path, cache, hashing):
"""核对进行中数据源被失效:之后来的请求重新核对,不搭失效之前那一次的便车。"""
path = _db(tmp_path / "snap.db")
view = _snapshot(path, sid="s1", snap="a" * 64, expected=_sha(path))
hashing.gate.clear()
before = asyncio.create_task(cache.get(view))
await _until(hashing.started)
await cache.invalidate("s1")
assert cache._verifying == {}
after = asyncio.create_task(cache.get(view))
await asyncio.sleep(0.05)
hashing.gate.set()
await asyncio.gather(before, after)
assert len(hashing.calls) == 2