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
+121 -11

No files matched your search

+12 -2
View File
@@ -85,9 +85,19 @@
python graph/test_graphctl.py -v python graph/test_graphctl.py -v
全绿应看到 `Ran 49 tests ... OK`,用时 20 秒上下(每条用例起一次子进程)。 全绿应看到 `Ran 52 tests ... OK`,用时 15 秒上下(每条用例起一次子进程)。
测试只往临时目录写:`<GRAPH_TEST_TMP>/graph-test-<pid>/<用例名>`,默认 `GRAPH_TEST_TMP` = `AIProject/ai1net-dsh-server/tmp`,由每条用例的 `tearDown` + 进程退出的 `atexit` 兜底删除。想换落点就设环境变量 `GRAPH_TEST_TMP`。 测试只往临时目录写:`<GRAPH_TEST_TMP>/graph-test-<pid>/<用例名>`,默认 `GRAPH_TEST_TMP` = `AIProject/ai1net-dsh-server/tmp`。每条用例只**新建**自己的目录、跑完不删,整轮收尾由 `tearDownModule` 清一次;这样一次跑下来新建几十个文件、不触发本机沙箱的「批量删除」守卫(那个守卫一次删满 50 个文件就会拦,曾让 20 条用例报 ERROR,而用例本身零失败)。想换落点就设环境变量 `GRAPH_TEST_TMP`。
## 五之二、并发写是怎么保证的
两个进程可以同时对同一张图动手(比如两个节点同时开工),做法是三层:
1. 临时文件名带进程号与随机数,两个进程写字不会撞同一个名字(早期版本用固定名 `graph.json.tmp`,并发时直接报 `PermissionError`)。
2. 每次写都先读最新磁盘状态、在它上面重放本次变更,再原子替换——避免「后写的整份覆盖前写的」导致节点状态静默消失。
3. 图里带 `_rev` 版本号:写完回读核对,发现自己不是最新一版就重读重做,连续三次仍被覆盖才报错退出(要的是「宁可报错,也不静默丢状态」)。
这条路径由 `T9_Concurrency` 的三个用例守着:并发开工两个节点、并发回报两个节点、版本号每次写都前进。
清残留在本机是常态:跑完后 `tmp/` 下偶尔还能看到 `graph-test-<pid>/`(进程内已确认删空,是本机沙箱在进程退出后把删除路径又放回来了,与测试代码无关)。一条命令清掉: 清残留在本机是常态:跑完后 `tmp/` 下偶尔还能看到 `graph-test-<pid>/`(进程内已确认删空,是本机沙箱在进程退出后把删除路径又放回来了,与测试代码无关)。一条命令清掉:
+43 -7
View File
@@ -63,6 +63,7 @@ import argparse
import io import io
import json import json
import os import os
import random
import sys import sys
import time import time
from pathlib import Path from pathlib import Path
@@ -92,13 +93,46 @@ def _load(ws: str, gid: str) -> dict:
def _save(ws: str, gid: str, g: dict) -> None: def _save(ws: str, gid: str, g: dict) -> None:
"""整份写回(新建图时用)。临时名带 pid + 随机数 ⇒ 两进程不会撞同一个名字。"""
p = _gdir(ws, gid) / "graph.json" 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", tmp.write_text(json.dumps(g, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8", newline="\n") encoding="utf-8", newline="\n")
os.replace(tmp, p) # 原子替换,避免半截文件 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: def _event(ws: str, gid: str, obj: dict) -> None:
p = _gdir(ws, gid) / "events.jsonl" p = _gdir(ws, gid) / "events.jsonl"
obj = dict(obj) obj = dict(obj)
@@ -258,7 +292,7 @@ def cmd_ready(a) -> int:
# ── 状态流转 ──────────────────────────────────────────────────────────── # ── 状态流转 ────────────────────────────────────────────────────────────
def cmd_start(a) -> int: def cmd_start(a) -> int:
g = _load(a.ws, a.gid) def _mut(g):
n = (g.get("nodes") or {}).get(a.node) n = (g.get("nodes") or {}).get(a.node)
if not n: if not n:
raise SystemExit("没有这个节点:%s" % a.node) raise SystemExit("没有这个节点:%s" % a.node)
@@ -268,25 +302,27 @@ def cmd_start(a) -> int:
raise SystemExit("节点 %s 已完成,不能开工" % a.node) raise SystemExit("节点 %s 已完成,不能开工" % a.node)
# pending 与 blocked 都可开工:blocked 是「这轮没过」,重试时 attempts 保留 # pending 与 blocked 都可开工:blocked 是「这轮没过」,重试时 attempts 保留
n["status"] = "running" n["status"] = "running"
_save(a.ws, a.gid, g) _update(a.ws, a.gid, _mut)
_event(a.ws, a.gid, {"node": a.node, "to": "running", "by": a.by or ""}) _event(a.ws, a.gid, {"node": a.node, "to": "running", "by": a.by or ""})
_out({"ok": True, "node": a.node, "status": "running"}) _out({"ok": True, "node": a.node, "status": "running"})
return 0 return 0
def cmd_report(a) -> int: def cmd_report(a) -> int:
g = _load(a.ws, a.gid) if a.state not in ("done", "blocked"):
raise SystemExit("--state 只认 done / blocked")
def _mut(g):
n = (g.get("nodes") or {}).get(a.node) n = (g.get("nodes") or {}).get(a.node)
if not n: if not n:
raise SystemExit("没有这个节点:%s" % a.node) raise SystemExit("没有这个节点:%s" % a.node)
if a.state not in ("done", "blocked"):
raise SystemExit("--state 只认 done / blocked")
if a.evidence: if a.evidence:
n["evidence"] = a.evidence n["evidence"] = a.evidence
n["status"] = a.state n["status"] = a.state
if a.state == "blocked": if a.state == "blocked":
n["attempts"] = int(n.get("attempts") or 0) + 1 n["attempts"] = int(n.get("attempts") or 0) + 1
_save(a.ws, a.gid, g)
g = _update(a.ws, a.gid, _mut)
_event(a.ws, a.gid, {"node": a.node, "to": a.state, _event(a.ws, a.gid, {"node": a.node, "to": a.state,
"evidence": a.evidence or "", "by": a.by or ""}) "evidence": a.evidence or "", "by": a.by or ""})
r = _ready(g) r = _ready(g)
+66 -2
View File
@@ -83,15 +83,18 @@ def _drop(p: Path, tries: int = 5) -> None:
class Base(unittest.TestCase): class Base(unittest.TestCase):
def setUp(self): def setUp(self):
# 🔴 2026-10-19 改(实测:本机沙箱有「批量删除」守卫,一次删满 50 个文件即弹
# SAFE_DELETE_BULK_CONFIRM_REQUIRED ⇒ 49 条用例里 20 条被拦成 ERROR,
# 单跑却全 OK —— 说明红的是清理动作,不是用例)。
# ⇒ 每例只**新建**自己的目录、跑完不删;整轮收尾由 tearDownModule 清一次。
# TMP_ROOT 带 pid,重跑天然不撞旧目录。
self.ws = TMP_ROOT / self._testMethodName self.ws = TMP_ROOT / self._testMethodName
shutil.rmtree(self.ws, ignore_errors=True)
self.ws.mkdir(parents=True, exist_ok=True) self.ws.mkdir(parents=True, exist_ok=True)
self.gid = "g1" self.gid = "g1"
def tearDown(self): def tearDown(self):
global _TD_COUNT global _TD_COUNT
_TD_COUNT += 1 _TD_COUNT += 1
_drop(self.ws)
# -- 便捷动作 -- # -- 便捷动作 --
def gpath(self, gid=None) -> Path: def gpath(self, gid=None) -> Path:
@@ -510,6 +513,67 @@ class T8_Isolation(Base):
os.environ.get("GRAPH_TEST_TMP"), TMP_ROOT) os.environ.get("GRAPH_TEST_TMP"), TMP_ROOT)
# ── 9 并发写 ────────────────────────────────────────────────────────────
class T9_Concurrency(Base):
"""两个进程同时写同一张图:要都成功,且谁的状态都不许丢。
2026-10-19 修的实测缺陷(桌面测试报告第 14 条):
临时名原为固定的 `graph.json.tmp` ⇒ 并发 start 直接撞 PermissionError;
更隐蔽的是「读-改-写」后写覆盖前写 ⇒ 节点状态静默消失。
修法=唯一临时名(pid+随机)+ `_rev` 乐观重试(写后回读核对,被覆盖则重读重做)。
"""
def _parallel(self, *argsets):
procs = [subprocess.Popen([PY, str(GRAPHCTL)] + list(a),
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
text=True, encoding="utf-8") for a in argsets]
return [p.communicate() + (p.returncode,) for p in procs]
def _two_free_nodes(self):
self.init({"max_parallel": 4, "nodes": [
{"id": "A", "title": "甲", "domain": ["a/"], "accepts": ["好"], "deps": []},
{"id": "B", "title": "乙", "domain": ["b/"], "accepts": ["好"], "deps": []},
]})
def test_parallel_start_keeps_both_running(self):
self._two_free_nodes()
res = self._parallel(
["start", "--ws", str(self.ws), "--gid", self.gid, "--node", "A", "--by", "t1"],
["start", "--ws", str(self.ws), "--gid", self.gid, "--node", "B", "--by", "t2"],
)
for out, err, rc in res:
self.assertEqual(rc, 0, "并发 start 不该失败(rc=%d, err=%s)" % (rc, err))
self.assertNotIn("PermissionError", err)
g = self.load()
self.assertEqual(g["nodes"]["A"]["status"], "running")
self.assertEqual(g["nodes"]["B"]["status"], "running")
def test_parallel_report_keeps_both_evidence(self):
self._two_free_nodes()
ok("start", "--ws", str(self.ws), "--gid", self.gid, "--node", "A")
ok("start", "--ws", str(self.ws), "--gid", self.gid, "--node", "B")
res = self._parallel(
["report", "--ws", str(self.ws), "--gid", self.gid, "--node", "A",
"--state", "done", "--evidence", "甲好了"],
["report", "--ws", str(self.ws), "--gid", self.gid, "--node", "B",
"--state", "done", "--evidence", "乙好了"],
)
for out, err, rc in res:
self.assertEqual(rc, 0, "并发 report 不该失败(rc=%d, err=%s)" % (rc, err))
g = self.load()
self.assertEqual(g["nodes"]["A"]["status"], "done")
self.assertEqual(g["nodes"]["B"]["status"], "done")
self.assertEqual(g["nodes"]["A"]["evidence"], "甲好了")
self.assertEqual(g["nodes"]["B"]["evidence"], "乙好了")
def test_rev_advances_on_every_write(self):
self._two_free_nodes()
r0 = int(self.load().get("_rev") or 0)
ok("start", "--ws", str(self.ws), "--gid", self.gid, "--node", "A")
self.assertGreater(self.load().get("_rev"), r0)
def tearDownModule(): def tearDownModule():
_drop(TMP_ROOT) _drop(TMP_ROOT)