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:
1 parent
4866ad8d37
commit
42a130e4f2
3 files changed
+136
-26
No files matched your search
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user