Files
AgentLab/backend/tests/test_caliber_write_race.py
T
ThearchyHelios 2f90f0eb3a 口径台账:固定执行、验证审阅发布与增量重导入
- QueryCore 固定执行(K09):按已发布的不可变口径跑一次,交回类型化标量、发布/manifest 引用与逐查询证据
- 依赖闭包(K08):按选中的指标算出要拷的取数节点、要的入口字段,接进 derive / compose / metrics
- SQL 模板参数完整性(K01):试跑、组合问数、派生图、运行时取数共用一套语义,缺参数在渲染前就拒
- 受限字段变更服务(K03)与口径领域边界(K01):改动打在整图深拷贝上,身份与证据归属不可改
- 版本化受控模板绑定(K04):业务口径按显式绑定落到 SQL,对不上就报 unsupported / needs_update
- 验证记录(K05)与最小审阅:样例匹配不等于正式批准,审批人只从服务端身份取
- 发布依据清单(K07):发布时按不可变图物化内容寻址清单,正式用途按它判
- 增量重导入(K11)与 B/L/R 三方合并、受限自然语言修改提案(K12):只产候选,落图仍走既有写路径
- 接口与前端:口径草稿页接上字段变更、绑定状态、验证与重导入,新增 caliber-drafts / studio 检查脚本

验证:前端 tsc -b 通过。
2026-11-10 15:05:18 +08:00

177 lines
9.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""K02 复核补充:已接管资产的判定、幂等的并发收敛、写入被拒之后的恢复路径。
- taken_over:文档起草建出来的(origin.kind=doc_draft)和 docattach 建出来的(旧产物没写 kind,按 tags 认)
都要保护;手建、蓝图、从运行提取、固化的都不算;
- docattach 产物照样要带基准版本,旧写 409;
- 同键并发:两路都写完之前谁也看不到对方的记录,靠 CAS / 唯一键收敛——只有确认是同一件事才算重放,别的完整性
错误照旧抛出去,不能假成功。
"""
from __future__ import annotations
import copy
import uuid
import pytest
from sqlalchemy.exc import IntegrityError
from app.api import ledger as api_ledger
from app.apps import versions as app_versions
from app.db.base import SessionLocal
from app.db.models import CaliberWriteRequest, Workflow
from tests.test_caliber_write_guard import ( # noqa: F401 - client / documents / datasource 是 fixture
_graph_of, _versions, cfg, client, datasource, documents, extract, fake_model, fake_trial, plan_reply,
ready_draft,
)
# ==========================================================================
# taken_over:新旧 docattach 产物一致,不误伤别的来源
# ==========================================================================
def test_taken_over_covers_both_doc_draft_products_and_nothing_else():
"""判定是纯函数,按工作流的形状认:kind=doc_draft、或者 docattach 那样的 tags + origin。"""
from app.apps.versions import taken_over
class Row:
def __init__(self, origin, tags=None):
self.origin, self.tags = origin, tags
# 新起草:origin 里写了 kind(app/ledger/authoring.py)
assert taken_over(Row({"kind": "doc_draft", "draft_id": "d1", "documents": ["doc-1"]}, ["doc_draft"]))
# docattach(含以前建的):没有 kind,只有建的时候打的标签和那几项来源(run/sources/draft_id/documents)
assert taken_over(Row({"run_id": "run-1", "freeze": True, "sources": [{"run_id": "run-1"}],
"draft_id": "d1", "documents": ["doc-1"]}, ["doc_draft"]))
assert taken_over(Row({"run_id": "run-1"}, ["doc_draft"]))
# 别的来源都不是:手建(没有 tags/origin)、蓝图、从运行提取、固化、老数据的 None
assert not taken_over(Row(None))
assert not taken_over(Row({}))
assert not taken_over(Row(None, []))
assert not taken_over(Row({"blueprint_id": "bp-1", "template": "board@1"}, ["blueprint"]))
assert not taken_over(Row({"run_id": "run-1", "conversation_id": "conv-1"}, ["extracted"]))
assert not taken_over(Row({"run_id": "run-1"}, ["certified"]))
assert not taken_over(Row({"sources": [{"run_id": "run-1"}]}))
async def test_an_attached_caliber_cannot_be_written_without_a_base_revision(client):
"""docattach 建出来的口径卡(没有 origin.kind):画布上照旧写不进去,要带基准版本。"""
wid = (await client.post("/api/workflows", json={"name": "K02 接上的 " + uuid.uuid4().hex[:6],
"graph": {"nodes": [
{"id": "card", "type": "metrics", "position": {"x": 0, "y": 0},
"data": {"label": "口径卡",
"config": {"metrics": [{"id": "m1", "name": "指标一",
"expression": "cell(nodes.q, 0, 'v')"}]}}},
], "edges": []}})).json()["id"]
async with SessionLocal() as session:
workflow = await session.get(Workflow, wid)
# 和 app/ledger/docattach.py 建出来的一模一样:tags 有 doc_draft,origin 里没有 kind
workflow.tags = ["doc_draft"]
workflow.origin = {"run_id": "run-1", "freeze": True, "sources": [{"run_id": "run-1"}],
"draft_id": "d-1", "documents": []}
await session.commit()
url = f"/api/workflows/{wid}"
graph = {"nodes": [*copy.deepcopy((await _graph_of(wid))["nodes"])], "edges": []}
cfg(graph, "card")["metrics"][0]["name"] = "旧页面改的"
refused = await client.patch(url, json={"graph": graph})
assert refused.status_code == 409, refused.text
assert refused.json()["code"] == "revision_required"
assert await _versions(wid) == [1]
ok = await client.patch(url, json={"graph": graph, "expected_revision": 1})
assert ok.status_code == 200 and ok.json()["version"] == 2, ok.text
assert await _versions(wid) == [1, 2]
# ==========================================================================
# 幂等的并发收敛:只有确认是同一件事才算重放
# ==========================================================================
async def test_a_duplicate_request_row_settles_into_a_replay(client, documents, datasource, monkeypatch, fake_trial):
"""两路真并发都没查到记录时后到的那一路撞唯一键:回滚自己写的图,按赢的那一路交回重放(不报成新的一版)。
注入方式:记录行事先就在,但把 claim_write 换成「一律当第一次见」——等于两路都走过了「还没写过」的判断。
"""
body = await ready_draft(client, documents, datasource, monkeypatch)
request_id = "race-0001"
metrics = copy.deepcopy(body["metrics"])
metrics[0]["name"] = "并发改的"
payload = api_ledger.DraftPatchIn(metrics=metrics, expected_revision=body["revision"], request_id=request_id,
target=api_ledger.DraftTargetIn(card_id=body["card_id"]))
async with SessionLocal() as session:
claim = await app_versions.claim_write(session, actor=None, workflow_id=body["workflow_id"],
target=body["card_id"], request_id=request_id,
payload_hash=api_ledger._patch_fingerprint(payload, body["card_id"]))
assert claim.state == app_versions.WRITE_NEW
# 先埋下「赢的那一路」写过的记录(内容和这次一样)
async with SessionLocal() as session:
session.add(CaliberWriteRequest(actor="", workflow_id=body["workflow_id"], card_id=body["card_id"],
request_id=request_id,
payload_hash=api_ledger._patch_fingerprint(payload, body["card_id"]),
result={"revision": 2}))
await session.commit()
original = app_versions.claim_write
calls = {"n": 0}
async def blind_once(*args, **kwargs):
# 只让这一次请求的「先查一遍」看不见记录(等于两路并发都没查到);回滚之后的重查走真的
calls["n"] += 1
if calls["n"] == 1:
return app_versions.WriteClaim(app_versions.WRITE_NEW)
return await original(*args, **kwargs)
monkeypatch.setattr(app_versions, "claim_write", blind_once)
async with SessionLocal() as session:
out = await api_ledger.patch_draft(body["id"], payload, session=session)
monkeypatch.setattr(app_versions, "claim_write", original)
assert calls["n"] == 2, calls
assert out.get("replayed") is True, out
# 自己那一版回滚掉了:谁都没多写
assert await _versions(body["workflow_id"]) == [1]
async def test_an_integrity_error_without_a_record_is_not_reported_as_success(client, documents, datasource,
monkeypatch, fake_trial):
"""记请求时撞完整性错误、可记录又不在(不是同一件事):照旧抛出去,不能假成功。"""
body = await ready_draft(client, documents, datasource, monkeypatch)
metrics = copy.deepcopy(body["metrics"])
metrics[0]["name"] = "不该成功"
payload = api_ledger.DraftPatchIn(metrics=metrics, expected_revision=body["revision"],
request_id="race-0002")
async def boom(*args, **kwargs):
raise IntegrityError("insert", {}, Exception("UNIQUE constraint failed: workflow_versions.workflow_id"))
monkeypatch.setattr(app_versions, "remember_write", boom)
with pytest.raises(IntegrityError):
async with SessionLocal() as session:
await api_ledger.patch_draft(body["id"], payload, session=session)
# 报错的那一次一条也没落库
assert await _versions(body["workflow_id"]) == [1]
async with SessionLocal() as session:
rows = list((await _rows_of_write(session, body["workflow_id"])))
assert rows == []
assert (await client.get(f"/api/ledger/drafts/{body['id']}")).json()["revision"] == 1
async def _rows_of_write(session, workflow_id: str):
from sqlalchemy import select
return (await session.execute(select(CaliberWriteRequest.id).where(
CaliberWriteRequest.workflow_id == workflow_id))).scalars()
async def test_two_requests_with_the_same_key_at_once_write_once(client, documents, datasource, monkeypatch, fake_trial):
"""同一个 request_id 的两路请求同时发:只落一版,两边都是 200。"""
import asyncio
body = await ready_draft(client, documents, datasource, monkeypatch)
metrics = copy.deepcopy(body["metrics"])
metrics[0]["name"] = "同键并发"
payload = {"metrics": metrics, "expected_revision": body["revision"], "request_id": "race-0003"}
url = f"/api/ledger/drafts/{body['id']}"
first, second = await asyncio.gather(client.patch(url, json=payload), client.patch(url, json=payload))
assert sorted(r.status_code for r in (first, second)) == [200, 200], (first.text, second.text)
assert await _versions(body["workflow_id"]) == [1, 2]
assert [r.json().get("replayed") for r in (first, second)].count(True) <= 1