mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 04:01:58 +00:00
913 lines
45 KiB
Python
913 lines
45 KiB
Python
"""运行固定上传表格的数据版本(期 1 WP-D)。
|
||
|
||
上传源每次重传是一个新快照。运行发起时把这张图用到的上传源的当前快照记进 runs.data_versions,
|
||
之后这次运行里的一切——工具描述、db_schema、冻结的表结构、查询结果——都出自那一版:
|
||
|
||
- 运行中途重传:审批恢复、失败后继续运行,查到的仍是发起时那一版;
|
||
- 固定的快照被回收、文件被删:工具明确报错,绝不改用当前版本;
|
||
- 只固定图里(含子工作流、图级 defaults 绑的)实际用到的上传源;工具名带模板时全部固定;手工源不进来;
|
||
- 子工作流沿用父运行固定的版本;构造工具时没拿到版本(漏传)的上传源报错,不改用当前版本;
|
||
- 发起时没算到的源,第一次用到时补固定、记进运行,并在事件流里留一条日志;
|
||
- 调用工具节点遇到数据本身用不了时判失败,报错照交原因,不让人改 SQL;
|
||
- 查询快照记下 data_version(快照 id),手工源不记;工具库试用(不在运行里)用当前版本。
|
||
|
||
夹具:表格在测试里用 openpyxl 现造,标签是假名,数字手写。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import io
|
||
import json
|
||
import os
|
||
import sqlite3
|
||
import stat
|
||
import uuid
|
||
|
||
import pytest
|
||
from langchain_core.messages import AIMessage, ToolMessage
|
||
from sqlalchemy import select
|
||
|
||
from app.api.runs import missing_tools
|
||
from app.core import artifact_store
|
||
from app.core.config import settings
|
||
from app.core.events import EventType
|
||
from app.data import table_versions
|
||
from app.data.engine import engines
|
||
from app.db.base import SessionLocal
|
||
from app.db.models import DataSource, Run, RunEvent, SourceSnapshot, Workflow
|
||
from app.engine.context import RunContext
|
||
from app.engine.runner import _data_versions, run_manager
|
||
from app.engine.schema import GraphSpec
|
||
from app.tools.datasource import (
|
||
DATA_UNAVAILABLE,
|
||
LATE_PIN_CODE,
|
||
RUN_VERSIONS_KEY,
|
||
build_datasource_tools,
|
||
run_versions,
|
||
)
|
||
from app.tools.registry import ToolContext, Versions
|
||
|
||
SUM = 'SELECT SUM("数量") AS total FROM "明细"'
|
||
|
||
|
||
@pytest.fixture(autouse=True)
|
||
def store(monkeypatch, tmp_path):
|
||
data = tmp_path / "data"
|
||
(data / "uploads").mkdir(parents=True)
|
||
monkeypatch.setattr(settings, "data_dir", data)
|
||
yield data
|
||
|
||
|
||
@pytest.fixture
|
||
async def engine_up(store):
|
||
# 依赖 store:checkpoint 库跟着 data_dir 走,要先换好目录再建
|
||
await run_manager.setup()
|
||
yield
|
||
await run_manager.shutdown()
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 夹具:表格、数据源
|
||
# --------------------------------------------------------------------------
|
||
|
||
|
||
def book(sheets: dict[str, list[list]]) -> bytes:
|
||
from openpyxl import Workbook
|
||
|
||
wb = Workbook()
|
||
wb.remove(wb.active)
|
||
for title, rows in sheets.items():
|
||
ws = wb.create_sheet(title)
|
||
for row in rows:
|
||
ws.append(row)
|
||
buf = io.BytesIO()
|
||
wb.save(buf)
|
||
return buf.getvalue()
|
||
|
||
|
||
#: 第一版:一张「明细」,数量合计 3
|
||
V1 = {"明细": [["区域", "数量"], ["分区甲", 1], ["分区乙", 2]]}
|
||
#: 第二版:「明细」合计 100,另多一张「新增」。表名、数字都和第一版不同,串了版本一眼看得出
|
||
V2 = {"明细": [["区域", "数量"], ["分区甲", 60], ["分区乙", 40]], "新增": [["时段", "客流"], ["上午", 7]]}
|
||
|
||
|
||
#: 十六进制里的数字换成字母:名字里不带数字
|
||
_NO_DIGITS = str.maketrans("0123456789", "ghijklmnop")
|
||
|
||
|
||
def uniq(prefix: str = "dv") -> str:
|
||
"""一个不会重复的源名,只有字母。
|
||
|
||
报错原文里有源名(「数据源「dv_…」」),不少断言靠「第二版的 100 不在原文里」证明没有改用当前版本。十六进制名字
|
||
每 500 个左右就有一个带着「100」(dv_3a100bc2ef),那几条断言就会无缘无故地失败——这正是
|
||
test_snapshot_file_deleted_after_tools_were_built 偶尔失败的原因,和并行、时序无关。名字里不带数字,数字的断言
|
||
才只看得见数据。"""
|
||
return f"{prefix}_{uuid.uuid4().hex[:12].translate(_NO_DIGITS)}"
|
||
|
||
|
||
def test_source_names_carry_no_digits():
|
||
"""守住 uniq 的约定:名字里一个数字都没有,报错原文里出现的数字只能来自数据(见 uniq 的说明)"""
|
||
names = {uniq() for _ in range(2000)}
|
||
assert len(names) == 2000 and not any(ch.isdigit() for name in names for ch in name.removeprefix("dv_"))
|
||
|
||
|
||
async def publish(name: str, sheets: dict[str, list[list]]) -> tuple[str, str]:
|
||
"""发布一版上传表格,返回 (源 id, 快照 id)。源不存在就新建。"""
|
||
async with SessionLocal() as session:
|
||
row = (await session.execute(select(DataSource).where(DataSource.name == name))).scalar_one_or_none()
|
||
if row is None:
|
||
row = DataSource(id=uuid.uuid4().hex, name=name, kind="sqlite", readonly=True, origin="upload")
|
||
result = await table_versions.publish_upload(session, row, book(sheets), "客流.xlsx",
|
||
header_row=1, mixed="reject", raw_mode=False)
|
||
return row.id, result.snapshot_id
|
||
|
||
|
||
async def manual_source(tmp_path, total: int) -> tuple[str, str, str]:
|
||
"""一个手工登记的 SQLite 源(文件在上传目录外)。返回 (源 id, 源名, 文件路径)。"""
|
||
path = tmp_path / f"{uuid.uuid4().hex[:8]}.db"
|
||
db = sqlite3.connect(path)
|
||
db.executescript('CREATE TABLE "明细" ("区域" TEXT, "数量" INTEGER);')
|
||
db.execute('INSERT INTO "明细" VALUES (?, ?)', ("分区甲", total))
|
||
db.commit()
|
||
db.close()
|
||
name = uniq("manual")
|
||
async with SessionLocal() as session:
|
||
row = DataSource(name=name, kind="sqlite", database=str(path), readonly=True, enabled=True)
|
||
session.add(row)
|
||
await session.commit()
|
||
return row.id, name, str(path)
|
||
|
||
|
||
async def keep(source_id: str, name: str, snapshot: str) -> str:
|
||
"""登记一条引用这个快照的中断运行,让之后重传触发的回收不删它。返回运行 id。"""
|
||
async with SessionLocal() as session:
|
||
run = Run(workflow_name="保留旧版本", status="interrupted",
|
||
data_versions={source_id: {"snapshot": snapshot, "name": name}})
|
||
session.add(run)
|
||
await session.commit()
|
||
return run.id
|
||
|
||
|
||
async def tools_for(names: list[str], *, data_versions=Versions.CURRENT, run_id: str = "", emit=None) -> dict:
|
||
"""建数据源工具。缺省按不在运行里(工具库试用)建,用当前版本。"""
|
||
async with SessionLocal() as session:
|
||
built = await build_datasource_tools(
|
||
names, ToolContext(run_id=run_id, node_id="n", data_versions=data_versions, emit=emit), session)
|
||
return {t.name: t for t in built}
|
||
|
||
|
||
def total_of(text: str) -> object:
|
||
return json.loads(text)["rows"][0][0]
|
||
|
||
|
||
def pinned_of(pins: dict, sid: str) -> str | None:
|
||
return (pins.get(sid) or {}).get("snapshot")
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 跑图的工具
|
||
# --------------------------------------------------------------------------
|
||
|
||
|
||
def node(nid, ntype, **config):
|
||
return {"id": nid, "type": ntype, "position": {"x": 0, "y": 0}, "data": {"label": nid, "config": config}}
|
||
|
||
|
||
def chain(*nodes):
|
||
return {"nodes": list(nodes), "edges": [{"source": a["id"], "target": b["id"]} for a, b in zip(nodes, nodes[1:])]}
|
||
|
||
|
||
async def wait(run_id: str, statuses=("interrupted", "failed", "succeeded"), timeout=30.0) -> Run:
|
||
deadline = asyncio.get_event_loop().time() + timeout
|
||
while asyncio.get_event_loop().time() < deadline:
|
||
async with SessionLocal() as session:
|
||
row = await session.get(Run, run_id)
|
||
if row and row.status in statuses and run_manager._tasks.get(run_id) is None:
|
||
return row
|
||
await asyncio.sleep(0.05)
|
||
raise AssertionError(f"等超时了:{run_id}")
|
||
|
||
|
||
async def events_of(run_id: str, kind: str, node_id: str | None = None) -> list[RunEvent]:
|
||
async with SessionLocal() as session:
|
||
query = select(RunEvent).where(RunEvent.run_id == run_id, RunEvent.type == kind)
|
||
if node_id is not None:
|
||
query = query.where(RunEvent.node_id == node_id)
|
||
return list((await session.execute(query.order_by(RunEvent.seq))).scalars())
|
||
|
||
|
||
async def started_events(run_id: str) -> list[RunEvent]:
|
||
return await events_of(run_id, "run.started")
|
||
|
||
|
||
async def late_pin_events(run_id: str) -> list[RunEvent]:
|
||
return [e for e in await events_of(run_id, "log") if (e.data or {}).get("code") == LATE_PIN_CODE]
|
||
|
||
|
||
def loop_graph(first: str, second: str, condition: str) -> dict:
|
||
"""t1 查 first → 循环(条件执行时才报错的话停在这里)→ t2 查 second → 输出两次的合计。"""
|
||
return {
|
||
"nodes": [
|
||
node("start", "input"),
|
||
node("t1", "tool", tool=f"db_query__{first}", args={"sql": SUM}),
|
||
node("lp", "loop", mode="while", condition=condition, max_iterations=1),
|
||
node("body", "transform", mode="template", template="转一圈"),
|
||
node("t2", "tool", tool=f"db_query__{second}", args={"sql": SUM}),
|
||
node("out", "output", fields=[{"name": "t1", "value": "{{ nodes.t1 }}"},
|
||
{"name": "t2", "value": "{{ nodes.t2 }}"}]),
|
||
],
|
||
"edges": [
|
||
{"source": "start", "target": "t1"}, {"source": "t1", "target": "lp"},
|
||
{"source": "lp", "target": "body", "sourceHandle": "body"}, {"source": "body", "target": "lp"},
|
||
{"source": "lp", "target": "t2", "sourceHandle": "done"}, {"source": "t2", "target": "out"},
|
||
],
|
||
}
|
||
|
||
|
||
#: 过得了静态检查、执行时才报错的循环条件(指数超限):运行停在循环节点上,失败
|
||
FAILS_AT_RUNTIME = "2 ** 100000 > 1"
|
||
|
||
|
||
def output_total(run: Run, field: str) -> object:
|
||
value = (run.output or {})[field]
|
||
return total_of(value if isinstance(value, str) else json.dumps(value))
|
||
|
||
|
||
def scripted_agent(monkeypatch, source: str) -> dict:
|
||
"""Agent 先列表结构、再查合计、然后收尾;协作成员(只绑了查询工具)直接查合计。
|
||
|
||
记下各自看到的工具描述(按 (谁, 工具名))和工具结果(按调用 id)。
|
||
"""
|
||
from app.providers import mock_model
|
||
|
||
seen: dict = {"descriptions": {}, "results": {}}
|
||
routes = {"n": 0}
|
||
|
||
def _decide(self, messages):
|
||
if self.response_format:
|
||
if "assignments" not in json.dumps(self.response_format, ensure_ascii=False):
|
||
return AIMessage(content="{}")
|
||
# 调度者:第一轮派给成员,第二轮收工
|
||
routes["n"] += 1
|
||
plan = ({"assignments": [{"agent": "analyst", "instruction": "查合计"}]} if routes["n"] == 1
|
||
else {"assignments": [], "done": True})
|
||
return AIMessage(content=json.dumps(plan, ensure_ascii=False))
|
||
if not self.tools:
|
||
return AIMessage(content="收尾")
|
||
names = {t["name"] for t in self.tools}
|
||
who = "agent" if f"db_schema__{source}" in names else "member"
|
||
for t in self.tools:
|
||
seen["descriptions"][(who, t["name"])] = t["description"]
|
||
done = set()
|
||
for m in messages:
|
||
if isinstance(m, ToolMessage):
|
||
seen["results"][m.tool_call_id] = str(m.content)
|
||
done.add(m.tool_call_id)
|
||
plan = [(f"{who}_schema", f"db_schema__{source}", {}), (f"{who}_sum", f"db_query__{source}", {"sql": SUM})]
|
||
for call_id, name, args in plan:
|
||
if name in names and call_id not in done:
|
||
return AIMessage(content="查一下。", tool_calls=[{"name": name, "args": args, "id": call_id}])
|
||
return AIMessage(content="查完了。")
|
||
|
||
monkeypatch.setattr(mock_model.MockChatModel, "_decide", _decide)
|
||
return seen
|
||
|
||
|
||
def json_in(text: str) -> dict:
|
||
"""工具结果交给模型时前面可能标了证据编号:从第一个「{」起读。"""
|
||
return json.loads(text[text.index("{"):])
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 发起时固定哪些源
|
||
# --------------------------------------------------------------------------
|
||
|
||
|
||
async def test_start_pins_only_the_upload_sources_the_graph_uses(engine_up, monkeypatch, tmp_path):
|
||
used, unused = uniq(), uniq()
|
||
used_id, used_snap = await publish(used, V1)
|
||
unused_id, _ = await publish(unused, V1)
|
||
manual_id, manual, _ = await manual_source(tmp_path, 5)
|
||
scripted_agent(monkeypatch, used)
|
||
|
||
graph = chain(
|
||
node("start", "input"),
|
||
node("t1", "tool", tool=f"db_query__{used}", args={"sql": SUM}),
|
||
node("bot", "agent", prompt="查一下", tools=[f"db_schema__{used}", f"db_query__{manual}"], max_steps=3),
|
||
node("out", "output", fields=[{"name": "t1", "value": "{{ nodes.t1 }}"}]),
|
||
)
|
||
run = await run_manager.start(graph=graph, input_payload={})
|
||
pinned = {used_id: {"snapshot": used_snap, "name": used}}
|
||
assert run.data_versions == pinned
|
||
done = await wait(run.id, ("succeeded", "failed"))
|
||
assert done.status == "succeeded", done.error
|
||
assert done.data_versions == pinned
|
||
assert unused_id not in done.data_versions and manual_id not in done.data_versions
|
||
# run.started 记下固定的版本:封存清单里查得到这次运行用的是哪一版
|
||
assert (await started_events(run.id))[0].data["data_versions"] == pinned
|
||
|
||
|
||
async def test_subgraph_sources_are_pinned_and_templated_names_pin_every_upload_source(tmp_path):
|
||
top, inner, other, off = uniq(), uniq(), uniq(), uniq()
|
||
top_id, top_snap = await publish(top, V1)
|
||
inner_id, inner_snap = await publish(inner, V1)
|
||
other_id, _ = await publish(other, V1)
|
||
off_id, _ = await publish(off, V1)
|
||
_, manual, _ = await manual_source(tmp_path, 5)
|
||
async with SessionLocal() as session:
|
||
(await session.get(DataSource, off_id)).enabled = False
|
||
sub = Workflow(name="子流程", graph=chain(
|
||
node("s", "input"), node("q", "tool", tool=f"db_query__{inner}", args={"sql": SUM}), node("o", "output")))
|
||
session.add(sub)
|
||
await session.commit()
|
||
sub_id = sub.id
|
||
|
||
graph = chain(
|
||
node("start", "input"),
|
||
node("t", "tool", tool=f"db_query__{top}", args={"sql": SUM}),
|
||
node("sub", "subgraph", workflow_id=sub_id),
|
||
node("m", "tool", tool=f"db_query__{manual}", args={"sql": SUM}),
|
||
node("out", "output"),
|
||
)
|
||
async with SessionLocal() as session:
|
||
pinned = await _data_versions(session, GraphSpec.model_validate(graph))
|
||
# 子工作流里绑的源也固定;没用到的、手工的不固定
|
||
assert pinned == {top_id: {"snapshot": top_snap, "name": top},
|
||
inner_id: {"snapshot": inner_snap, "name": inner}}
|
||
|
||
# 工具名带模板:发起时说不准会用哪个源,所有启用着的上传源都固定上,停用的、手工的不算
|
||
graph["nodes"][1]["data"]["config"]["tool"] = "db_query__{{ vars.which }}"
|
||
async with SessionLocal() as session:
|
||
everything = await _data_versions(session, GraphSpec.model_validate(graph))
|
||
uploads = {r.id: r.current_snapshot_id for r in (await session.execute(select(DataSource).where(
|
||
DataSource.origin == "upload", DataSource.enabled.is_(True),
|
||
DataSource.current_snapshot_id.is_not(None)))).scalars()}
|
||
assert {sid: v["snapshot"] for sid, v in everything.items()} == uploads
|
||
assert {top_id, inner_id, other_id} <= set(everything) and off_id not in everything
|
||
|
||
# 一个上传源都没用到:空 dict(不是 None,None 留给升级前发起的运行)
|
||
async with SessionLocal() as session:
|
||
assert await _data_versions(session, GraphSpec.model_validate(
|
||
chain(node("start", "input"), node("out", "output")))) == {}
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 运行中途重传:恢复、继续运行都还是发起时那一版
|
||
# --------------------------------------------------------------------------
|
||
|
||
|
||
async def test_reupload_while_waiting_for_approval_does_not_change_the_run(engine_up, monkeypatch):
|
||
src = uniq()
|
||
sid, s1 = await publish(src, V1)
|
||
seen = scripted_agent(monkeypatch, src)
|
||
graph = chain(
|
||
node("start", "input"),
|
||
node("t1", "tool", tool=f"db_query__{src}", args={"sql": SUM}),
|
||
node("gate", "human", mode="approve", title="看一眼"),
|
||
node("bot", "agent", prompt="查合计", tools=[f"db_schema__{src}", f"db_query__{src}"], max_steps=4),
|
||
node("team", "supervisor", goal="查合计", max_rounds=2,
|
||
agents=[{"name": "analyst", "system": "你是分析员", "tools": [f"db_query__{src}"], "max_steps": 3}]),
|
||
node("t2", "tool", tool=f"db_query__{src}", args={"sql": SUM}),
|
||
node("out", "output", fields=[{"name": "t1", "value": "{{ nodes.t1 }}"},
|
||
{"name": "t2", "value": "{{ nodes.t2 }}"}]),
|
||
)
|
||
run = await run_manager.start(graph=graph, input_payload={})
|
||
assert (await wait(run.id)).status == "interrupted"
|
||
|
||
# 停在审批上时重传:当前版本变成 S2(合计 100,多一张「新增」)
|
||
_, s2 = await publish(src, V2)
|
||
assert s2 != s1
|
||
await run_manager.resume(run.id, {"approved": True})
|
||
done = await wait(run.id, ("succeeded", "failed"))
|
||
assert done.status == "succeeded", done.error
|
||
|
||
assert output_total(done, "t1") == 3
|
||
assert output_total(done, "t2") == 3, "恢复之后查到了重传的新版本"
|
||
# 恢复后才建的 Agent 和协作成员的工具:描述、表结构、查询都还是 S1
|
||
for who in ("agent", "member"):
|
||
desc = seen["descriptions"][(who, f"db_query__{src}")]
|
||
assert "明细" in desc and "新增" not in desc, who
|
||
assert "共 1 张表" in seen["results"]["agent_schema"] and "新增" not in seen["results"]["agent_schema"]
|
||
assert json_in(seen["results"]["agent_sum"])["rows"] == [[3]]
|
||
assert json_in(seen["results"]["member_sum"])["rows"] == [[3]]
|
||
# 运行记录和两段 run.started 记的都是 S1
|
||
assert done.data_versions == {sid: {"snapshot": s1, "name": src}}
|
||
assert [e.data["data_versions"] for e in await started_events(run.id)] == [
|
||
{sid: {"snapshot": s1, "name": src}}] * 2
|
||
|
||
|
||
async def test_continue_after_failure_keeps_the_version(engine_up):
|
||
src = uniq()
|
||
sid, s1 = await publish(src, V1)
|
||
|
||
run = await run_manager.start(graph=loop_graph(src, src, FAILS_AT_RUNTIME), input_payload={})
|
||
assert (await wait(run.id, ("failed", "succeeded"))).status == "failed"
|
||
await publish(src, V2)
|
||
|
||
await run_manager.continue_failed(run.id, graph=loop_graph(src, src, "false"))
|
||
done = await wait(run.id, ("succeeded", "failed"))
|
||
assert done.status == "succeeded", done.error
|
||
assert output_total(done, "t1") == 3
|
||
assert output_total(done, "t2") == 3, "继续运行时改用了重传的新版本"
|
||
assert [e.data["data_versions"] for e in await started_events(run.id)] == [
|
||
{sid: {"snapshot": s1, "name": src}}] * 2
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 工具层:按固定的版本生成、固定的版本没了就报错
|
||
# --------------------------------------------------------------------------
|
||
|
||
|
||
async def test_tools_follow_the_pinned_snapshot_and_record_data_version():
|
||
src = uniq()
|
||
sid, s1 = await publish(src, V1)
|
||
await keep(sid, src, s1)
|
||
_, s2 = await publish(src, V2)
|
||
pins = {sid: {"snapshot": s1, "name": src}}
|
||
try:
|
||
tools = await tools_for([f"db_query__{src}", f"db_schema__{src}"], data_versions=pins)
|
||
query, schema = tools[f"db_query__{src}"], tools[f"db_schema__{src}"]
|
||
assert "明细" in query.description and "新增" not in query.description
|
||
listed = await schema.coroutine()
|
||
assert "共 1 张表" in listed and "新增" not in listed
|
||
assert "数量" in await schema.coroutine(table="明细")
|
||
|
||
text = await query.coroutine(sql=SUM)
|
||
payload = json.loads(text)
|
||
assert total_of(text) == 3
|
||
# data_version 只进查询快照,不进交给模型的结果
|
||
assert "data_version" not in payload
|
||
assert artifact_store.load(payload["artifact"])["data_version"] == s1
|
||
# 冻结的表结构是 S1 的那份,不是数据源上现在的 S2
|
||
assert set(artifact_store.load(payload["schema_artifact"])["tables"]) == {"明细"}
|
||
|
||
# 不在运行里(工具库试用、data_versions 为 None)用当前版本
|
||
current = (await tools_for([f"db_query__{src}"]))[f"db_query__{src}"]
|
||
assert "新增" in current.description
|
||
text = await current.coroutine(sql=SUM)
|
||
assert total_of(text) == 100
|
||
assert artifact_store.load(json.loads(text)["artifact"])["data_version"] == s2
|
||
finally:
|
||
await engines.invalidate(sid)
|
||
|
||
|
||
async def test_pinned_snapshot_collected_by_gc_gives_a_clear_error_and_never_reads_current(monkeypatch):
|
||
# 期 3 起回收另外保留最近 SNAPSHOT_KEEP 个快照;调成 0(期 1 的规则),没人引用的 S1 才会在重传后被回收
|
||
monkeypatch.setattr(table_versions, "SNAPSHOT_KEEP", 0)
|
||
src = uniq()
|
||
sid, s1 = await publish(src, V1)
|
||
# 没有运行引用 S1:重传后的回收把它标成已回收、删掉文件
|
||
await publish(src, V2)
|
||
async with SessionLocal() as session:
|
||
snap = await session.get(SourceSnapshot, s1)
|
||
assert snap.retired_at is not None and not os.path.exists(snap.db_path)
|
||
try:
|
||
tools = await tools_for([f"db_query__{src}", f"db_schema__{src}"],
|
||
data_versions={sid: {"snapshot": s1, "name": src}})
|
||
query, schema = tools[f"db_query__{src}"], tools[f"db_schema__{src}"]
|
||
# 描述不列表名:表名是当前版本的
|
||
assert "明细" not in query.description and "新增" not in query.description
|
||
assert "本次运行固定的数据版本已不存在" in query.description
|
||
text = await query.coroutine(sql=SUM)
|
||
assert text.startswith(f"{DATA_UNAVAILABLE}本次运行固定的数据版本已不存在")
|
||
assert "不会改用当前版本" in text and "100" not in text
|
||
# Agent 里这句话作为工具结果交回(tool.end),运行面板按「查询失败」开头把那一步标成失败
|
||
assert text.startswith("查询失败")
|
||
listed = await schema.coroutine()
|
||
assert "本次运行固定的数据版本已不存在" in listed and "新增" not in listed
|
||
|
||
# 版本根本不属于这个源(记录被改坏、别的源的快照)也一样
|
||
other = uniq()
|
||
_, foreign = await publish(other, V2)
|
||
tools = await tools_for([f"db_query__{src}"], data_versions={sid: {"snapshot": foreign, "name": src}})
|
||
text = await tools[f"db_query__{src}"].coroutine(sql=SUM)
|
||
assert text.startswith(f"{DATA_UNAVAILABLE}本次运行固定的数据版本已不存在") and "100" not in text
|
||
finally:
|
||
await engines.invalidate(sid)
|
||
|
||
|
||
async def test_snapshot_file_deleted_after_tools_were_built():
|
||
"""工具建好之后快照文件被删:缓存的连接还开着已删的文件,也不能拿它接着查。"""
|
||
src = uniq()
|
||
sid, s1 = await publish(src, V1)
|
||
await keep(sid, src, s1)
|
||
await publish(src, V2)
|
||
try:
|
||
tools = await tools_for([f"db_query__{src}", f"db_schema__{src}"],
|
||
data_versions={sid: {"snapshot": s1, "name": src}})
|
||
query = tools[f"db_query__{src}"]
|
||
assert total_of(await query.coroutine(sql=SUM)) == 3 # 连接已建好、进了缓存
|
||
async with SessionLocal() as session:
|
||
path = (await session.get(SourceSnapshot, s1)).db_path
|
||
os.chmod(path, stat.S_IRUSR | stat.S_IWUSR)
|
||
os.unlink(path)
|
||
text = await query.coroutine(sql=SUM)
|
||
assert text.startswith(f"{DATA_UNAVAILABLE}本次运行固定的数据版本已不存在") and "100" not in text
|
||
assert (await tools[f"db_schema__{src}"].coroutine()).startswith(
|
||
f"{DATA_UNAVAILABLE}本次运行固定的数据版本已不存在")
|
||
finally:
|
||
await engines.invalidate(sid)
|
||
|
||
|
||
async def test_source_first_used_mid_run_is_pinned_and_recorded():
|
||
"""发起时没算到的源(继续运行时改绑、子工作流中途改过):第一次用到时固定,记进运行。"""
|
||
src = uniq()
|
||
sid, s1 = await publish(src, V1)
|
||
async with SessionLocal() as session:
|
||
run = Run(workflow_name="中途补固定", status="running", data_versions={})
|
||
session.add(run)
|
||
await session.commit()
|
||
run_id = run.id
|
||
pins: dict = {}
|
||
seen: list = []
|
||
emit = lambda kind, **data: seen.append((kind, data)) # noqa: E731 - 和 NodeContext.emit 一样是同步的
|
||
try:
|
||
tools = await tools_for([f"db_query__{src}"], data_versions=pins, run_id=run_id, emit=emit)
|
||
assert pins == {sid: {"snapshot": s1, "name": src, "late": True}}
|
||
async with SessionLocal() as session:
|
||
assert (await session.get(Run, run_id)).data_versions == pins
|
||
# 补固定这件事本身进事件流:当次这一段的 run.started 里没有这个源
|
||
[(kind, data)] = seen
|
||
assert kind == EventType.LOG and data["code"] == LATE_PIN_CODE
|
||
assert (data["source_id"], data["snapshot"], data["late"]) == (sid, s1, True)
|
||
assert src in data["message"]
|
||
# 之后重传:同一段执行里(同一个 dict)、恢复后(从运行记录读回)都还是 S1
|
||
await publish(src, V2)
|
||
assert total_of(await tools[f"db_query__{src}"].coroutine(sql=SUM)) == 3
|
||
again = await tools_for([f"db_query__{src}"], data_versions=pins, run_id=run_id, emit=emit)
|
||
assert len(seen) == 1, "已经固定过的源不该再发一遍"
|
||
assert total_of(await again[f"db_query__{src}"].coroutine(sql=SUM)) == 3
|
||
async with SessionLocal() as session:
|
||
stored = dict((await session.get(Run, run_id)).data_versions)
|
||
resumed = await tools_for([f"db_query__{src}"], data_versions=stored, run_id=run_id)
|
||
assert total_of(await resumed[f"db_query__{src}"].coroutine(sql=SUM)) == 3
|
||
finally:
|
||
await engines.invalidate(sid)
|
||
|
||
|
||
async def test_late_pin_takes_what_the_run_record_already_has():
|
||
"""运行记录里已经有这个源(上一段执行补固定的),内存里还没有:认运行记录的,不取当前版本。"""
|
||
src = uniq()
|
||
sid, s1 = await publish(src, V1)
|
||
async with SessionLocal() as session:
|
||
run = Run(workflow_name="已记过", status="running",
|
||
data_versions={sid: {"snapshot": s1, "name": src, "late": True}})
|
||
session.add(run)
|
||
await session.commit()
|
||
run_id = run.id
|
||
await publish(src, V2)
|
||
pins: dict = {}
|
||
seen: list = []
|
||
|
||
async def emit(kind, **data): # 协程函数也认
|
||
seen.append((kind, data))
|
||
|
||
try:
|
||
tools = await tools_for([f"db_query__{src}"], data_versions=pins, run_id=run_id, emit=emit)
|
||
assert pinned_of(pins, sid) == s1
|
||
assert seen == [], "上一段执行补固定时已经发过事件"
|
||
assert total_of(await tools[f"db_query__{src}"].coroutine(sql=SUM)) == 3
|
||
finally:
|
||
await engines.invalidate(sid)
|
||
|
||
|
||
async def test_manual_sources_are_not_versioned(tmp_path):
|
||
sid, name, path = await manual_source(tmp_path, 5)
|
||
pins: dict = {}
|
||
try:
|
||
tools = await tools_for([f"db_query__{name}"], data_versions=pins)
|
||
text = await tools[f"db_query__{name}"].coroutine(sql=SUM)
|
||
assert total_of(text) == 5
|
||
assert "data_version" not in artifact_store.load(json.loads(text)["artifact"])
|
||
assert pins == {}, "手工源不该进 data_versions"
|
||
# 手工源查的就是那个文件此刻的内容
|
||
db = sqlite3.connect(path)
|
||
db.execute('UPDATE "明细" SET "数量" = 9')
|
||
db.commit()
|
||
db.close()
|
||
assert total_of(await tools[f"db_query__{name}"].coroutine(sql=SUM)) == 9
|
||
finally:
|
||
await engines.invalidate(sid)
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 子工作流、漏传版本、调用工具节点的报错、图级 defaults、补固定的事件
|
||
# --------------------------------------------------------------------------
|
||
|
||
|
||
async def test_subgraph_resumed_after_approval_reads_the_pinned_version(engine_up):
|
||
"""子工作流的工具经它自己的 RunContext 拿版本:父运行停在审批时重传,恢复后子工作流查的仍是 S1。"""
|
||
src = uniq()
|
||
sid, s1 = await publish(src, V1)
|
||
async with SessionLocal() as session:
|
||
sub = Workflow(name="子流程取数", graph=chain(
|
||
node("s", "input"),
|
||
node("q", "tool", tool=f"db_query__{src}", args={"sql": SUM}),
|
||
node("o", "output", fields=[{"name": "t", "value": "{{ nodes.q }}"}])))
|
||
session.add(sub)
|
||
await session.commit()
|
||
sub_id = sub.id
|
||
graph = chain(
|
||
node("start", "input"),
|
||
node("gate", "human", mode="approve", title="看一眼"),
|
||
node("sub", "subgraph", workflow_id=sub_id),
|
||
node("out", "output", fields=[{"name": "sub", "value": "{{ nodes.sub.output.t }}"}]),
|
||
)
|
||
try:
|
||
run = await run_manager.start(graph=graph, input_payload={})
|
||
assert run.data_versions == {sid: {"snapshot": s1, "name": src}}
|
||
assert (await wait(run.id)).status == "interrupted"
|
||
|
||
_, s2 = await publish(src, V2)
|
||
assert s2 != s1
|
||
await run_manager.resume(run.id, {"approved": True})
|
||
done = await wait(run.id, ("succeeded", "failed"))
|
||
assert done.status == "succeeded", done.error
|
||
assert output_total(done, "sub") == 3, "子工作流恢复后查到了重传的新版本"
|
||
payload = json.loads((done.output or {})["sub"])
|
||
assert artifact_store.load(payload["artifact"])["data_version"] == s1
|
||
# 拿到的是发起时固定的那份,不是第一次用到时才补的
|
||
assert done.data_versions == {sid: {"snapshot": s1, "name": src}}
|
||
assert await late_pin_events(run.id) == []
|
||
finally:
|
||
await engines.invalidate(sid)
|
||
|
||
|
||
async def test_tools_built_without_versions_refuse_upload_sources(tmp_path):
|
||
"""构造 ToolContext 时没给版本(在运行里漏传了):上传源报错,不当成「不在运行里」改用当前版本。"""
|
||
src = uniq()
|
||
sid, _ = await publish(src, V1)
|
||
spec = GraphSpec.model_validate(chain(node("start", "input"), node("out", "output")))
|
||
# 运行上下文里没有这个键(不是 runner 建的,或往下传时丢了):UNSET,不是 None
|
||
assert run_versions(RunContext(run_id="r", thread_id="r", spec=spec)) is Versions.UNSET
|
||
assert run_versions(RunContext(run_id="r", thread_id="r", spec=spec, extra={RUN_VERSIONS_KEY: None})) is None
|
||
pins: dict = {}
|
||
assert run_versions(RunContext(run_id="r", thread_id="r", spec=spec, extra={RUN_VERSIONS_KEY: pins})) is pins
|
||
assert ToolContext().data_versions is Versions.UNSET
|
||
|
||
manual_id, manual, _ = await manual_source(tmp_path, 5)
|
||
try:
|
||
async with SessionLocal() as session:
|
||
built = {t.name: t for t in await build_datasource_tools(
|
||
[f"db_query__{src}", f"db_schema__{src}", f"db_query__{manual}"],
|
||
ToolContext(run_id="r", node_id="n"), session)}
|
||
query, schema = built[f"db_query__{src}"], built[f"db_schema__{src}"]
|
||
assert "没有拿到本次运行固定的数据版本" in query.description and "明细" not in query.description
|
||
text = await query.coroutine(sql=SUM)
|
||
assert text.startswith(f"{DATA_UNAVAILABLE}没有拿到本次运行固定的数据版本") and "rows" not in text
|
||
assert (await schema.coroutine()).startswith(f"{DATA_UNAVAILABLE}没有拿到本次运行固定的数据版本")
|
||
# 手工源不分版本,照常查
|
||
assert total_of(await built[f"db_query__{manual}"].coroutine(sql=SUM)) == 5
|
||
finally:
|
||
await engines.invalidate(sid)
|
||
await engines.invalidate(manual_id)
|
||
|
||
|
||
@pytest.mark.parametrize("prefix", ["db_query__", "db_schema__"])
|
||
async def test_tool_node_fails_with_the_reason_when_its_pinned_snapshot_is_gone(engine_up, prefix):
|
||
"""停在审批时重传、再删掉 S1 的文件:恢复后调用工具节点判失败,报错照交原因,不让人改 SQL。"""
|
||
src = uniq()
|
||
sid, s1 = await publish(src, V1)
|
||
tool = f"{prefix}{src}"
|
||
graph = chain(
|
||
node("start", "input"),
|
||
node("gate", "human", mode="approve", title="看一眼"),
|
||
node("t", "tool", tool=tool, args={"sql": SUM} if prefix == "db_query__" else {}),
|
||
node("out", "output", fields=[{"name": "t", "value": "{{ nodes.t }}"}]),
|
||
)
|
||
try:
|
||
run = await run_manager.start(graph=graph, input_payload={})
|
||
assert (await wait(run.id)).status == "interrupted"
|
||
await publish(src, V2) # 这次运行引用着 S1,回收不删它
|
||
async with SessionLocal() as session:
|
||
path = (await session.get(SourceSnapshot, s1)).db_path
|
||
os.chmod(path, stat.S_IRUSR | stat.S_IWUSR)
|
||
os.unlink(path)
|
||
|
||
await run_manager.resume(run.id, {"approved": True})
|
||
done = await wait(run.id, ("succeeded", "failed"))
|
||
assert done.status == "failed"
|
||
error = done.error or ""
|
||
assert f"工具 {tool} 无法使用:本次运行固定的数据版本已不存在(数据源「{src}」)" in error, error
|
||
assert "请重新发起运行" in error
|
||
# 改 SQL 没用,不能这么指;原话的开头也不能和外面那层叠成两遍
|
||
assert "SQL" not in error and "查询失败" not in error and error.count(DATA_UNAVAILABLE) == 0, error
|
||
[err] = await events_of(run.id, "tool.error", "t")
|
||
assert err.data["error"].startswith(f"{DATA_UNAVAILABLE}本次运行固定的数据版本已不存在")
|
||
assert not await events_of(run.id, "tool.end", "t")
|
||
finally:
|
||
await engines.invalidate(sid)
|
||
|
||
|
||
async def test_tools_bound_through_graph_defaults_are_pinned_at_start():
|
||
"""Agent 节点没写 tools、协作节点没写成员时运行时取图级 defaults,发起时固定版本也按它算。"""
|
||
agent_src, member_src = uniq(), uniq()
|
||
agent_id, agent_snap = await publish(agent_src, V1)
|
||
member_id, member_snap = await publish(member_src, V1)
|
||
graph = chain(node("start", "input"), node("bot", "agent", prompt="查一下"),
|
||
node("team", "supervisor", goal="查合计"), node("out", "output"))
|
||
graph["defaults"] = {"tools": [f"db_query__{agent_src}"],
|
||
"agents": [{"name": "analyst", "tools": [f"db_schema__{member_src}"]}]}
|
||
async with SessionLocal() as session:
|
||
assert await _data_versions(session, GraphSpec.model_validate(graph)) == {
|
||
agent_id: {"snapshot": agent_snap, "name": agent_src},
|
||
member_id: {"snapshot": member_snap, "name": member_src}}
|
||
|
||
# 发起前检查是同一个取法:defaults 里绑的工具不存在,发起前就拦下
|
||
ghost = uniq("ghost")
|
||
broken = {**graph, "defaults": {"tools": [f"db_query__{ghost}"]}}
|
||
missing = await missing_tools(session, GraphSpec.model_validate(broken))
|
||
assert [(name, where) for _, name, where in missing] == [(f"db_query__{ghost}", "data")]
|
||
|
||
# 节点上写了(哪怕是空列表)就不看 defaults:和 NodeContext.cfg 一样
|
||
graph["nodes"][1]["data"]["config"]["tools"] = []
|
||
assert set(await _data_versions(session, GraphSpec.model_validate(graph))) == {member_id}
|
||
|
||
|
||
async def test_source_first_used_after_continue_is_pinned_and_leaves_an_event(engine_up):
|
||
"""继续运行时改绑了发起时没用到的源:第一次用到时补固定,记进运行,事件流里留一条日志。"""
|
||
first, later = uniq(), uniq()
|
||
first_id, first_snap = await publish(first, V1)
|
||
later_id, later_snap = await publish(later, V1)
|
||
try:
|
||
run = await run_manager.start(graph=loop_graph(first, first, FAILS_AT_RUNTIME), input_payload={})
|
||
failed = await wait(run.id, ("failed", "succeeded"))
|
||
assert failed.status == "failed"
|
||
assert failed.data_versions == {first_id: {"snapshot": first_snap, "name": first}}
|
||
|
||
await run_manager.continue_failed(run.id, graph=loop_graph(first, later, "false"))
|
||
done = await wait(run.id, ("succeeded", "failed"))
|
||
assert done.status == "succeeded", done.error
|
||
assert done.data_versions[later_id] == {"snapshot": later_snap, "name": later, "late": True}
|
||
# 这一段的 run.started 里没有它(发起这一段时还没用到):补固定的那条日志是事件流里唯一的记录
|
||
assert later_id not in (await started_events(run.id))[-1].data["data_versions"]
|
||
[event] = await late_pin_events(run.id)
|
||
assert event.node_id == "t2"
|
||
assert (event.data["source_id"], event.data["snapshot"], event.data["late"]) == (later_id, later_snap, True)
|
||
finally:
|
||
await engines.invalidate(first_id)
|
||
await engines.invalidate(later_id)
|
||
|
||
|
||
async def test_continue_after_the_pinned_source_is_deleted_and_recreated_under_the_same_name(engine_up):
|
||
"""固定的源被删、又用同一个名字新建:续跑报错,不改查同名的新源,也不把新源当成中途新建的补固定。"""
|
||
from httpx import ASGITransport, AsyncClient
|
||
|
||
from app.main import app
|
||
|
||
src = uniq()
|
||
a_id, s1 = await publish(src, V1)
|
||
b_id = None
|
||
try:
|
||
run = await run_manager.start(graph=loop_graph(src, src, FAILS_AT_RUNTIME), input_payload={})
|
||
failed = await wait(run.id, ("failed", "succeeded"))
|
||
assert failed.status == "failed"
|
||
assert failed.data_versions == {a_id: {"snapshot": s1, "name": src}}
|
||
|
||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://t") as c:
|
||
assert (await c.delete(f"/api/datasources/{a_id}")).status_code == 204
|
||
b_id, _ = await publish(src, V2)
|
||
assert b_id != a_id
|
||
|
||
await run_manager.continue_failed(run.id, graph=loop_graph(src, src, "false"))
|
||
done = await wait(run.id, ("succeeded", "failed"))
|
||
assert done.status == "failed", f"续跑改查了同名重建的新源:{done.output}"
|
||
error = done.error or ""
|
||
assert f"本次运行固定的数据源「{src}」已被删除或重建,不会改用同名的新数据源" in error, error
|
||
assert "SQL" not in error and "100" not in error
|
||
# 新源没有被补固定进运行记录,也没有补固定的日志
|
||
assert set(done.data_versions) == {a_id}
|
||
assert not await late_pin_events(run.id)
|
||
finally:
|
||
await engines.invalidate(a_id)
|
||
if b_id:
|
||
await engines.invalidate(b_id)
|
||
|
||
|
||
async def test_same_name_manual_source_does_not_stand_in_for_a_pinned_upload_source(tmp_path):
|
||
"""同名重建成手工源也拦住:手工源不分版本,但运行固定的是另一个源,不能拿它顶上。"""
|
||
_, manual, _ = await manual_source(tmp_path, 5)
|
||
pins = {"gone-source-id": {"snapshot": "0" * 64, "name": manual}}
|
||
tools = await tools_for([f"db_query__{manual}", f"db_schema__{manual}"], data_versions=pins, run_id="r")
|
||
text = await tools[f"db_query__{manual}"].coroutine(sql=SUM)
|
||
assert text.startswith(f"{DATA_UNAVAILABLE}本次运行固定的数据源「{manual}」已被删除或重建"), text
|
||
assert "5" not in text.replace(manual, "")
|
||
assert (await tools[f"db_schema__{manual}"].coroutine()).startswith(DATA_UNAVAILABLE)
|
||
# 运行里没固定过这个名字的手工源照常查
|
||
tools = await tools_for([f"db_query__{manual}"], data_versions={}, run_id="r")
|
||
assert total_of(await tools[f"db_query__{manual}"].coroutine(sql=SUM)) == 5
|
||
|
||
|
||
def _tamper(path: str) -> None:
|
||
"""改掉库文件末尾一个字节(发布后是 0444,先放开写)。"""
|
||
os.chmod(path, stat.S_IRUSR | stat.S_IWUSR)
|
||
with open(path, "r+b") as f:
|
||
f.seek(-1, os.SEEK_END)
|
||
last = f.read(1)
|
||
f.seek(-1, os.SEEK_END)
|
||
f.write(bytes([last[0] ^ 0xFF]))
|
||
|
||
|
||
async def test_tampered_snapshot_is_reported_as_data_unavailable_not_as_a_sql_error(engine_up):
|
||
"""快照文件被改过(哈希对不上):工具按「数据不可用」交回,调用工具节点不让人去改 SQL。"""
|
||
src = uniq()
|
||
sid, s1 = await publish(src, V1)
|
||
graph = chain(node("start", "input"), node("t", "tool", tool=f"db_query__{src}", args={"sql": SUM}),
|
||
node("out", "output", fields=[{"name": "t", "value": "{{ nodes.t }}"}]))
|
||
try:
|
||
async with SessionLocal() as session:
|
||
path = (await session.get(SourceSnapshot, s1)).db_path
|
||
await engines.invalidate(sid) # 下次查询新建引擎,建之前核对哈希
|
||
_tamper(path)
|
||
|
||
for pins in (Versions.CURRENT, {sid: {"snapshot": s1, "name": src}}):
|
||
tools = await tools_for([f"db_query__{src}"], data_versions=pins, run_id="r")
|
||
text = await tools[f"db_query__{src}"].coroutine(sql=SUM)
|
||
assert text.startswith(f"{DATA_UNAVAILABLE}数据源「{src}」的数据文件与登记的版本不一致"), text
|
||
assert "SQL" not in text and "请联系管理员" in text
|
||
|
||
run = await run_manager.start(graph=graph, input_payload={})
|
||
done = await wait(run.id, ("succeeded", "failed"))
|
||
assert done.status == "failed"
|
||
error = done.error or ""
|
||
assert error.startswith(f"工具 db_query__{src} 无法使用:数据源「{src}」的数据文件与登记的版本不一致"), error
|
||
assert "SQL" not in error and "查询失败" not in error, error
|
||
finally:
|
||
await engines.invalidate(sid)
|
||
|
||
|
||
async def test_reupload_while_a_run_is_being_started_does_not_collect_its_snapshot(engine_up, monkeypatch):
|
||
"""发起运行读完指针、运行记录还没提交时有人重传:发布后的回收看不到这条运行,以前会把刚固定的快照删掉,
|
||
这次运行只能报「固定的数据版本已不存在」。现在读指针到提交持着版本存储的锁,回收等它提交之后才开始。"""
|
||
from app.engine import runner
|
||
|
||
src = uniq()
|
||
sid, s1 = await publish(src, V1)
|
||
real = runner._data_versions
|
||
pending: list[asyncio.Task] = []
|
||
|
||
async def racing(session, spec):
|
||
pins = await real(session, spec)
|
||
# 读完指针的这一刻有人重传。给它足够的时间做完(没有锁的话它会做完,连同回收)
|
||
task = asyncio.create_task(publish(src, V2))
|
||
pending.append(task)
|
||
await asyncio.wait({task}, timeout=0.5)
|
||
return pins
|
||
|
||
monkeypatch.setattr(runner, "_data_versions", racing)
|
||
graph = chain(node("start", "input"), node("t", "tool", tool=f"db_query__{src}", args={"sql": SUM}),
|
||
node("out", "output", fields=[{"name": "t", "value": "{{ nodes.t }}"}]))
|
||
try:
|
||
run = await run_manager.start(graph=graph, input_payload={})
|
||
_, s2 = await pending[0]
|
||
assert run.data_versions == {sid: {"snapshot": s1, "name": src}} and s2 != s1
|
||
async with SessionLocal() as session:
|
||
snap = await session.get(SourceSnapshot, s1)
|
||
assert snap.retired_at is None and os.path.exists(snap.db_path), "回收删掉了刚固定的快照"
|
||
done = await wait(run.id, ("succeeded", "failed"))
|
||
assert done.status == "succeeded", done.error
|
||
assert output_total(done, "t") == 3
|
||
finally:
|
||
await engines.invalidate(sid)
|
||
|
||
|
||
async def test_late_pin_reads_the_pointer_again_under_the_lock(monkeypatch):
|
||
"""补固定时数据源记录是建工具前读的:那之后重传过、旧快照已回收,不能拿旧指针去固定。
|
||
在版本存储的锁里重新读指针,固定的是此刻的当前版本,而且回收不会在写进运行记录之前删掉它。"""
|
||
from app.tools import datasource
|
||
|
||
src = uniq()
|
||
sid, s1 = await publish(src, V1)
|
||
async with SessionLocal() as session:
|
||
run = Run(workflow_name="补固定撞上重传", status="running", data_versions={})
|
||
session.add(run)
|
||
await session.commit()
|
||
run_id = run.id
|
||
real = datasource._late_lock
|
||
reuploaded: list[str] = []
|
||
|
||
class Racing:
|
||
"""进补固定的锁之前,先让一次重传整个做完(发布、切指针、回收掉没人引用的 S1)。"""
|
||
|
||
async def __aenter__(self):
|
||
reuploaded.append((await publish(src, V2))[1])
|
||
self.lock = real()
|
||
await self.lock.acquire()
|
||
|
||
async def __aexit__(self, *exc):
|
||
self.lock.release()
|
||
|
||
monkeypatch.setattr(datasource, "_late_lock", lambda: Racing())
|
||
pins: dict = {}
|
||
try:
|
||
tools = await tools_for([f"db_query__{src}"], data_versions=pins, run_id=run_id)
|
||
[s2] = reuploaded
|
||
assert pinned_of(pins, sid) == s2, "补固定用了建工具前读到的旧指针"
|
||
text = await tools[f"db_query__{src}"].coroutine(sql=SUM)
|
||
assert not text.startswith(DATA_UNAVAILABLE), text
|
||
assert total_of(text) == 100
|
||
async with SessionLocal() as session:
|
||
assert pinned_of((await session.get(Run, run_id)).data_versions, sid) == s2
|
||
finally:
|
||
await engines.invalidate(sid)
|