Skip to content

跨一次断电的审批:checkpoint 与幂等键 ​

最棘手的重试发生在工具已经改完对象、助手却没收到成功响应的时候。此刻任务数据库和集群各知道一半事实;从任何一方单独看,都不够判断能不能再写一次。

sequenceDiagram
Reviewer->>Graph: 批准已展示的计划
Graph->>Tool: 携带 UID 和操作编号
Tool->>API: test 后提交 Patch
API-->>Tool: 写入已生效
Tool--xGraph: 回复丢失
Graph->>Tool: 用同一操作编号重试
Tool-->>Graph: 读回标记再验证

用户正在看修复计划时服务重启,要找回“谁在等签字”。工具成功后回复丢失,要知道“这个副作用是不是已经发生”。二者不能由同一个布尔值解决。前者由 LangGraph 将状态和下一节点按 thread_id 存盘;后者要让目标系统本身能认出重复操作。

FastAPI 入口提供 POST /runs、GET /runs/{id} 和 POST /runs/{id}/decision。read key 可发起/查看诊断,review key 才能批准;StrictBool 拒绝字符串“false”被当做真值。SQLite 记录审批角色、任务 ID、时间和决策;这是实验级角色审计,不是实名审计系统。并发执行由进程锁串行化,启动一个 worker。跨副本需要数据库租约/行级锁加状态版本,不能多台进程共享 sqlite 文件。

sh
uv pip install --python .venv/bin/python -r opspilot/cluster/requirements.lock
# 先在本机设置 OPS_READ_KEY、OPS_REVIEW_KEY 两个不同随机密钥
.venv/bin/uvicorn api:app --app-dir opspilot/cluster --host 127.0.0.1 --port 18190

可选 OPS_CHECKPOINT_DSN 切换到 PostgreSQL 的 PostgresSaver;审批审计和经验库仍在本机 SQLite,此开关不等于整个服务可水平扩展。OPS_TRACE_FILE 输出本地 OpenTelemetry spans,或配置 OTEL_EXPORTER_OTLP_ENDPOINT 发给指定 collector。span 只记录 run.id 和证据数,不携带 token 和完整日志。

CLI 与 API 是同一检查点数据库的两个入口;实验时选其一,勿对同一 run 并发审批。单机网页仍使用原有 /ops/api/runs,模型 JSON 模拟后端不会被悄悄换成真实集群写操作。

完整源码:opspilot/cluster/tools_live.py · 第 49–64 行

python
def patch_plan(d,uid,generation,operation):
    m=d["metadata"];spec=d["spec"]["template"]["spec"]
    if m["uid"]!=uid:raise ValueError("Deployment replaced")
    if m.get("annotations",{}).get("lab.modeldock.dev/last-operation")==operation:return []
    if m["generation"]!=generation or spec.get("nodeSelector")!={"lab.invalid/pool":"absent"}:raise ValueError("stale or out-of-policy plan")
    return [{"op":"test","path":"/metadata/uid","value":uid},{"op":"test","path":"/metadata/resourceVersion","value":m["resourceVersion"]},
            {"op":"test","path":"/spec/template/spec/nodeSelector","value":{"lab.invalid/pool":"absent"}},
            {"op":"remove","path":"/spec/template/spec/nodeSelector"},
            {"op":"add","path":"/metadata/annotations","value":{**m.get("annotations",{}),"lab.modeldock.dev/last-operation":operation}}]

@mcp.tool()
def release_selector(uid:str,generation:int,operation:str)->dict:
    """Only remove the exact injected selector from the dedicated lab Deployment."""
    d=json.loads(kube("get","deploy",NAME,"-o","json",writer=True))
    patch=patch_plan(d,uid,generation,operation)
    if patch:kube("patch","deploy",NAME,"--type=json","-p",json.dumps(patch),writer=True)

用时序辨认四种结局 ​

故障点恢复方式不变量
approval 前崩溃用同一 run ID 取快照,不自动同意不调用写工具
批准后目标已换 UID拒绝旧计划,重新收集证据不修改同名新对象
写成功响应丢失读回目标注解中的 operation ID同一任务只移除一次指定 selector
patch 成功但新 Pod 未 Readyverification_failed,保存观察供人处理不把成功经验写入 memory

同一 API 调用中 test UID、resourceVersion、精确 selector 再 remove,才能缩小读写时间窗;先 GET 再无条件 PATCH 仍可能覆盖其他控制器修改。

sh
.venv/bin/python -m unittest discover -s opspilot/tests -v

真实 SQLite 测试关闭连接后重新创建 Engine,工具替身故意先写再抛异常,然后恢复;检查 generation 只增加一次。另一个测试覆盖错误 UID、冲突与重放。这里工具是替身,真实集群回读见下一章。

PostgreSQL 检查点与审计边界 ​

如果已有一个专用 PostgreSQL,可给本地 Python 运行时设置 OPS_CHECKPOINT_DSN。也可启动一次性本机容器(需 Docker 和空闲端口);下面用随机密码,数据库只绑定回环接口:

sh
study_pg="cloudlab-checkpoints-$(date +%s)"
study_pass="$(openssl rand -hex 20)"
docker run -d --name "$study_pg" -p 127.0.0.1:19432:5432 -e POSTGRES_DB=ops -e POSTGRES_PASSWORD="$study_pass" postgres:17-alpine
docker exec "$study_pg" pg_isready -U postgres -d ops
export OPS_CHECKPOINT_DSN="postgresql://postgres:${study_pass}@127.0.0.1:19432/ops"
# 待 pg_isready 接受连接,用同一个 DSN 运行 start 和 show
.venv/bin/python opspilot/cluster/run.py start > tmp/pg-run.json
run_id="$(python3 -c "import json;print(json.load(open('tmp/pg-run.json'))['id'])")"
.venv/bin/python opspilot/cluster/run.py show "$run_id"
# 本次实验任务完成后;移除临时数据库会丢失这份实验检查点
unset OPS_CHECKPOINT_DSN study_pass
docker rm -fv "$study_pg"

连接池或凭据错误应先在创建检查点时暴露,不能静默切回 SQLite 造成“任务消失”。PostgresSaver.setup 初始化它自己的表;本地 role 审计与已验证记忆并没有随之迁移。要支持多进程同时审批,还需任务租约或 CAS 状态更新、唯一操作号和集中审计,不能仅把 sqlite 文件换成 PostgreSQL。

用 HTTP 复核读者与审批者的边界 ​

集群 API 用 POST /runs 开任务、GET /runs/{id} 看快照、POST /runs/{id}/decision 决定。下面在本机终端运行;先按 Pending 章节准备夹具,再启动单个 uvicorn worker。将同一组随机密钥传给同一终端的客户端与服务端,避免新终端缺少环境变量。

sh
export OPS_READ_KEY="$(openssl rand -hex 24)" OPS_REVIEW_KEY="$(openssl rand -hex 24)"
.venv/bin/uvicorn api:app --app-dir opspilot/cluster --host 127.0.0.1 --port 18190 &
api_pid=$!
# 等待终端显示启动完成
run_id="$(curl -fsS -X POST -H "Authorization: Bearer $OPS_READ_KEY" http://127.0.0.1:18190/runs | python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])')"
curl -fsS -H "Authorization: Bearer $OPS_READ_KEY" "http://127.0.0.1:18190/runs/$run_id"
curl -s -o /dev/null -w '%{http_code}\n' -H "Authorization: Bearer $OPS_READ_KEY" -H 'Content-Type: application/json' -d '{"approved":false}' "http://127.0.0.1:18190/runs/$run_id/decision"
curl -fsS -H "Authorization: Bearer $OPS_REVIEW_KEY" -H 'Content-Type: application/json' -d '{"approved":false}' "http://127.0.0.1:18190/runs/$run_id/decision"
kill "$api_pid"
unset OPS_READ_KEY OPS_REVIEW_KEY

reader 的决策请求预期 403,reviewer 提交 false 后任务为 rejected;写操作不应发生。这是角色级实验审计,而非个人实名审计。拒绝不会修复夹具,结束时按 Pending 章节发起新的任务并批准,或回收专属夹具。

本轮用一次性 PostgreSQL 17 保存有 GPU 不足证据的任务,另建图实例后按原 thread_id 读回 gpu_insufficient/report_only。HTTP 权限则用真实 Kind 故障分别验证 reader 被拒、reviewer 拒绝修复,以及 reviewer 批准后恢复。两项测试分别证明存储恢复和权限控制,不合并解读为多机容灾。