Files
AgentLab/backend/tests/test_run_races.py
ThearchyHelios 2e7d6103ea 应用库可移植到 PostgreSQL:AGENTLAB_DATABASE_URL 设成 postgresql+asyncpg://… 时整个应用库用 PostgreSQL,断点用官方的 langgraph-checkpoint-postgres(同一个库,AGENTLAB_CHECKPOINT_URL 可另给);不设时照旧是数据目录里的 SQLite,行为不变
- 配置:database_url、checkpoint_url(启动时校验写法,只认 PostgreSQL)、连接池四项;启动日志写用的是哪种库(主机、库名、用户,不写密码)
- 引擎(db/base.make_engine):SQLite 照旧 WAL、外键、忙等;PostgreSQL 用连接池、探活、会话时区 UTC,装上方言调整(db/postgres.py):建表时 String(n) 是不限长的 VARCHAR(SQLite 从不查长度,导过来的存量也可能超长)、ORDER BY 里可空列的 NULL 位置照 SQLite 补写(升序在前、降序在后,窗口函数里同样);Integer 列在 PostgreSQL 上是 BIGINT(列挂到表上时换成 with_variant,绑定参数跟着列按 BIGINT 转换);JSON 列两边都存原文(PostgreSQL 的 json,不用 JSONB:JSONB 重排对象里的键,快照的列序、表的先后读回来就变了);JSON 里的 NaN 写成 null、NUL 去掉(PostgreSQL 不收)
- 迁移按方言:已有的列和索引用 inspector 认;PostgreSQL 上补列的类型取模型里这一列的、缺省值取迁移表里的(布尔 0/1 换成 false/true),ADD COLUMN IF NOT EXISTS;去重语句里的布尔按方言写;部分唯一索引补 postgresql_where;init_db 在 PostgreSQL 上拿咨询锁(几个进程同时启动不撞)
- 应用库上的 SQLite 专用写法改成按方言编译的表达式(db/portable.py):json_extract 的十几处(口径召回、出具、对账、个人应用、幂等键、访问控制、数据源变化通知、剖析任务、待办超时)换成 json_eq / json_flag / json_text,json_type 换成 json_is_string(两种库同一套带类型的比较);能力登记的 sqlite insert 换成按会话方言的 insert_ignore;能力使用次数的 json_each 按方言分支;附件「没绑记录」的条件不靠 json 的等号;按报错文字认唯一约束的地方换成 unique_violation(按表和列认,两种库的报错都认得)
- 断点(engine/checkpoints.py):PostgreSQL 时开 psycopg 连接池、在咨询锁里 setup;runner 只认 BaseCheckpointSaver,读断点仍只走 saver 的公开接口
- 依赖:新 extra postgres(asyncpg、psycopg[binary] 3、psycopg-pool、langgraph-checkpoint-postgres 3.1.2);langgraph-checkpoint 卡在 <4.3(断点格式就在这个包里,postgres 包新版要 4.3,不动已装的 4.2.0);锁文件补上新包
- 运行收尾:记 action.unknown、安排核实做完才把运行摘掉(以前先摘掉,等「不在跑了」再读事件的一方在库慢时读不到这一条)
- 测试:AGENTLAB_TEST_DATABASE_URL 时跑在 PostgreSQL 上(每个 xdist 进程建自己的库、按事件循环分的连接池 tests/pgpool.py),不设时盖掉环境里的数据库地址照旧用 SQLite;scripts/test-postgres.sh 起临时的 postgres:16 容器跑全量;sqlite_only 标记;另开连接改库的几个竞态测试、触发器测试改成两种库都能跑(tests/appdb.py);新增 test_db_portable 守着方言调整、JSON 键序、超长字符串、大整数、NULL 排序、时区、PostgreSQL 老库迁移
2026-10-19 09:52:09 +08:00

285 lines
12 KiB
Python

"""运行收尾和几条并发路径上的竞态。
终态事件和运行记录要同时可见。两边都有人在读:
- 收到 run.finished 的客户端马上 GET 这次运行取完整成果(事件里的 output 是截断的);
以前先落事件、隔一个事务才提交状态,每次都读到 running 和空的 output,问数据页
于是把截断过的成果标成残缺存进会话;
- 读到 failed 的一方马上去翻事件,要翻得到 run.failed。更早以前是先提交状态,这一边
偶尔翻不到。
两边都要成立,只能放进同一个事务。
恢复 / 接着跑和「放弃这次运行」抢同一次运行:读到 interrupted 之后隔了好几次 await
才写 running,放弃正好在这中间提交的话,一次已经取消、封存、发过 run.cancelled 的
运行会被改回 running,又拉起来跑。
刚停到审批上、还在落 interrupted 状态的那一刻点「放弃」:以前直接取消执行任务,把
收尾从中间打断——状态停在 running、审批还挂着、任务没了,再点一次只剩 409。
"""
from __future__ import annotations
import asyncio
import pytest
from sqlalchemy import select
from app.core.bus import bus
from app.db.base import SessionLocal
from app.db.models import Approval, Run, RunEvent
from app.engine import compiler, runner
from app.engine.context import NodeError
from app.engine.runner import RunManager, run_manager
from app.engine.schema import NodeType
@pytest.fixture(autouse=True)
async def engine_up():
await run_manager.setup()
yield
await run_manager.shutdown()
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:])]}
@pytest.fixture
def slow(monkeypatch):
"""transform 节点慢一点,好让订阅先接上;id 为 boom 的直接失败,id 为 stuck 的一直卡着。"""
original = compiler.RUNNERS[NodeType.TRANSFORM]
async def run(state, ctx):
await asyncio.sleep(30 if ctx.node.id == "stuck" else 0.15)
if ctx.node.id == "boom":
raise NodeError(ctx.node.id, "炸了")
return await original(state, ctx)
monkeypatch.setitem(compiler.RUNNERS, NodeType.TRANSFORM, run)
def _graph(work_id: str = "work", *, gate: bool = False) -> dict:
nodes = [node("start", "input", fields=[{"name": "q"}]),
node(work_id, "transform", mode="template", template="成果全文")]
if gate:
nodes.append(node("gate", "human", mode="approve", title="放行吗"))
nodes.append(node("out", "output", fields=[{"name": "r", "value": f"{{{{ nodes.{work_id} }}}}"}]))
return chain(*nodes)
async def _status(run_id: str) -> str:
async with SessionLocal() as session:
return (await session.get(Run, run_id)).status
async def _until(check, timeout: float = 15.0) -> None:
deadline = asyncio.get_event_loop().time() + timeout
while asyncio.get_event_loop().time() < deadline:
if await check():
return
await asyncio.sleep(0.02)
raise AssertionError("等超时了")
async def _events(run_id: str) -> list[RunEvent]:
async with SessionLocal() as session:
return list((await session.execute(
select(RunEvent).where(RunEvent.run_id == run_id).order_by(RunEvent.seq)
)).scalars())
async def _row_when(run_id: str, terminal: str, act=None) -> Run:
"""订阅这次运行,一收到 terminal 事件就读运行记录——客户端就是这么做的。"""
sub = bus.attach(run_id)
acting = asyncio.ensure_future(act()) if act is not None else None
try:
async for event in sub.stream():
if str(event.type) == terminal:
async with SessionLocal() as session:
return await session.get(Run, run_id)
finally:
sub.close()
if acting is not None:
await acting
raise AssertionError(f"没等到 {terminal}")
# --------------------------------------------------------------------------
# 终态事件和运行记录同时可见
# --------------------------------------------------------------------------
async def test_the_run_is_final_when_run_finished_arrives(slow):
for _ in range(5):
run = await run_manager.start(graph=_graph(), input_payload={"q": "x"})
row = await _row_when(run.id, "run.finished")
assert row.status == "succeeded", f"收到 run.finished 时运行还是 {row.status}"
assert row.output == {"r": "成果全文"}, row.output
assert row.finished_at is not None and row.usage.get("wall_ms") is not None
await run_manager.wait_idle(run.id)
async def test_the_run_is_final_when_run_failed_arrives(slow):
for _ in range(5):
run = await run_manager.start(graph=_graph("boom"), input_payload={"q": "x"})
row = await _row_when(run.id, "run.failed")
assert row.status == "failed" and "炸了" in (row.error or ""), (row.status, row.error)
assert row.error_node_id == "boom"
await run_manager.wait_idle(run.id)
async def test_the_terminal_event_is_there_once_the_status_is(slow):
"""反方向:读到终态状态时,终态事件一定已经在库里。"""
for work_id, terminal in (("work", "run.finished"), ("boom", "run.failed")):
for _ in range(3):
run = await run_manager.start(graph=_graph(work_id), input_payload={"q": "x"})
async def settled(run_id=run.id) -> bool:
return await _status(run_id) in ("succeeded", "failed")
await _until(settled)
assert (await _events(run.id))[-1].type == terminal
await run_manager.wait_idle(run.id)
async def test_the_run_is_final_when_a_stop_arrives(slow):
run = await run_manager.start(graph=_graph("stuck"), input_payload={"q": "x"})
await _until(lambda: _started(run.id, "stuck"))
row = await _row_when(run.id, "run.cancelled", act=lambda: run_manager.cancel(run.id))
assert row.status == "cancelled" and row.finished_at is not None
async def test_the_run_is_final_when_an_abandon_arrives(slow):
run = await run_manager.start(graph=_graph(gate=True), input_payload={"q": "x"})
await _until(lambda: _is(run.id, "interrupted"))
await run_manager.wait_idle(run.id)
row = await _row_when(run.id, "run.cancelled", act=lambda: run_manager.cancel(run.id))
assert row.status == "cancelled" and row.finished_at is not None
assert row.last_seq == (await _events(run.id))[-1].seq
async def _started(run_id: str, node_id: str) -> bool:
return any(e.type == "node.started" and e.node_id == node_id for e in await _events(run_id))
async def _is(run_id: str, status: str) -> bool:
return await _status(run_id) == status
# --------------------------------------------------------------------------
# 恢复 / 接着跑 和 放弃 抢同一次运行
# --------------------------------------------------------------------------
async def _suspended_run() -> str:
"""服务重启挂起的运行:interrupted,没有待审批,断点完好。"""
run = await run_manager.start(graph=_graph("stuck"), input_payload={"q": "x"})
await _until(lambda: _started(run.id, "stuck"))
await run_manager.shutdown()
await run_manager.setup()
assert await _status(run.id) == "interrupted"
return run.id
def _abandon_in_between(monkeypatch) -> None:
"""在「读到 interrupted」和「写 running」之间,放弃这次运行的请求先提交了。"""
async def racing(run_id, spec, snapshot):
assert await run_manager._abandon(run_id, "另一个人")
return None
monkeypatch.setattr(runner, "_stale_replay", racing)
@pytest.mark.parametrize("path", ["resume", "continue"])
async def test_a_run_abandoned_mid_way_is_not_revived(slow, monkeypatch, path):
run_id = await _suspended_run()
_abandon_in_between(monkeypatch)
with pytest.raises(ValueError, match="已取消"):
if path == "resume":
await run_manager.resume(run_id, None)
else:
await run_manager.continue_failed(run_id)
assert not run_manager.is_active(run_id), "取消了的运行又被拉起来跑了"
assert await _status(run_id) == "cancelled"
events = await _events(run_id)
assert events[-1].type == "run.cancelled" and not [e for e in events if e.type == "run.resumed"]
async def test_an_approval_closed_by_an_abandon_is_not_answered_again(slow, monkeypatch):
run = await run_manager.start(graph=_graph(gate=True), input_payload={"q": "x"})
await _until(lambda: _is(run.id, "interrupted"))
await run_manager.wait_idle(run.id)
_abandon_in_between(monkeypatch)
with pytest.raises(ValueError):
await run_manager.resume(run.id, {"approved": True})
async with SessionLocal() as session:
approval = (await session.execute(
select(Approval).where(Approval.run_id == run.id))).scalar_one()
assert approval.status == "cancelled", approval.status
assert await _status(run.id) == "cancelled" and not run_manager.is_active(run.id)
# --------------------------------------------------------------------------
# 刚停到审批上、还在落状态时点「放弃」
# --------------------------------------------------------------------------
async def test_abandoning_while_the_pause_is_being_recorded(slow, monkeypatch):
original = RunManager._finalize
recording = asyncio.Event()
async def slow_finalize(self, run_id, status, *args, **kwargs):
if status == "interrupted":
recording.set()
await asyncio.sleep(0.4) # 落 interrupted 状态的这一段被拉长
return await original(self, run_id, status, *args, **kwargs)
monkeypatch.setattr(RunManager, "_finalize", slow_finalize)
run = await run_manager.start(graph=_graph(gate=True), input_payload={"q": "x"})
await asyncio.wait_for(recording.wait(), 10)
assert run.id in run_manager._finalizing
assert await run_manager.cancel(run.id, actor="审核员") == "cancelled"
assert await _status(run.id) == "cancelled"
async with SessionLocal() as session:
approval = (await session.execute(
select(Approval).where(Approval.run_id == run.id))).scalar_one()
assert approval.status == "cancelled" and approval.resolved_by == "审核员"
assert (await _events(run.id))[-1].type == "run.cancelled"
async def test_an_approval_closed_just_before_it_is_marked_answered(slow, monkeypatch):
"""放弃恰好提交在「读到这条审批还是 pending」和「把它记成已回复」之间。"""
from sqlalchemy import event
from app.db.base import engine
from tests import appdb
run = await run_manager.start(graph=_graph(gate=True), input_payload={"q": "x"})
await _until(lambda: _is(run.id, "interrupted"))
await run_manager.wait_idle(run.id)
armed = {"on": True}
def abandon_first(conn, cursor, statement, params, context, executemany):
if armed["on"] and statement.lstrip().upper().startswith("UPDATE APPROVALS"):
armed["on"] = False
appdb.execute(("UPDATE approvals SET status='cancelled' WHERE run_id=?", (run.id,)),
("UPDATE runs SET status='cancelled' WHERE id=?", (run.id,)))
event.listen(engine.sync_engine, "before_cursor_execute", abandon_first)
try:
with pytest.raises(ValueError, match="已处理过|放弃"):
await run_manager.resume(run.id, {"approved": True})
finally:
event.remove(engine.sync_engine, "before_cursor_execute", abandon_first)
assert not armed["on"]
async with SessionLocal() as session:
approval = (await session.execute(
select(Approval).where(Approval.run_id == run.id))).scalar_one()
assert approval.status == "cancelled", "关掉的审批被改回了已回复"
assert not run_manager.is_active(run.id)