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
@@ -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)
|
||||
|
||||
|
||||
Reference in new issue
Block a user