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

+66 -2
View File
@@ -83,15 +83,18 @@ def _drop(p: Path, tries: int = 5) -> None:
class Base(unittest.TestCase):
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
shutil.rmtree(self.ws, ignore_errors=True)
self.ws.mkdir(parents=True, exist_ok=True)
self.gid = "g1"
def tearDown(self):
global _TD_COUNT
_TD_COUNT += 1
_drop(self.ws)
# -- 便捷动作 --
def gpath(self, gid=None) -> Path:
@@ -510,6 +513,67 @@ class T8_Isolation(Base):
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():
_drop(TMP_ROOT)