Files

286 lines
16 KiB
Python
Raw Permalink 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.
"""按新口径补算(D-f;设计文档 §3.4 趋势那一行、§2.10 X3 的 app_backfill)。
钉住项升版时选了 recompute(按新口径重算),趋势只画新版本的数:升版之后才有的那一两期孤零零的,历史全空着。补算就是
把旧版本在同一来源上算过的那几期,按新版本重新发起一遍正式运行:
- 只对读定时任务或正式运行的视图(latest_sealed):同一来源才说得清「那几期」是哪几期;
- 只在这个视图的钉住项最近一次升版选了 recompute 时:选 dual、incomparable 的本来就照旧画旧版本的数;
- 只补没有外部副作用的工作流(_side_effect):钉作流程的、图里(含子工作流)有人工节点的、调用工具节点绑了写外部系统
能力的、按流程使用会出事(process.*:Agent 中途等审批、联网的代码节点)的一律拒。补算一次最多发起 SERIES_MAX 次正式
运行,对着历史输入逐个给人开待办、往外部系统重发写入,这些都收不回来,生效记录还会挡住以后删运行、删工作流;
- 取旧版本在同一来源(同一个定时任务,或 formal)上最近 N 期已封存的正式运行的输入(N 不超过视图上趋势的 last,
最多 SERIES_MAX)。旧版本自己补算过的那几期也算(trend_runs):连着升过两次版的,历史一样接得上;
- 新版本已经算过同一个输入的跳过:补算过的(没失败的),以及来源本身已经按新版本跑过的那一期;
- 用新版本逐个发起正式运行:来源 app_backfill、按钉住的 graph_hash 核对(expect_hash)、带 app_id / app_version,
origin 里记补的是哪个来源、哪一次、那一期的时间(period_at),趋势按它排(resolve.period_of)。
补算的运行和别的正式运行一样封存、一样能点开证据;它不是「最近一次」(resolve.sealed_runs 不取它),只是趋势上的点。
同一个视图的补算在进程内串行(_lock):锁里重新算一遍计划再发起,并发点两次、确认框弹着时别人先补过的,后来的那次
只会跳过。部署是单进程(调度器、运行管理都在进程里),进程内的锁就够。
"""
from __future__ import annotations
import asyncio
from dataclasses import dataclass, field
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.apps.pins import load_pins
from app.apps.resolve import (BACKFILL_ORIGIN, SERIES_MAX, input_key, period_of, pin_upgrade, sealed_runs,
trend_runs)
from app.apps.tree import components
from app.db.models import Run
from app.db.models_apps import AppVersion
#: 补算被拒的机器码
BACKFILL_REFUSED = "app_backfill_refused"
#: 趋势没写 last 时取几期(同 resolve._series)
SERIES_DEFAULT = 12
_LIVE = ("queued", "running", "interrupted", "succeeded")
class BackfillRefused(Exception):
def __init__(self, status_code: int, message: str, code: str | None = BACKFILL_REFUSED) -> None:
super().__init__(message)
self.status_code = status_code
self.message = message
self.code = code
@dataclass
class Plan:
view: str
alias: str
source: str
from_version: int
to_version: int
#: 这个视图上的趋势最多画几期
periods: int
#: 要发起的:旧版本那几期的运行,按输入期从早到晚
todo: list[Run] = field(default_factory=list)
skipped: int = 0
def out(self) -> dict[str, Any]:
return {"view": self.view, "alias": self.alias, "source": self.source, "from_version": self.from_version,
"to_version": self.to_version, "periods": self.periods, "will_launch": len(self.todo),
"will_skip": self.skipped}
def series_periods(spec: dict[str, Any], view_id: str) -> int:
"""这个视图上的趋势最多画几期:各趋势组件 last 的最大值(没写按 SERIES_DEFAULT),不超过 SERIES_MAX。没有趋势组件
返回 0。"""
wanted = 0
for comp, _, _ in components(spec):
bind = comp.get("bind") if isinstance(comp.get("bind"), dict) else None
if not bind or bind.get("view") != view_id or "series" not in bind:
continue
last = bind.get("last")
n = last if isinstance(last, int) and not isinstance(last, bool) and last > 0 else SERIES_DEFAULT
wanted = max(wanted, n)
return min(wanted, SERIES_MAX)
async def _upgrade(session: AsyncSession, app: Any, version: AppVersion, view_id: str
) -> tuple[dict[str, Any], Any, dict[str, Any], str] | None:
"""(视图来源, 新版本的钉住状态, 升版前的钉住项, 来源);这个视图不需要补算时返回 None。拒绝的情形抛 BackfillRefused。"""
spec = version.spec or {}
view = (spec.get("views") or {}).get(view_id)
src = (view or {}).get("source") if isinstance(view, dict) else None
if not isinstance(src, dict):
raise BackfillRefused(404, f"应用里没有视图「{view_id}」", "app_view_not_found")
if src.get("kind") != "latest_sealed":
raise BackfillRefused(409, "只有读定时任务或正式运行的视图能按新口径补算:按需运行、实例视图没有「历次」可补")
alias = str(src.get("pin") or "")
previous, disposition = await pin_upgrade(session, app.id, version, alias)
if previous is None or disposition != "recompute":
return None
state = (await load_pins(session, spec)).get(alias)
if state is None or not state.usable:
raise BackfillRefused(409, "视图固定的工作流版本已退役或不可用,应用需要升级后才能补算", "app_needs_upgrade")
if reason := await _side_effect(session, state):
raise BackfillRefused(409, f"「{state.label}」{reason},补算会对历史输入逐个重做这些事、无法撤回,这类工作流不能"
"按新口径补算")
return src, state, previous, str(src.get("from") or "formal")
async def _side_effect(session: AsyncSession, state: Any) -> str | None:
"""新版本的图补算时会不会碰到人或外部系统:会的交回一句原因(接在「「周报 v3」」后面),不会的返回 None。
作为流程固定的一律算;别的按图判(graph_side_effect)。"""
if state.pin.get("use") == "process":
return "是作为流程固定的"
return await graph_side_effect(session, state.graph, level=state.level)
async def graph_side_effect(session: AsyncSession, graph_json: Any, *, level: str | None = None) -> str | None:
"""一张图替人重跑一次会不会碰到人或外部系统:会的交回一句原因(接在「「周报 v3」」后面),不会的返回 None。按新口径
补算(这里)和按口径正式出具(ledger/issue.py:自动出具、一键出具都不代人做这些事)共用这一个判断。
判断沿用应用检查对流程钉住项的那一套(apps/lint._lint_process):人工节点、写外部系统的能力
(capabilities.registry.writes_externally,和运行时、发布检查同一个判断)、process.* 问题(子工作流先读好,
process.human_in_subgraph 才查得到)。level 是那一版发布时的级别(门禁按它查),没有按 published。"""
from app.engine.governance import publish_issues
from app.engine.lint_process import load_subgraphs, with_subgraphs
from app.engine.schema import GraphSpec, NodeType
try:
graph = GraphSpec.model_validate(graph_json)
except ValueError:
return "的图无法解析"
if humans := [n.title for n in graph.nodes if n.type == NodeType.HUMAN]:
return f"里有人工节点「{'、'.join(humans[:3])}」"
if reason := await _external_write(session, graph):
return reason
with with_subgraphs(await load_subgraphs(session, graph)):
issues = publish_issues(graph, level=level or "published")
if process := [i for i in issues if (i.code or "").startswith("process.")]:
return f"按流程使用不安全:{process[0].message.rstrip('。')}"
return None
async def graph_external_write(session: AsyncSession, graph_json: Any) -> str | None:
"""一张图里有没有调用工具节点会写外部系统(graph_side_effect 里的那一条):有的交回一句原因,没有返回 None。
口径召回(ledger/recall)只用这一条:召回图是从口径卡往上派生的取数和口径卡,图里别处的人工节点、按流程使用的问题
碰不到它(细案做法 7 不重算的只有缺入口字段、数据部分不能复用、会写外部系统三种);替人整张图重跑的补算、按口径出具
仍然走 graph_side_effect 全套。"""
from app.engine.schema import GraphSpec
try:
graph = GraphSpec.model_validate(graph_json)
except ValueError:
return "的图无法解析"
return await _external_write(session, graph)
async def _external_write(session: AsyncSession, graph: Any) -> str | None:
from app.capabilities.registry import effective_effect, get_capability, writes_externally
from app.engine.schema import NodeType
for node in graph.nodes:
name = node.config.get("tool") if node.type == NodeType.TOOL else None
if not isinstance(name, str) or not name:
continue
cap = await get_capability(session, name)
if cap is not None and writes_externally(cap.name, effective_effect(cap), cap.endpoint_id):
return f"的「{node.title}」会写外部系统({name})"
return None
async def plan(session: AsyncSession, app: Any, version: AppVersion, view_id: str) -> Plan:
"""这个视图补算会发起几次、跳过几次(不发起任何运行)。"""
found = await _upgrade(session, app, version, view_id)
if found is None:
raise BackfillRefused(409, "这个视图固定的版本最近一次升版没有选「按新口径重算」,不需要补算")
src, state, previous, source = found
periods = series_periods(version.spec or {}, view_id)
if periods <= 0:
raise BackfillRefused(409, "这个视图上没有趋势组件,补算出来的历史没有地方展示")
out = Plan(view=view_id, alias=state.alias, source=source, from_version=int(previous.get("version")),
to_version=int(state.pin["version"]), periods=periods)
origin = src.get("from")
old_runs = await trend_runs(session, app.id, previous.get("workflow_id"), previous.get("version"),
previous.get("graph_hash"), origin, periods)
done = await _computed(session, app.id, state, origin, source)
for run in old_runs:
key = input_key(run.input)
if key in done:
out.skipped += 1
continue
done.add(key)
out.todo.append(run)
return out
async def _computed(session: AsyncSession, app_id: str, state: Any, origin: str | None, source: str) -> set[str]:
"""新版本已经算过(或正在算)的输入:这个应用为同一来源补算过、没失败的;来源本身按新版本跑过的。"""
pin = state.pin
keys = {input_key(r.input) for r in await sealed_runs(session, pin["workflow_id"], pin["version"],
pin.get("graph_hash"), origin, SERIES_MAX * 2)}
rows = (await session.execute(select(Run.input, Run.origin).where(
Run.app_id == app_id, Run.origin_kind == BACKFILL_ORIGIN, Run.workflow_id == pin["workflow_id"],
Run.version == pin["version"], Run.version_hash == pin.get("graph_hash"), Run.status.in_(_LIVE)))).all()
keys |= {input_key(inputs) for inputs, meta in rows if (meta or {}).get("source") == source}
return keys
async def run_backfill(session: AsyncSession, app: Any, version: AppVersion, view_id: str, *, principal: Any,
actor: str | None) -> dict[str, Any]:
"""按计划逐个发起正式运行。返回 {launched, skipped, run_ids, ...plan}。
一次也没发起成(发起被拒,比如口径升版没处置)时原样抛 LaunchRefused;发起了几次之后被拒的,已经发起的照常算,
原因写进 refused 交回。"""
async with _lock(app.id, view_id):
return await _run_backfill(session, app, version, view_id, principal=principal, actor=actor)
#: (应用 id, 视图) → 补算锁。只增不减:一个应用的视图就那么几个
_LOCKS: dict[tuple[str, str], asyncio.Lock] = {}
def _lock(app_id: str, view_id: str) -> Any:
"""跨进程的锁(app/cluster/mutex):同一个视图的补算多进程时也只跑一份。"""
from app.cluster import mutex
return mutex.lock(f"app-backfill:{app_id}:{view_id}")
async def _run_backfill(session: AsyncSession, app: Any, version: AppVersion, view_id: str, *, principal: Any,
actor: str | None) -> dict[str, Any]:
from app.engine.launch import LaunchRefused, launch_run
# 计划在锁里算:前一次补算发起的运行已经提交,这里读得到、按「已经补算过」跳过
todo = await plan(session, app, version, view_id)
pin = (await load_pins(session, version.spec or {}))[todo.alias].pin
run_ids: list[str] = []
refused: str | None = None
for old in todo.todo:
period = period_of(old)
try:
run = await launch_run(
session, workflow_id=pin["workflow_id"], run_class="formal", version=pin["version"],
expect_hash=pin.get("graph_hash"), input_payload=dict(old.input or {}), actor=actor,
origin_kind=BACKFILL_ORIGIN, app_id=app.id, app_version=version.version,
origin={"view": view_id, "spec_hash": version.spec_hash, "source": todo.source,
"backfill_of": old.id, "from_version": todo.from_version,
"period_at": period.isoformat() if period else None,
"actor_verified": bool(getattr(principal, "verified", False)),
"auth_mode": getattr(principal, "mode", "off")})
except LaunchRefused as e:
if not run_ids:
raise
refused = e.message
break
run_ids.append(run.id)
from app.apps.audit import record
await record(session, action="app.backfill", subject_kind="app", subject_id=app.id, actor=actor,
data={**todo.out(), "launched": len(run_ids), "run_ids": run_ids, "app_version": version.version})
await session.commit()
return {**todo.out(), "launched": len(run_ids), "skipped": todo.skipped, "run_ids": run_ids,
**({"refused": refused} if refused else {})}
async def backfillable(session: AsyncSession, app: Any) -> list[dict[str, Any]]:
"""当前发布版里能按新口径补算的视图,各自会发起几次(编辑器、应用页上的「按新口径补算历史」按钮用)。只读。"""
if not app.published_version:
return []
version = (await session.execute(select(AppVersion).where(
AppVersion.app_id == app.id, AppVersion.version == app.published_version))).scalar_one_or_none()
if version is None:
return []
out: list[dict[str, Any]] = []
for view_id, view in ((version.spec or {}).get("views") or {}).items():
source = (view or {}).get("source") if isinstance(view, dict) else None
if not isinstance(source, dict) or source.get("kind") != "latest_sealed":
continue
try:
found = await _upgrade(session, app, version, view_id)
if found is None or series_periods(version.spec or {}, view_id) <= 0:
continue
out.append((await plan(session, app, version, view_id)).out())
except BackfillRefused:
continue
return out