fix(session-mechanism/graph): 并发写不丢状态,测试不再被批量删除守卫拦

- graphctl.py 并发修复
  · 临时名原来固定成 graph.json.tmp ⇒ 两进程同时写直接撞 PermissionError(桌面实测第 14 条)
  · 新增 _update():读最新状态 → 重放本次变更 → 原子替换 → 回读核对 _rev,
    被覆盖则重读重做(最多 3 次),仍失败就报错退出
  · 修掉更隐蔽的一层:后写的整份覆盖前写会让节点状态静默消失
  · start / report 改为走 _update,业务校验放在 mutate 内
- test_graphctl.py
  · 每例只新建自己的目录、跑完不删,整轮收尾清一次 ⇒ 不再触发沙箱「批量删除」守卫
    (此前 49 条里 20 条报 ERROR,全部是本机策略拦清理,用例本身零失败)
  · 新增 T9_Concurrency 三个用例:并发开工、并发回报、版本号每次写都前进
  · 49 → 52 条,实跑 Ran 52 tests OK(13 s)
- README:补「并发写是怎么保证的」三层说明,测试数与用时更新
This commit is contained in:
admin committed 2026-10-19 20:09:24 +08:00
1 parent 4866ad8d37
commit 42a130e4f2
3 files changed
+136 -26

No files matched your search

+58 -22
View File
@@ -63,6 +63,7 @@ import argparse
import io
import json
import os
import random
import sys
import time
from pathlib import Path
@@ -92,13 +93,46 @@ def _load(ws: str, gid: str) -> dict:
def _save(ws: str, gid: str, g: dict) -> None:
"""整份写回(新建图时用)。临时名带 pid + 随机数 ⇒ 两进程不会撞同一个名字。"""
p = _gdir(ws, gid) / "graph.json"
tmp = p.with_suffix(".json.tmp")
tmp = p.with_suffix(".%d.%d.json.tmp" % (os.getpid(), random.randint(0, 1 << 30)))
tmp.write_text(json.dumps(g, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8", newline="\n")
os.replace(tmp, p) # 原子替换,避免半截文件
def _update(ws: str, gid: str, mutate, retries: int = 3) -> dict:
"""读-改-写,带版本号乐观重试(2026-10-19 修两处实测缺陷)。
① 临时名原来固定成 `graph.json.tmp` ⇒ 两进程同时写直接撞 `PermissionError`
(桌面测试报告第 14 条实测:同时对 C、D 执行 start 就复现)。
② 更隐蔽的一层:就算临时名不撞,两人同时读-改-写会**丢更新** ——
后写的整份覆盖前写的,节点状态静默消失,连报错都没有。
③ 对策:图里带 `_rev`,每次在**最新磁盘状态**上重放变更,写完回读核对;
被别的进程覆盖就重读重做,最多 `retries` 次,仍失败则**报错退出**(⛔ 不静默)。
⚠️ 本机不用 OS 文件锁(实测会卡死),靠「唯一临时名 + 原子替换 + 回读核对」。
"""
p = _gdir(ws, gid) / "graph.json"
if not p.is_file():
raise SystemExit("找不到图:%s" % p)
for _ in range(retries):
g = json.loads(p.read_text(encoding="utf-8"))
rev = int(g.get("_rev") or 0)
mutate(g)
g["_rev"] = rev + 1
tmp = p.with_suffix(".%d.%d.json.tmp" % (os.getpid(), random.randint(0, 1 << 30)))
tmp.write_text(json.dumps(g, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8", newline="\n")
os.replace(tmp, p)
try:
back = json.loads(p.read_text(encoding="utf-8"))
except Exception: # noqa: BLE001
back = {}
if int(back.get("_rev") or 0) == rev + 1:
return back
raise SystemExit("并发冲突:%s 连续 %d 次被别的进程覆盖,稍后重试" % (p, retries))
def _event(ws: str, gid: str, obj: dict) -> None:
p = _gdir(ws, gid) / "events.jsonl"
obj = dict(obj)
@@ -258,35 +292,37 @@ def cmd_ready(a) -> int:
# ── 状态流转 ────────────────────────────────────────────────────────────
def cmd_start(a) -> int:
g = _load(a.ws, a.gid)
n = (g.get("nodes") or {}).get(a.node)
if not n:
raise SystemExit("没有这个节点:%s" % a.node)
if str(n.get("status")) == "running":
raise SystemExit("节点 %s 已在跑,不能重复开工" % a.node)
if str(n.get("status")) == "done":
raise SystemExit("节点 %s 已完成,不能开工" % a.node)
# pending 与 blocked 都可开工:blocked 是「这轮没过」,重试时 attempts 保留
n["status"] = "running"
_save(a.ws, a.gid, g)
def _mut(g):
n = (g.get("nodes") or {}).get(a.node)
if not n:
raise SystemExit("没有这个节点:%s" % a.node)
if str(n.get("status")) == "running":
raise SystemExit("节点 %s 已在跑,不能重复开工" % a.node)
if str(n.get("status")) == "done":
raise SystemExit("节点 %s 已完成,不能开工" % a.node)
# pending 与 blocked 都可开工:blocked 是「这轮没过」,重试时 attempts 保留
n["status"] = "running"
_update(a.ws, a.gid, _mut)
_event(a.ws, a.gid, {"node": a.node, "to": "running", "by": a.by or ""})
_out({"ok": True, "node": a.node, "status": "running"})
return 0
def cmd_report(a) -> int:
g = _load(a.ws, a.gid)
n = (g.get("nodes") or {}).get(a.node)
if not n:
raise SystemExit("没有这个节点:%s" % a.node)
if a.state not in ("done", "blocked"):
raise SystemExit("--state 只认 done / blocked")
if a.evidence:
n["evidence"] = a.evidence
n["status"] = a.state
if a.state == "blocked":
n["attempts"] = int(n.get("attempts") or 0) + 1
_save(a.ws, a.gid, g)
def _mut(g):
n = (g.get("nodes") or {}).get(a.node)
if not n:
raise SystemExit("没有这个节点:%s" % a.node)
if a.evidence:
n["evidence"] = a.evidence
n["status"] = a.state
if a.state == "blocked":
n["attempts"] = int(n.get("attempts") or 0) + 1
g = _update(a.ws, a.gid, _mut)
_event(a.ws, a.gid, {"node": a.node, "to": a.state,
"evidence": a.evidence or "", "by": a.by or ""})
r = _ready(g)