mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 12:32:01 +00:00
合入遗留收口分支 nr8/l 的复核修复(变化记录先占住那一行再读改写、召回里行序不定的指标、事件触发列表按任务各取上限、选对象按名字查用 ilike、子工作流写入认旧键、几处测试的竞态)
# Conflicts: # backend/app/ledger/recall.py
This commit is contained in:
@@ -618,7 +618,10 @@ async def trigger_options(event: str | None = None, q: str | None = None, select
|
||||
async def page(model: Any, *conds: Any) -> tuple[list[Any], bool]:
|
||||
query = select(model).where(*conds)
|
||||
if words:
|
||||
query = query.where(model.name.contains(words, autoescape=True))
|
||||
# 名字里含这几个字,不分大小写:PostgreSQL 上 LIKE 分 ASCII 大小写、SQLite 不分,一律 ilike(转义同 runs._like)
|
||||
from app.api.runs import _like
|
||||
|
||||
query = query.where(model.name.ilike(f"%{_like(words)}%", escape="\\"))
|
||||
rows = list((await session.execute(query.order_by(model.name, model.id).limit(limit + 1))).scalars())
|
||||
more = len(rows) > limit
|
||||
rows = rows[:limit]
|
||||
|
||||
@@ -29,7 +29,7 @@ from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy import func, or_, select, update
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -151,11 +151,16 @@ async def record(session: AsyncSession, source: Any, change: NewChange, detected
|
||||
raise ValueError(f"不认识的严重程度:{change.severity}")
|
||||
fp = fingerprint(change.kind, change.table, change.column, change.key)
|
||||
now = utcnow()
|
||||
# 已经有了的:先按条件 UPDATE 记下又见到一次(同时占住那一行:SQLite 上拿到写锁、PostgreSQL 上锁住这一行),再读出来改
|
||||
# 影响面——口径试算同时往 affects 里记没试算的那几版(ledger/recall._probe_skipped,同样先占住再读改写),谁也不盖掉谁
|
||||
seen = await session.execute(update(SourceChange).where(
|
||||
SourceChange.source_id == source.id, SourceChange.fingerprint == fp)
|
||||
.values(last_seen_at=now, seen_count=func.coalesce(SourceChange.seen_count, 0) + 1)
|
||||
.execution_options(synchronize_session=False))
|
||||
found = (await session.execute(select(SourceChange).where(
|
||||
SourceChange.source_id == source.id, SourceChange.fingerprint == fp))).scalar_one_or_none()
|
||||
SourceChange.source_id == source.id, SourceChange.fingerprint == fp)
|
||||
.execution_options(populate_existing=True))).scalar_one_or_none() if seen.rowcount else None
|
||||
if found is not None:
|
||||
found.last_seen_at = now
|
||||
found.seen_count = int(found.seen_count or 0) + 1
|
||||
if found.status == "open":
|
||||
# 影响按这一次刷新;表结构变了之后口径试算的情况(ledger/recall 记的 probes)不是探查算的,留着
|
||||
probes = (found.affects or {}).get("probes") if isinstance(found.affects, dict) else None
|
||||
|
||||
@@ -43,12 +43,6 @@ class RunContext:
|
||||
"""这张图里的节点 id → 这次运行里的 id(事件、工件上记的那个):最外层原样,子工作流里加前缀。"""
|
||||
return f"{self.prefix}{node_id}"
|
||||
|
||||
@property
|
||||
def root_ids(self) -> frozenset[str]:
|
||||
"""这次运行最外层图上的节点 id:最外层就是这张图的,子工作流里是 multi.run_subgraph 交下来的。"""
|
||||
given = self.extra.get(ROOT_IDS_KEY)
|
||||
return frozenset(given) if given is not None else frozenset(n.id for n in self.spec.nodes)
|
||||
|
||||
def global_label(self, title: str) -> str:
|
||||
"""这张图里的节点标题 → 这次运行里给人看的名字:最外层原样,子工作流里接上子工作流的名字(「子工作流 › x」,嵌套的
|
||||
一层层接上,同 multi.run_subgraph 记在异常上的 label)。父运行的图上没有子工作流里的节点,各处照这个名字说。"""
|
||||
@@ -59,8 +53,6 @@ class RunContext:
|
||||
PREFIX_KEY = "node_prefix"
|
||||
#: 子工作流里的节点名前缀(「子工作流 › 」,同上):RunContext.global_label
|
||||
LABEL_PREFIX_KEY = "node_label_prefix"
|
||||
#: 最外层图上的节点 id(同上,只在子工作流里有):RunContext.root_ids
|
||||
ROOT_IDS_KEY = "root_node_ids"
|
||||
#: 父运行的事件流(RunContext.extra 里的键,同上)。子工作流在父节点里用 ainvoke 跑,LangGraph 不把它的 custom 事件交给
|
||||
#: 父图的流(ainvoke 不开 custom 模式,get_stream_writer 拿到的是空的那个):子工作流里每一步以前在运行的事件里一条都
|
||||
#: 没有,时间线看不到、封存也不含它查过的数。所以 run_subgraph 把父图那一刻的写入口交下来,子工作流的节点经它发
|
||||
|
||||
@@ -15,7 +15,7 @@ from app.core.events import EventType
|
||||
from app.db.base import SessionLocal
|
||||
from app.db.models import Workflow
|
||||
from app.engine.context import (
|
||||
LABEL_PREFIX_KEY, PREFIX_KEY, ROOT_IDS_KEY, STREAM_KEY, NodeContext, NodeError, stream_writer,
|
||||
LABEL_PREFIX_KEY, PREFIX_KEY, STREAM_KEY, NodeContext, NodeError, stream_writer,
|
||||
)
|
||||
from app.engine.lineage import whole_ref
|
||||
from app.engine.state import GraphState, message_text, template_context
|
||||
@@ -694,9 +694,7 @@ async def run_subgraph(state: GraphState, ctx: NodeContext) -> dict[str, Any]:
|
||||
# 子图里的节点经父运行的写入口发事件、节点 id 带前缀;嵌套的子图拿到的是同一个写入口、更长的前缀
|
||||
extra={**{k: v for k, v in ctx.run.extra.items() if k not in ("resumed", "actors")},
|
||||
PREFIX_KEY: f"{ctx.run.global_id(ctx.node.id)}/", LABEL_PREFIX_KEY: f"{ctx.run.global_label(ctx.node.title)} › ",
|
||||
STREAM_KEY: stream_writer(ctx.run),
|
||||
# 最外层图上的节点 id:子图里的写入认升级前按子图里的 id 记的幂等键时,排除父图同名节点记的那一条(tools._run_write)
|
||||
ROOT_IDS_KEY: ctx.run.extra.get(ROOT_IDS_KEY) or frozenset(n.id for n in ctx.run.spec.nodes)},
|
||||
STREAM_KEY: stream_writer(ctx.run)},
|
||||
)
|
||||
# 这个子工作流节点之前几次执行里,子图各节点跑过的记录(交回父图 trail 的,见下):子图从它们接着数第几次执行
|
||||
# (engine/evidence.next_exec)。以前每次都从空的 trail 起步,循环里第二轮的子图节点又记成第 1 次:台账分不出是哪一次,
|
||||
@@ -772,7 +770,7 @@ async def run_subgraph(state: GraphState, ctx: NodeContext) -> dict[str, Any]:
|
||||
# 从这里接着数(上面的 seed);走了驳回出口的那几步(outcome=rejected)运行收尾据此在终态事件上照实写「途经驳回」
|
||||
# (runner._rejected_via),实例页、通知、定时任务的 Webhook 说得出「子工作流 › 把关」被驳回、转到哪(名字、转到哪几步
|
||||
# 在子图里已经按这次运行的说法记好:global_label、global_id)。父图自己的节点按自己的 id 数,不受影响
|
||||
updates["trail"] = [{**t, "node_id": f"{prefix}{t.get('node_id')}"} for t in ran]
|
||||
updates["trail"] = _forwarded_trail(ran, prefix)
|
||||
var_name = ctx.cfg("assign_to", "")
|
||||
if var_name:
|
||||
updates["vars"] = {var_name: output}
|
||||
@@ -783,6 +781,11 @@ async def run_subgraph(state: GraphState, ctx: NodeContext) -> dict[str, Any]:
|
||||
_LEDGER_NODE_KEYS = ("node_id", "card")
|
||||
|
||||
|
||||
def _forwarded_trail(ran: list[dict[str, Any]], prefix: str) -> list[dict[str, Any]]:
|
||||
"""子图这一次跑过的步骤交回父图 trail 的样子:节点 id 接上「子工作流节点 id/」前缀。"""
|
||||
return [{**t, "node_id": f"{prefix}{t.get('node_id')}"} for t in ran]
|
||||
|
||||
|
||||
def _forwarded(entry: dict[str, Any], node_id: str) -> dict[str, Any]:
|
||||
"""子图台账里的一条交回父图:节点 id 加上「子工作流节点 id/」前缀,别的照抄。"""
|
||||
out = dict(entry)
|
||||
|
||||
@@ -275,10 +275,10 @@ async def _run_write(state: GraphState, ctx: NodeContext, name: str, args: dict[
|
||||
|
||||
exec_no = next_exec(state, ctx.node.id)
|
||||
# 幂等键按这次运行里的节点 id 算(子工作流里的写「sub/pay」):以前按子图里的 id(pay)算,父图里同名的节点、套同一个
|
||||
# 工作流的两个子工作流节点撞同一个键,后发的那一笔交回先发的回执、不发送。升级前停在子图里的运行按子图里的 id 记过,
|
||||
# 恢复时整个子图重跑:照旧认那个键(legacy_node_id),已经生效的不重发;父图里有同名节点时不认(那一条可能是它的)
|
||||
node_id = ctx.run.global_id(ctx.node.id)
|
||||
legacy = ctx.node.id if node_id != ctx.node.id and ctx.node.id not in ctx.run.root_ids else None
|
||||
# 工作流的两个子工作流节点撞同一个键,后发的那一笔交回先发的回执、不发送。升级前子工作流里的写入按子图里的 id 记过键:
|
||||
# 写入节点本身重新执行时(结果未知核实之后、失败后接着跑;审批恢复时子图从自己的断点接着跑,不走到这里)照旧认那个键
|
||||
# (legacy_node_id),是不是这个节点的那一笔、是不是这一次的内容,由 actions.prepare 按那条记录的事件和内容指纹认
|
||||
node_id, legacy = _action_ids(ctx)
|
||||
try:
|
||||
prep = await actions.prepare(run_id=ctx.run.run_id, node_id=node_id, exec_no=exec_no,
|
||||
capability=capability, args=args, legacy_node_id=legacy)
|
||||
@@ -329,6 +329,12 @@ async def _run_write(state: GraphState, ctx: NodeContext, name: str, args: dict[
|
||||
return _write_updates(ctx, result)
|
||||
|
||||
|
||||
def _action_ids(ctx: NodeContext) -> tuple[str, str | None]:
|
||||
"""写入的幂等键按哪个节点 id 算:(这次运行里的 id, 升级前按子图里的 id 记过的那种;最外层的为 None)。"""
|
||||
node_id = ctx.run.global_id(ctx.node.id)
|
||||
return node_id, ctx.node.id if node_id != ctx.node.id else None
|
||||
|
||||
|
||||
async def _confirmed_by(run_id: str, node_id: str) -> str | None:
|
||||
"""确认这次发送的人:节点重新执行(结果未知核实后接着跑、重启后恢复)时 ctx.actor() 已经不是确认的那次恢复,
|
||||
按待办上记的处理人认(会签时是凑齐的那一票)。"""
|
||||
|
||||
@@ -1448,7 +1448,8 @@ def _rejected_via(state: dict[str, Any]) -> dict[str, Any]:
|
||||
"""这次运行途经的驳回出口(engine/compiler._rejected_exit 记在 trail 上的 outcome=rejected):{rejected_via: [{node_id,
|
||||
label, kind, decision, actor, note, to}]},按先后。终态事件带上它,运行照它的路径结束(成功结束也一样)照实说「途经
|
||||
驳回」;没有途经的不带这个键,终态事件和以前一字不差。读的是收尾时的状态:节点出错、被驳回结束的那几种收尾拿不到
|
||||
状态(final_state 为空),不带;子工作流里的驳回出口不在这里(它的 trail 是子工作流自己的状态)。"""
|
||||
状态(final_state 为空),不带。子工作流里走了驳回出口的那几步也在这里:子图跑过的步骤交回父图的 trail(节点 id 带
|
||||
「子工作流节点 id/」前缀、名字写「子工作流 › x」,engine/nodes/multi.run_subgraph)。"""
|
||||
via = [{k: t.get(k) for k in ("node_id", "label", "kind", "decision", "actor", "note", "to")}
|
||||
for t in state.get("trail") or [] if isinstance(t, dict) and t.get("outcome") == "rejected"]
|
||||
return {"rejected_via": via} if via else {}
|
||||
|
||||
@@ -66,7 +66,7 @@ from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import func, not_, select
|
||||
from sqlalchemy import func, not_, select, update
|
||||
|
||||
from app.core.events import EventType
|
||||
from app.db.base import SessionLocal, utcnow
|
||||
@@ -83,7 +83,7 @@ REASONS = ("caliber", "catalog", "structure")
|
||||
#: 发起的批次:发布后自动的、版本历史里手动接着算的
|
||||
BATCHES = ("auto", "manual")
|
||||
#: 一组的结论
|
||||
OUTCOMES = ("changed", "unchanged", "failed", "running", "pending", "not_recomputable")
|
||||
OUTCOMES = ("changed", "unchanged", "row_order", "failed", "running", "pending", "not_recomputable")
|
||||
#: 启动时接着做最近几天发布的
|
||||
RESUME_DAYS = 7
|
||||
#: 目录、表结构变了时最多看多少次探索运行(新的在前)
|
||||
@@ -110,6 +110,8 @@ SUPERSEDED = "已有更新的发布版 v{latest},召回在 v{latest} 那一版
|
||||
NOT_CURRENT = "v{version} 已不是当前发布版,召回不再接着算"
|
||||
#: 收尾
|
||||
CHANGED_TITLE = "口径「{name}」升到 v{version},答案的数变了"
|
||||
#: 全部指标行序不定、核对不了时的待办标题(settle 的 row_order)
|
||||
ROW_ORDER_TITLE = "口径「{name}」升到 v{version},答案里的数行序不定、没能核对,请人工核对"
|
||||
CATALOG_TITLE = "答案依据的数据目录有变:按新目录检查出了新的问题"
|
||||
STRUCTURE_TITLE = "口径「{name}」v{version} 依据的表结构变了,试算没有算出来"
|
||||
#: 依据有变的四种(ledger.basis_changed 的 kind)
|
||||
@@ -533,11 +535,12 @@ def _reasons(plan: Plan) -> list[dict[str, Any]]:
|
||||
|
||||
|
||||
async def outcome_of(session: Any, run: Run) -> tuple[str, dict[str, Any]]:
|
||||
"""一次召回运行的结论:(changed / unchanged / failed / running, 收尾时记下的那一条 ledger.recalled 的内容)。"""
|
||||
"""一次召回运行的结论:(changed / unchanged / row_order(全部指标行序不定、核对不了)/ failed / running, 收尾时记下的那一条
|
||||
ledger.recalled 的内容)。"""
|
||||
data = await _settled(session, run.id)
|
||||
if data is not None:
|
||||
if data.get("outcome") == "failed":
|
||||
return "failed", data
|
||||
if data.get("outcome") in ("failed", "row_order"):
|
||||
return str(data["outcome"]), data
|
||||
return ("changed" if data.get("changed") else "unchanged"), data
|
||||
if run.status in ("failed", "cancelled"):
|
||||
return "failed", {"error": run.error}
|
||||
@@ -725,22 +728,31 @@ async def _settle(run_id: str) -> str | None:
|
||||
await _mark(run_id, {**base, "outcome": "failed", "changed": None, "error": CARD_UNREADABLE})
|
||||
return "failed"
|
||||
results = compare(meta, values)
|
||||
changed = any(r["changed"] for r in results)
|
||||
# 行序不定的(recall_card 记的 row_order)比不出变没变:不算「没变」。全部都行序不定的结论写核对不了、开待办请人核对;
|
||||
# 部分行序不定的,已判定的照常判,行序不定的那几项在结论、待办、原答案的记账里照实列出
|
||||
unstable = [r["name"] for r in results if r.get("row_order") == "unstable"]
|
||||
decided = [r for r in results if r.get("row_order") != "unstable"]
|
||||
changed = any(r["changed"] for r in decided)
|
||||
outcome = "row_order" if not decided else "changed" if changed else "unchanged"
|
||||
answers = list(dict.fromkeys(str(u.get("run_id")) for u in origin.get("uses") or [] if u.get("run_id")))
|
||||
tasks: list[str] = []
|
||||
if changed:
|
||||
tasks = await open_tasks(run, reason="caliber")
|
||||
if outcome in ("changed", "row_order"):
|
||||
tasks = await open_tasks(run, reason="caliber", row_order=outcome == "row_order")
|
||||
else:
|
||||
for answer in answers:
|
||||
await _mark_answer(answer, run)
|
||||
await _mark(run_id, {**base, "outcome": "changed" if changed else "unchanged", "changed": changed,
|
||||
await _mark_answer(answer, run, row_order=unstable)
|
||||
await _mark(run_id, {**base, "outcome": outcome, "changed": changed if decided else None,
|
||||
"answers": len(answers), "tasks": tasks,
|
||||
"metrics": [{"k": r["k"], "name": r["name"], "changed": r["changed"]} for r in results]})
|
||||
return "changed" if changed else "unchanged"
|
||||
**({"row_order_metrics": unstable} if unstable else {}),
|
||||
"metrics": [{"k": r["k"], "name": r["name"], "changed": r["changed"],
|
||||
**({"row_order": "unstable"} if r.get("row_order") else {})}
|
||||
for r in results]})
|
||||
return outcome
|
||||
|
||||
|
||||
async def _mark_answer(answer_id: str, recall_run: Run) -> None:
|
||||
"""数没变:在原答案的运行上记一条 ledger.recalled(已按 vN 核对,不影响)。这次召回记过的不再记。"""
|
||||
async def _mark_answer(answer_id: str, recall_run: Run, *, row_order: Sequence[str] = ()) -> None:
|
||||
"""数没变:在原答案的运行上记一条 ledger.recalled(已按 vN 核对,不影响)。这次召回记过的不再记。row_order 是行序不定、
|
||||
没能核对的那几项(照实列出,不算进「不影响」)。"""
|
||||
origin = recall_run.origin if isinstance(recall_run.origin, dict) else {}
|
||||
async with SessionLocal() as session:
|
||||
rows = (await session.execute(select(RunEvent.data).where(
|
||||
@@ -749,7 +761,8 @@ async def _mark_answer(answer_id: str, recall_run: Run) -> None:
|
||||
return
|
||||
await _mark(answer_id, {"role": "answer", "changed": False, "recall_run": recall_run.id,
|
||||
"workflow_id": origin.get("workflow_id"), "from_version": origin.get("from_version"),
|
||||
"to_version": origin.get("to_version")})
|
||||
"to_version": origin.get("to_version"),
|
||||
**({"row_order_metrics": list(row_order)} if row_order else {})})
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
@@ -784,7 +797,7 @@ def correlation(run_id: str, owner: tuple[str, str], reason: str = "caliber", ex
|
||||
|
||||
|
||||
async def open_tasks(run: Run, *, reason: str, owners: Iterable[tuple[str, str]] | None = None,
|
||||
title: str | None = None, extra: str = "") -> list[str]:
|
||||
title: str | None = None, extra: str = "", row_order: bool = False) -> list[str]:
|
||||
"""给答案的主人开 recall 待办(挂在 run 上)。owners 不给就按召回运行 origin 里各答案的主人。按 correlation_key 认同一条,
|
||||
开过的不再开。提交之后推通知。返回这次新开的待办 id。"""
|
||||
from app.process import tasks as process_tasks
|
||||
@@ -796,7 +809,8 @@ async def open_tasks(run: Run, *, reason: str, owners: Iterable[tuple[str, str]]
|
||||
targets = await _owner_targets(session, owners)
|
||||
name = await ledger_uses.workflow_name(session, str(origin.get("workflow_id") or ""))
|
||||
if title is None:
|
||||
title = CHANGED_TITLE.format(name=name or "口径", version=origin.get("to_version"))
|
||||
title = (ROW_ORDER_TITLE if row_order else CHANGED_TITLE).format(name=name or "口径",
|
||||
version=origin.get("to_version"))
|
||||
opened: list[str] = []
|
||||
for owner in targets:
|
||||
key = correlation(run.id, owner, reason, extra)
|
||||
@@ -1016,7 +1030,10 @@ async def answer_marks(session: Any, run_id: str, principal: Any) -> dict[str, A
|
||||
data = data if isinstance(data, dict) else {}
|
||||
if etype == EventType.LEDGER_RECALLED and data.get("role") == "answer":
|
||||
checked.append({k: data.get(k) for k in ("workflow_id", "from_version", "to_version", "recall_run")}
|
||||
| {"changed": bool(data.get("changed")), "at": at(ts)})
|
||||
| {"changed": bool(data.get("changed")), "at": at(ts)}
|
||||
# 行序不定、没能核对的那几项(只列名字):不算进「不影响」
|
||||
| ({"row_order_metrics": [str(x) for x in data["row_order_metrics"]]}
|
||||
if isinstance(data.get("row_order_metrics"), list) and data["row_order_metrics"] else {}))
|
||||
workflows.add(str(data.get("workflow_id") or ""))
|
||||
elif etype == EventType.LEDGER_BASIS_CHANGED:
|
||||
items = data.get("items")
|
||||
@@ -1596,8 +1613,14 @@ async def _probe_skipped(change: Mapping[str, Any], caliber: Any, pinned: Any, g
|
||||
note = {"workflow_id": caliber.workflow_id, "version": caliber.version, "name": str(name), "outcome": "skipped",
|
||||
"missing": missing, "reason": reason, "inputs_from": inputs_from}
|
||||
async with SessionLocal() as session:
|
||||
row = (await session.execute(select(SourceChange).where(SourceChange.id == change.get("id")))).scalar_one_or_none()
|
||||
# 先占住那一行再读改写(SQLite 上拿到写锁、PostgreSQL 上锁住这一行):几版口径同时记、探查同时刷新影响
|
||||
# (source_changes.record)时一个接一个来,谁也不盖掉谁
|
||||
await session.execute(update(SourceChange).where(SourceChange.id == change.get("id"))
|
||||
.values(seen_count=SourceChange.seen_count).execution_options(synchronize_session=False))
|
||||
row = (await session.execute(select(SourceChange).where(SourceChange.id == change.get("id"))
|
||||
.execution_options(populate_existing=True))).scalar_one_or_none()
|
||||
if row is None:
|
||||
await session.rollback()
|
||||
return
|
||||
affects = dict(row.affects or {})
|
||||
kept = [p for p in affects.get(PROBES_KEY) or [] if isinstance(p, dict)
|
||||
|
||||
@@ -409,7 +409,9 @@ async def notify_recall(task_id: str, users: list[str]) -> int:
|
||||
|
||||
def message(answers: str, tasks: int, link: tuple[str, str] | None) -> dict[str, Any]:
|
||||
return {"event": event, "status": "pending", "ok": False,
|
||||
"title": recall.CHANGED_TITLE.format(name=name[:80] or "口径", version=origin.get("to_version")),
|
||||
# 待办开出来时按结论起的标题(数变了 / 行序不定、没能核对):通知和待办说同一句
|
||||
"title": (recall.ROW_ORDER_TITLE if "行序不定" in (task.title or "") else recall.CHANGED_TITLE)
|
||||
.format(name=name[:80] or "口径", version=origin.get("to_version")),
|
||||
"lines": [*head, ("答案", answers), *([("待办", f"{tasks} 条")] if tasks > 1 else []), *tail],
|
||||
"links": [link] if base and link else [],
|
||||
"subject": {"task_id": task_id, "run_id": task.run_id, "workflow_id": origin.get("workflow_id")}}
|
||||
|
||||
@@ -244,6 +244,51 @@ class Prepared:
|
||||
return f"{self.key}:{self.attempt}"
|
||||
|
||||
|
||||
#: 按自己的键找到别人的那一笔时换用的键的前缀(prepare):节点 id 里不会有这个字符
|
||||
OWN_KEY_MARK = "\x1e"
|
||||
#: 带 action_id 的那几种事件:认一条记录是哪个节点写的(_owner)
|
||||
_ACTION_EVENTS = (EventType.ACTION_PREPARED, EventType.ACTION_SENT, EventType.ACTION_CONFIRMED, EventType.ACTION_FAILED,
|
||||
EventType.ACTION_UNKNOWN, EventType.ACTION_SIMULATED)
|
||||
|
||||
|
||||
async def _owner(session: AsyncSession, rec: ActionRecord) -> str | None:
|
||||
"""这条记录是哪个节点写的(这次运行里的 id,子工作流里的带前缀):按它的 action.* 事件上记的 node_id。记录上的 node_id
|
||||
升级前子工作流里记的是子图里的 id,和父图同名的节点分不开;事件一向按这次运行里的 id 记。认不出(事件没写上)为 None。"""
|
||||
from app.db.models import RunEvent
|
||||
|
||||
rows = (await session.execute(select(RunEvent.node_id, RunEvent.data).where(
|
||||
RunEvent.run_id == rec.run_id, RunEvent.type.in_([str(t) for t in _ACTION_EVENTS])))).all()
|
||||
return next((str(node) for node, data in rows if node and isinstance(data, dict) and data.get("action_id") == rec.id),
|
||||
None)
|
||||
|
||||
|
||||
def _same_payload(rec: ActionRecord, capability: Any, args: dict[str, Any]) -> bool | None:
|
||||
"""这条记录是不是这一次要发的内容:能力相同、内容指纹等于这一次参数收好类型之后的指纹。收不成类型(参数定义改了)的
|
||||
比不了,为 None。"""
|
||||
name = capability.name
|
||||
if rec.capability != name:
|
||||
return False
|
||||
try:
|
||||
typed = typed_args(capability, args)
|
||||
except ActionFailed:
|
||||
return None
|
||||
return rec.payload_hash == payload_hash(name, typed)
|
||||
|
||||
|
||||
async def _belongs(session: AsyncSession, rec: ActionRecord, node_id: str, capability: Any, args: dict[str, Any], *,
|
||||
strict: bool = False) -> bool:
|
||||
"""这条记录是不是这个节点(node_id,这次运行里的 id)的。事件认得出是谁写的就按事件;认不出的按内容(能力、内容指纹),
|
||||
比不了的当是(以前就是这样认的)。strict(认升级前的旧键时):事件说是这个节点的,内容也要对得上——循环跨了升级时,
|
||||
同一个节点第二轮按老算法同样是第 1 次执行,找到的是第一轮那一笔;比不了内容(参数定义改了)的照事件认。"""
|
||||
owner = await _owner(session, rec)
|
||||
same = _same_payload(rec, capability, args)
|
||||
if owner is not None and owner != node_id:
|
||||
return False
|
||||
if owner is not None and not strict:
|
||||
return True
|
||||
return same is not False
|
||||
|
||||
|
||||
async def latest(session: AsyncSession, key: str) -> ActionRecord | None:
|
||||
return await session.scalar(select(ActionRecord).where(ActionRecord.action_key == key)
|
||||
.order_by(ActionRecord.attempt.desc()).limit(1))
|
||||
@@ -268,12 +313,20 @@ async def prepare(*, run_id: str, node_id: str, exec_no: int, capability: Any, a
|
||||
run = await session.get(Run, run_id)
|
||||
endpoint = await _endpoint(session, capability)
|
||||
prev = await latest(session, key)
|
||||
if prev is not None and not await _belongs(session, prev, node_id, capability, args):
|
||||
# 按自己的键找到的是别的节点的那一笔:升级前子工作流里同名的节点按子图里的 id 记的键,和父图这个节点的一样。
|
||||
# 那一笔不是这个节点的,换一个只属于这个节点的键(同样的输入永远算出同一个)
|
||||
key = action_key(run_id, f"{OWN_KEY_MARK}{node_id}", exec_no)
|
||||
prev = await latest(session, key)
|
||||
if prev is None and legacy_node_id:
|
||||
# 升级前子工作流里的写入按子图里的节点 id 记幂等键(node_id 也记成它):恢复时整个子图重跑,照那个键认出已经
|
||||
# 记下的那一笔,接着用它的键(不重发、第几次发送接着数)。新记的子图写入 node_id 带前缀,认不到这里来
|
||||
# 升级前子工作流里的写入按子图里的节点 id 记幂等键(node_id 也记成它):写入节点本身重新执行时(结果未知核实之后、
|
||||
# 失败后接着跑)照那个键认出已经记下的那一笔,接着用它的键(不重发、第几次发送接着数)。只认这个节点的、这一次的:
|
||||
# 那一笔的事件记着是这个节点写的(父图里同名的节点按同一个键写过),内容指纹也和这一次的一样(循环跨了升级时,
|
||||
# 第二轮按老算法同样是第 1 次执行,找到的是第一轮那一笔)
|
||||
old_key = action_key(run_id, legacy_node_id, exec_no)
|
||||
old = await latest(session, old_key)
|
||||
if old is not None and old.node_id == legacy_node_id:
|
||||
if old is not None and old.node_id == legacy_node_id \
|
||||
and await _belongs(session, old, node_id, capability, args, strict=True):
|
||||
key, prev, node_id = old_key, old, legacy_node_id
|
||||
facts = {"app_id": run.app_id, "workflow_id": run.workflow_id, "workflow_version": run.version,
|
||||
"graph_hash": run.version_hash, "started_by": run.started_by} if run is not None else {}
|
||||
|
||||
@@ -559,11 +559,17 @@ async def pending_for_many(session: Any, schedules: list[Schedule]) -> dict[str,
|
||||
counts = dict((await session.execute(select(TriggerDelivery.schedule_id, func.count()).where(
|
||||
TriggerDelivery.schedule_id.in_(ids), TriggerDelivery.status == "pending")
|
||||
.group_by(TriggerDelivery.schedule_id))).all())
|
||||
# 每个任务各读最早的 DRAIN_BATCH 条(同 _drain 一拍读的):合起来一个上限的话,积压多的任务会把别的挤掉,它们的 due_at 为空
|
||||
ranked = (select(TriggerDelivery.id.label("did"), func.row_number().over(
|
||||
partition_by=TriggerDelivery.schedule_id,
|
||||
order_by=(TriggerDelivery.occurred_at, TriggerDelivery.created_at, TriggerDelivery.id)).label("rn"))
|
||||
.where(TriggerDelivery.schedule_id.in_([i for i in ids if counts.get(i)]), TriggerDelivery.status == "pending")
|
||||
.subquery())
|
||||
waiting = (await session.execute(
|
||||
select(TriggerDelivery.schedule_id, TriggerDelivery.id, TriggerDelivery.created_at, TriggerEvent)
|
||||
.join(TriggerEvent, TriggerEvent.id == TriggerDelivery.event_id)
|
||||
.where(TriggerDelivery.schedule_id.in_([i for i in ids if counts.get(i)]), TriggerDelivery.status == "pending")
|
||||
.order_by(TriggerDelivery.occurred_at, TriggerEvent.created_at).limit(DRAIN_BATCH))).all() if counts else []
|
||||
.join(ranked, ranked.c.did == TriggerDelivery.id).where(ranked.c.rn <= DRAIN_BATCH)
|
||||
.order_by(TriggerDelivery.occurred_at, TriggerEvent.created_at))).all() if counts else []
|
||||
by: dict[str, list[tuple[str, TriggerEvent, datetime]]] = {}
|
||||
stale: dict[str, int] = {}
|
||||
for sid, did, routed, ev in waiting:
|
||||
@@ -585,8 +591,14 @@ async def pending_for_many(session: Any, schedules: list[Schedule]) -> dict[str,
|
||||
|
||||
cond = or_(*[and_(TriggerEvent.event == kind, TriggerEvent.subject_id.in_(sorted(subs)))
|
||||
for kind, subs in pairs.items()])
|
||||
# 每个(事件种类, 对象)各读前 ROUTE_BATCH 条(同改前每个任务各读的那么多):合起来一个上限会把别的对象挤掉、少算
|
||||
ranked = (select(TriggerEvent.id.label("eid"), func.row_number().over(
|
||||
partition_by=(TriggerEvent.event, TriggerEvent.subject_id),
|
||||
order_by=(TriggerEvent.created_at, TriggerEvent.id)).label("rn"))
|
||||
.where(TriggerEvent.status == "pending", cond).subquery())
|
||||
unrouted = (await session.execute(select(TriggerEvent.event, TriggerEvent.subject_id, TriggerEvent.payload)
|
||||
.where(TriggerEvent.status == "pending", cond).limit(ROUTE_BATCH))).all()
|
||||
.join(ranked, ranked.c.eid == TriggerEvent.id)
|
||||
.where(ranked.c.rn <= ROUTE_BATCH))).all()
|
||||
for kind, subject, payload in unrouted:
|
||||
for sid in ids:
|
||||
if triggers.matches(trig[sid], kind, subject, payload):
|
||||
|
||||
@@ -954,3 +954,90 @@ async def test_a_probe_that_cannot_assemble_its_inputs_says_which_are_missing(en
|
||||
await session.commit()
|
||||
again = (await session.execute(select(SourceChange).where(SourceChange.id == out["id"]))).scalar_one()
|
||||
assert again.affects["probes"] == [note]
|
||||
|
||||
|
||||
async def test_probe_notes_and_change_refreshes_do_not_overwrite_each_other(engine_up):
|
||||
"""同一条变化上几处同时写 affects(几版口径各记一条没试算、探查又刷新了一次影响):先占住那一行再读改写,谁也不盖掉谁。"""
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
|
||||
from app.data import source_changes as sc
|
||||
from app.db.models import DataSource
|
||||
from app.db.models_sources import SourceChange
|
||||
|
||||
async with SessionLocal() as session:
|
||||
source = (await session.execute(select(DataSource).where(DataSource.name == S.CURRENT["source"]))).scalar_one()
|
||||
change, _ = await sc.record(session, source, sc.NewChange(kind="column_removed", table="orders", column="amount",
|
||||
severity="action", key="v4",
|
||||
affects={"catalog": [], "templates": [],
|
||||
"rules_touched": False}),
|
||||
sc.Detected(job_kind="introspect"))
|
||||
await session.commit()
|
||||
out = sc.change_out(change)
|
||||
source_id = source.id
|
||||
|
||||
async def refresh() -> None:
|
||||
async with SessionLocal() as session:
|
||||
src = await session.get(DataSource, source_id)
|
||||
await sc.record(session, src, sc.NewChange(kind="column_removed", table="orders", column="amount",
|
||||
severity="action", key="v4",
|
||||
affects={"catalog": [], "templates": [], "rules_touched": True}),
|
||||
sc.Detected(job_kind="introspect"))
|
||||
await session.commit()
|
||||
|
||||
calibers = [SimpleNamespace(workflow_id=f"wf-{i}", version=1) for i in range(6)]
|
||||
await asyncio.gather(*(recall._probe_skipped(out, c, None, {}, "defaults", "缺参数") for c in calibers), refresh())
|
||||
async with SessionLocal() as session:
|
||||
row = (await session.execute(select(SourceChange).where(SourceChange.id == out["id"]))).scalar_one()
|
||||
assert sorted(p["workflow_id"] for p in row.affects["probes"]) == [f"wf-{i}" for i in range(6)], row.affects
|
||||
assert row.affects["rules_touched"] is True, "探查刷新的那一份也在"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 行序不定的指标(召回比不出变没变):不当「没变」
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_all_metrics_with_unstable_row_order_cannot_be_checked_and_open_a_task(monkeypatch):
|
||||
"""全部指标都行序不定:结论写「核对不了(行序不定)」,开召回待办请人核对,不在原答案上记「已核对,不影响」。"""
|
||||
wf = await S.publish()
|
||||
a1 = await answer(monkeypatch)
|
||||
graph = S.caliber_graph()
|
||||
graph["nodes"][3]["data"]["config"]["instructions"] = "写周销售(新的写法)"
|
||||
await publish_v2(wf, graph=graph)
|
||||
monkeypatch.setattr(recall, "_rows_stable", lambda pinned, metric: False)
|
||||
[done] = await run_recall(wf)
|
||||
assert done.status == "succeeded", done.error
|
||||
[settled] = [d for d in await events(done.id, EventType.LEDGER_RECALLED) if d.get("role") == "recall"]
|
||||
assert settled["outcome"] == "row_order" and settled["changed"] is None
|
||||
assert settled["row_order_metrics"] == ["销售额"] and settled["metrics"][0]["row_order"] == "unstable"
|
||||
[task] = await recall_tasks(done.id)
|
||||
assert task.title == "口径「周销售」升到 v2,答案里的数行序不定、没能核对,请人工核对"
|
||||
assert await events(a1.id, EventType.LEDGER_RECALLED) == [], "核对不了的不在原答案上记「不影响」"
|
||||
async with SessionLocal() as session:
|
||||
view = await recall.batches(session, wf, 2)
|
||||
state, _ = await recall.outcome_of(session, done)
|
||||
assert state == "row_order" and view["groups"][0]["state"] == "row_order" and view["counts"]["row_order"] == 1
|
||||
|
||||
|
||||
async def test_some_metrics_with_unstable_row_order_are_listed_and_the_rest_decide(monkeypatch):
|
||||
"""部分行序不定:已判定的照常判(数没变就在原答案上记不影响),行序不定的那几项在结论和原答案的记账里照实列出。"""
|
||||
two = S.caliber_graph()
|
||||
card = two["nodes"][2]["data"]["config"]
|
||||
card["metrics"] = [*card["metrics"], {**card["metrics"][0], "id": "gmv2", "name": "销售额(另算)"}]
|
||||
wf = await S.publish(graph=two)
|
||||
a1 = await answer(monkeypatch)
|
||||
v2 = S.caliber_graph()
|
||||
v2["nodes"][2]["data"]["config"]["metrics"] = card["metrics"]
|
||||
v2["nodes"][3]["data"]["config"]["instructions"] = "写周销售(新的写法)"
|
||||
await publish_v2(wf, graph=v2)
|
||||
monkeypatch.setattr(recall, "_rows_stable", lambda pinned, metric: metric.get("id") != "gmv2")
|
||||
[done] = await run_recall(wf)
|
||||
assert done.status == "succeeded", done.error
|
||||
[settled] = [d for d in await events(done.id, EventType.LEDGER_RECALLED) if d.get("role") == "recall"]
|
||||
assert settled["outcome"] == "unchanged" and settled["changed"] is False
|
||||
assert settled["row_order_metrics"] == ["销售额(另算)"]
|
||||
assert [m.get("changed") for m in settled["metrics"]] == [False, None]
|
||||
[booked] = await events(a1.id, EventType.LEDGER_RECALLED)
|
||||
assert booked["changed"] is False and booked["row_order_metrics"] == ["销售额(另算)"]
|
||||
assert await recall_tasks(done.id) == []
|
||||
|
||||
@@ -96,7 +96,9 @@ async def _events(run_id: str, kind: str) -> list[RunEvent]:
|
||||
|
||||
|
||||
async def _confirm(run_id: str) -> Task:
|
||||
"""等确认卡出来,按卡上的内容指纹确认。"""
|
||||
"""等运行停到确认上、确认卡出来,按卡上的内容指纹确认。待办和审批同一个事务开出来,那一刻运行可能还没收成 interrupted、
|
||||
还在跑——这时去确认会被拒(「运行正在运行,没有待处理的审批」):先等运行停下来,再取待办。"""
|
||||
await _wait(run_id, ("interrupted",))
|
||||
task = await _task(run_id, "action_confirm")
|
||||
card = (await tasks.detail(task.id, ANN))["payload"]
|
||||
out = await tasks.respond(task.id, ANN, decision="approve", payload_hash=card["payload_hash"])
|
||||
@@ -166,6 +168,11 @@ async def test_unknown_result_with_lookup_is_reconciled_automatically_and_sent_o
|
||||
await _confirm(run_id)
|
||||
held = await _wait(run_id, ("interrupted", "failed", "succeeded"))
|
||||
assert held.status == "interrupted" and held.manifest_hash is None
|
||||
# 运行先挂起、runner 再安排核实(actions.on_unknown):等排上查询(记下 action.unknown)再看记录
|
||||
for _ in range(100):
|
||||
if await _events(run_id, "action.unknown"):
|
||||
break
|
||||
await asyncio.sleep(0.05)
|
||||
[rec] = await _records(run_id)
|
||||
assert rec.state == "unknown" and rec.next_lookup_at is not None
|
||||
assert len(await _events(run_id, "action.unknown")) == 1
|
||||
|
||||
@@ -169,3 +169,46 @@ async def test_a_window_ending_where_another_fire_already_is_still_fires():
|
||||
assert len(await fires(sid)) == 2
|
||||
assert [d.status for d in await deliveries(sid)] == ["fired", "fired"]
|
||||
assert TriggerDelivery is not None
|
||||
|
||||
|
||||
async def test_trigger_option_search_ignores_case_and_takes_wildcards_literally(client):
|
||||
"""按名字查不分大小写(PostgreSQL 上 LIKE 分 ASCII 大小写、SQLite 不分:一律 ilike),% 和 _ 按字面匹配、不当通配符。"""
|
||||
async with SessionLocal() as session:
|
||||
session.add_all([Workflow(name=n, graph={"nodes": [], "edges": []})
|
||||
for n in ("Weekly_Report 样例", "WeeklyXReport 样例", "满 100% 样例", "满 1000 样例")])
|
||||
await session.commit()
|
||||
|
||||
async def names(q: str) -> list[str]:
|
||||
r = await client.get("/api/schedules/trigger-options", params={"event": "workflow.finished", "q": q})
|
||||
assert r.status_code == 200, r.text
|
||||
return [o["name"] for o in r.json()["events"][0]["objects"]]
|
||||
|
||||
assert await names("weekly_report") == ["Weekly_Report 样例"], "大小写不分;_ 不匹配任意一个字"
|
||||
assert await names("100%") == ["满 100% 样例"], "% 不匹配任意几个字"
|
||||
|
||||
|
||||
async def test_a_backlog_on_one_schedule_does_not_crowd_out_the_others(monkeypatch):
|
||||
"""积压多的任务不把别的任务挤掉:等着的事件、还没派发的事件按任务(按对象)各读各的上限,不是合起来一个上限。"""
|
||||
monkeypatch.setattr(outbox, "DRAIN_BATCH", 3)
|
||||
monkeypatch.setattr(outbox, "ROUTE_BATCH", 3)
|
||||
busy_up, quiet_up = await workflow("积压的上游"), await workflow("安静的上游")
|
||||
downstream = await workflow("区域汇总")
|
||||
busy = await event_schedule(downstream, {"event": "workflow.finished", "workflow_id": busy_up, "debounce_s": 600},
|
||||
name="积压的")
|
||||
quiet = await event_schedule(downstream, {"event": "workflow.finished", "workflow_id": quiet_up, "debounce_s": 600},
|
||||
name="安静的")
|
||||
t0 = utcnow().replace(microsecond=0)
|
||||
for i in range(5):
|
||||
await put_event("workflow.finished", busy_up, {"workflow_id": busy_up, "run_id": f"b{i}"}, t0 + timedelta(seconds=i))
|
||||
await put_event("workflow.finished", quiet_up, {"workflow_id": quiet_up, "run_id": "q0"}, t0 + timedelta(seconds=10))
|
||||
for _ in range(4): # 一拍只派 ROUTE_BATCH 条:多派几拍,六条都派给任务
|
||||
await outbox.route(t0 + timedelta(seconds=20))
|
||||
for i in range(5):
|
||||
await put_event("workflow.finished", busy_up, {"workflow_id": busy_up, "run_id": f"c{i}"}, t0 + timedelta(seconds=30 + i))
|
||||
await put_event("workflow.finished", quiet_up, {"workflow_id": quiet_up, "run_id": "q1"}, t0 + timedelta(seconds=40))
|
||||
async with SessionLocal() as session:
|
||||
rows = list((await session.execute(select(Schedule).where(Schedule.id.in_([busy, quiet])))).scalars())
|
||||
state = await outbox.pending_for_many(session, rows)
|
||||
assert state[quiet]["due_at"] == (t0 + timedelta(seconds=610)).isoformat(), state
|
||||
assert state[quiet]["events"] == 1 and state[busy]["events"] == 3, state
|
||||
assert state[busy]["due_at"] == (t0 + timedelta(seconds=600)).isoformat() and state[busy]["waiting"] == 5
|
||||
|
||||
@@ -137,22 +137,144 @@ async def test_a_namesake_in_the_parent_does_not_share_the_key(monkeypatch, erp)
|
||||
|
||||
|
||||
async def test_a_write_recorded_before_the_upgrade_is_still_found(monkeypatch, erp):
|
||||
"""升级前子图里的写入按子图里的 id(pay)记幂等键。停在子图里的运行恢复时整个子图重跑:照旧按那个键找到已经生效的
|
||||
记录、交回回执,不重发。父图里有同名节点时不这样认(那一条可能是父图那个节点的)。"""
|
||||
"""升级前子图里的写入按子图里的 id(pay)记幂等键、记录上的 node_id 也是它;事件一向按这次运行里的 id(sub/pay)记。
|
||||
写入节点本身重新执行时(结果未知核实之后接着跑):事件说是这个节点的、内容指纹也是这一次的,照旧按那个键交回回执、不重发;
|
||||
事件说是父图同名节点的、或者内容不一样(跨升级的循环,第二轮按老算法也是第 1 次执行)的不认。"""
|
||||
from app.core.events import EventType
|
||||
from app.db.models import RunEvent
|
||||
|
||||
caps = install(monkeypatch, erp, await endpoint_row(test=True), lookup=False)
|
||||
async with SessionLocal() as session:
|
||||
run = Run(graph={"nodes": [node("sub", "subgraph")], "edges": []}, status="running", run_class="exploratory")
|
||||
session.add(run)
|
||||
await session.flush()
|
||||
legacy = actions.action_key(run.id, "pay", 1)
|
||||
session.add(ActionRecord(action_key=legacy, attempt=1, idem_key=f"{legacy}:1", run_id=run.id, node_id="pay",
|
||||
exec_no=1, capability=PAY, environment="test", payload_hash="0" * 64, state="confirmed",
|
||||
receipt={"summary": {"id": "PAY-OLD"}}))
|
||||
await session.commit()
|
||||
run_id = run.id
|
||||
args = {"amount": "5", "card": "6222020200112233"}
|
||||
digest = actions.payload_hash(PAY, actions.typed_args(caps[PAY], args))
|
||||
|
||||
async def old_record(writer: str, payload: str) -> tuple[str, str]:
|
||||
async with SessionLocal() as session:
|
||||
run = Run(graph={"nodes": [node("sub", "subgraph")], "edges": []}, status="running", run_class="exploratory")
|
||||
session.add(run)
|
||||
await session.flush()
|
||||
legacy = actions.action_key(run.id, "pay", 1)
|
||||
rec = ActionRecord(action_key=legacy, attempt=1, idem_key=f"{legacy}:1", run_id=run.id, node_id="pay",
|
||||
exec_no=1, capability=PAY, environment="test", payload_hash=payload, state="confirmed",
|
||||
receipt={"summary": {"id": "PAY-OLD"}})
|
||||
session.add(rec)
|
||||
await session.flush()
|
||||
session.add(RunEvent(run_id=run.id, seq=1, type=str(EventType.ACTION_PREPARED), node_id=writer, ts=0.0,
|
||||
data={"action_id": rec.id, "capability": PAY, "payload_hash": payload}))
|
||||
await session.commit()
|
||||
return run.id, legacy
|
||||
|
||||
run_id, legacy = await old_record("sub/pay", digest)
|
||||
found = await actions.prepare(run_id=run_id, node_id="sub/pay", exec_no=1, capability=caps[PAY], args=args,
|
||||
legacy_node_id="pay")
|
||||
assert found.key == legacy and found.receipt is not None
|
||||
fresh = await actions.prepare(run_id=run_id, node_id="sub/pay", exec_no=1, capability=caps[PAY], args=args)
|
||||
assert fresh.key != legacy and fresh.receipt is None
|
||||
# 父图里同名的 pay 按自己的键(和旧键一样)找到这一笔:事件说是 sub/pay 的,不是自己的,换一个只属于自己的键
|
||||
mine = await actions.prepare(run_id=run_id, node_id="pay", exec_no=1, capability=caps[PAY], args=args)
|
||||
assert mine.key != legacy and mine.receipt is None
|
||||
# 内容不一样(这一次是 7):不当成这一次已经生效
|
||||
other = await actions.prepare(run_id=run_id, node_id="sub/pay", exec_no=1, capability=caps[PAY],
|
||||
args={**args, "amount": "7"}, legacy_node_id="pay")
|
||||
assert other.key != legacy and other.receipt is None
|
||||
# 那一笔是父图 pay 写的(事件说 pay):子图里的 pay 不认
|
||||
run_id, legacy = await old_record("pay", digest)
|
||||
theirs = await actions.prepare(run_id=run_id, node_id="sub/pay", exec_no=1, capability=caps[PAY], args=args,
|
||||
legacy_node_id="pay")
|
||||
assert theirs.key != legacy and theirs.receipt is None
|
||||
own = await actions.prepare(run_id=run_id, node_id="pay", exec_no=1, capability=caps[PAY], args=args)
|
||||
assert own.key == legacy and own.receipt is not None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 跨升级:升级前的运行按老算法记的键,升级后接着跑
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _old_code(monkeypatch):
|
||||
"""装成升级前的代码:子工作流里的写入按子图里的 id 算幂等键、不认旧键,子图跑过的步骤不交回父图(exec 每次从 1 数)。
|
||||
交回一个函数,调了就换回现在的代码(同一个进程里接力跑,等于升级后重启接着跑;能力登记的替身要重新装)。"""
|
||||
from app.engine.nodes import multi, tools
|
||||
|
||||
monkeypatch.setattr(tools, "_action_ids", lambda ctx: (ctx.node.id, None))
|
||||
monkeypatch.setattr(multi, "_forwarded_trail", lambda ran, prefix: [])
|
||||
return monkeypatch.undo
|
||||
|
||||
|
||||
def _amounts(erp) -> list[str]:
|
||||
return [str((w.get("body") or {}).get("amount")) for w in erp.writes("test")]
|
||||
|
||||
|
||||
async def _until_status(run_id: str, statuses: tuple[str, ...]) -> Run:
|
||||
for _ in range(600):
|
||||
await asyncio.sleep(0.05)
|
||||
async with SessionLocal() as session:
|
||||
row = await session.get(Run, run_id)
|
||||
if row.status in statuses and not run_manager.is_active(run_id):
|
||||
return row
|
||||
raise AssertionError(f"运行 {run_id} 停在 {row.status}")
|
||||
|
||||
|
||||
async def test_an_unknown_write_inside_a_subworkflow_with_a_namesake_in_the_parent_survives_the_upgrade(monkeypatch, erp):
|
||||
"""升级前:子工作流里的 pay(1)发出去、结果未知、运行挂起,键按子图里的 id(pay)记——和父图里同名的 pay 一样。升级后
|
||||
核实为已生效、接着跑:子图里的 pay 认出自己那一笔(事件记着是「sub/pay」写的),不再发;父图的 pay(99)按自己的键找到
|
||||
的是子图那一笔、不是自己的,换用自己的键照常发。1 和 99 各只发一次。"""
|
||||
from app.db.models import Task
|
||||
from app.process import tasks
|
||||
from tests.test_process_actions import ANN
|
||||
|
||||
await _unattended(monkeypatch, erp)
|
||||
inner = S.chain(node("s", "input", fields=[{"name": "n"}]), pay(),
|
||||
node("o", "output", fields=[{"name": "r", "value": "x"}]))
|
||||
sub_id = await S.publish("付款子流程", graph=inner, level="draft")
|
||||
graph = S.chain(node("start", "input", fields=[{"name": "n"}]),
|
||||
node("sub", "subgraph", "子流程", workflow_id=sub_id, workflow_version=1, input={"n": "1"}),
|
||||
pay(), node("out", "output", fields=[{"name": "r", "value": "完成"}]))
|
||||
upgrade = _old_code(monkeypatch)
|
||||
erp.mode = "timeout_after_write"
|
||||
run = await run_manager.start(graph=graph, input_payload={"n": 99})
|
||||
parked = await _until_status(run.id, ("interrupted", "failed", "succeeded"))
|
||||
assert parked.status == "interrupted", parked.error
|
||||
assert _amounts(erp) == ["1"]
|
||||
upgrade()
|
||||
await _unattended(monkeypatch, erp) # undo 连能力登记的替身一起撤了:重新装上
|
||||
erp.mode = "ok"
|
||||
async with SessionLocal() as session:
|
||||
task = (await session.execute(select(Task).where(Task.run_id == run.id, Task.kind == "reconcile",
|
||||
Task.status == "pending"))).scalars().first()
|
||||
assert task is not None
|
||||
await tasks.respond(task.id, ANN, decision="confirmed", values={"external_id": "PAY-1"})
|
||||
final = await _until_status(run.id, ("succeeded", "failed"))
|
||||
assert final.status == "succeeded", final.error
|
||||
assert sorted(_amounts(erp)) == ["1", "99"], _amounts(erp)
|
||||
|
||||
|
||||
async def test_a_loop_across_the_upgrade_sends_the_second_round(monkeypatch, erp):
|
||||
"""升级前跑完第一轮(子图里 pay 发了 1)、停在父图的审批上;升级后批准,第二轮按老算法同样是第 1 次执行,找到第一轮那一笔:
|
||||
内容指纹不一样(这一轮是 2),不当成已经生效,照常发;也不记「已经生效」的提示。"""
|
||||
await _unattended(monkeypatch, erp)
|
||||
inner = S.chain(node("s", "input", fields=[{"name": "n"}]), pay(),
|
||||
node("o", "output", fields=[{"name": "r", "value": "x"}]))
|
||||
sub_id = await S.publish("付款子流程", graph=inner, level="draft")
|
||||
nodes = [node("start", "input"),
|
||||
node("each", "loop", "逐项", mode="foreach", items="[1, 2]", item_var="it", max_iterations=5),
|
||||
node("sub", "subgraph", "子流程", workflow_id=sub_id, workflow_version=1, input={"n": "{{ vars.it }}"}),
|
||||
node("gate", "human", "把关", mode="approve", title="下一轮?"),
|
||||
node("out", "output", fields=[{"name": "r", "value": "完成"}])]
|
||||
edges = [edge("start", "each"), edge("each", "sub", "body"), edge("sub", "gate"), edge("gate", "each", "approved"),
|
||||
edge("each", "out", "done")]
|
||||
upgrade = _old_code(monkeypatch)
|
||||
run = await run_manager.start(graph={"nodes": nodes, "edges": edges}, input_payload={})
|
||||
parked = await _until_status(run.id, ("interrupted", "failed", "succeeded"))
|
||||
assert parked.status == "interrupted", parked.error
|
||||
assert _amounts(erp) == ["1"]
|
||||
upgrade()
|
||||
await _unattended(monkeypatch, erp)
|
||||
await run_manager.resume(run.id, {"approved": True})
|
||||
parked = await _until_status(run.id, ("interrupted", "failed", "succeeded"))
|
||||
assert parked.status == "interrupted", parked.error
|
||||
assert _amounts(erp) == ["1", "2"], "第二轮照发,不交回第一轮的回执"
|
||||
assert not [e for e in await _events(run.id, "log") if e.data.get("code") == "action_already_applied"]
|
||||
await run_manager.resume(run.id, {"approved": True})
|
||||
final = await _until_status(run.id, ("succeeded", "failed"))
|
||||
assert final.status == "succeeded", final.error
|
||||
assert _amounts(erp) == ["1", "2"]
|
||||
|
||||
@@ -79,6 +79,8 @@ export function RecallPreviewRow({ workflowId, version }: { workflowId: string;
|
||||
|
||||
const STATE_COLOR: Record<string, string | undefined> = {
|
||||
changed: 'var(--st-waiting)', failed: 'var(--err)', not_recomputable: 'var(--text-faint)', unchanged: 'var(--ok)',
|
||||
// 核对不了(行序不定):要人去核对,和「数变了」同一种提醒色
|
||||
row_order: 'var(--st-waiting)',
|
||||
}
|
||||
/** 列表先列几组,其余收起 */
|
||||
const SHOWN = 6
|
||||
|
||||
@@ -133,6 +133,8 @@ export const LEDGER_UI = {
|
||||
recall: {
|
||||
/** 口径升版后在同一份数据上重算过、数没变 */
|
||||
checked: (caliber: string) => `已按口径${caliber} 核对,不影响`,
|
||||
/** 部分指标行序不定:那几项没能核对,照实列出 */
|
||||
checkedRowOrder: (names: string[]) => `其中${names.map((x) => `「${x}」`).join('、')}行序不定,没能核对`,
|
||||
/** 依据有变:目录里影响数字的项被驳回或修改、出现新码值、处理变化时改了目录(目录类),表或列被删、类型变了(表结构) */
|
||||
basis: (what: 'catalog' | 'structure' | 'other', items: number) =>
|
||||
`${what === 'structure' ? '依据的表结构有变' : what === 'catalog' ? '依据的数据目录有变' : '答案的依据有变'}`
|
||||
|
||||
@@ -58,6 +58,7 @@ export const RECALL_TEXT = {
|
||||
const parts = [
|
||||
c.changed ? `数变了 ${n(c.changed)} 组` : '',
|
||||
c.unchanged ? `没变 ${n(c.unchanged)} 组` : '',
|
||||
c.row_order ? `核对不了(行序不定)${n(c.row_order)} 组` : '',
|
||||
c.running ? `正在重算 ${n(c.running)} 组` : '',
|
||||
c.failed ? `重算失败 ${n(c.failed)} 组` : '',
|
||||
c.not_recomputable ? `无法重算 ${n(c.not_recomputable)} 组` : '',
|
||||
@@ -65,7 +66,8 @@ export const RECALL_TEXT = {
|
||||
return parts.join(',')
|
||||
},
|
||||
state: {
|
||||
changed: '数变了', unchanged: '没变', failed: '重算失败', running: '正在重算', pending: '未重算',
|
||||
changed: '数变了', unchanged: '没变', row_order: '核对不了(行序不定)', failed: '重算失败', running: '正在重算',
|
||||
pending: '未重算',
|
||||
not_recomputable: '无法重算',
|
||||
} as Record<string, string>,
|
||||
reason: (state: string, reason: string) => (state === 'not_recomputable' ? `无法重算:${reason}` : reason),
|
||||
|
||||
@@ -127,7 +127,12 @@ export function RecallMarks({ recall }: { recall?: LedgerRecallMarks }) {
|
||||
{[...checked.values()].map((c) => (
|
||||
<li key={`c-${c.workflow_id ?? ''}`} className="flex items-start gap-1.5 text-dim" data-ledger-recall-checked={c.to_version ?? ''}>
|
||||
<CircleCheck size={11} className="mt-[2px] shrink-0 text-faint" aria-hidden />
|
||||
<span>{R.checked(caliberName(c.workflow_name, c.to_version))}</span>
|
||||
<span>
|
||||
{R.checked(caliberName(c.workflow_name, c.to_version))}
|
||||
{!!c.row_order_metrics?.length && (
|
||||
<span className="ml-1" data-ledger-recall-row-order="">{R.checkedRowOrder(c.row_order_metrics)}</span>
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
{[...basis.entries()].map(([group, b]) => (
|
||||
|
||||
@@ -220,6 +220,8 @@ export interface LedgerRecallMarks {
|
||||
recall_run?: string | null
|
||||
changed?: boolean
|
||||
at?: string | null
|
||||
/** 行序不定、没能核对的那几项(服务端 ledger/recall.settle):不算进「不影响」 */
|
||||
row_order_metrics?: string[]
|
||||
}[]
|
||||
/** 依据有变:kind 是 catalog(目录里影响数字的项被驳回或修改)/ structure(表结构)/ values(新码值)/ catalog_edit */
|
||||
basis?: { kind?: string | null; items?: number; new_errors?: number; at?: string | null }[]
|
||||
|
||||
@@ -42,7 +42,8 @@ export interface RecallPreview {
|
||||
reasons: { reason: string; groups: number }[]
|
||||
}
|
||||
|
||||
export type RecallGroupState = 'changed' | 'unchanged' | 'failed' | 'running' | 'pending' | 'not_recomputable'
|
||||
/** row_order:全部指标行序不定、核对不了(服务端 ledger/recall.settle),已开待办请人核对 */
|
||||
export type RecallGroupState = 'changed' | 'unchanged' | 'row_order' | 'failed' | 'running' | 'pending' | 'not_recomputable'
|
||||
|
||||
/** 召回批次里的一组 */
|
||||
export interface RecallGroup {
|
||||
|
||||
Reference in New Issue
Block a user