Merge pull request #118 from openqj/feat-coord-running-leak

worker: release running slot after coordinator completes
This commit is contained in:
openqj authored and GitHub committed 2026-10-29 03:07:35 +08:00
commit 630d14fe07
2 files changed
+96 -3

No files matched your search

+54
View File
@@ -270,6 +270,60 @@ class WorkerCoordinatorTestCase(unittest.TestCase):
self.assertIn("后端好了", result["stdout"])
self.assertEqual(_row(self.db_path, "coord")["status"], "completed")
def test_coordinator_completion_releases_running_slot(self):
# 回归:协调员不走 _run_attempt 的 finally,旧版完成
# 后 _running 占位永不释放,同进程 worker 的有效容量
# 逐个协调员递减直至再也认领不了(2026-10-28 40 人
# 与 18 人旧轮的第 4 槽/C-6 未认领即此漏)。
_insert_task(self.db_path, "coord", coordinator_parts=self._parts())
_insert_task(self.db_path, "c1", status="completed",
parent_task_id="coord", coordinator_part="后端",
result_text="后端好了")
_insert_task(self.db_path, "c2", status="completed",
parent_task_id="coord", coordinator_part="前端",
result_text="前端好了")
worker = self._worker()
result = worker.run_one(worker.get_task("coord"))
self.assertEqual(result["status"], "completed")
self.assertNotIn("coord", worker._running_snapshot())
self.assertEqual(worker._running_count(), 0)
def test_coordinator_then_next_claim_succeeds(self):
# 容量门视角的同一回归:协调员完成后,下一个任务
# 的 _claim 必须成功,而不是被漏掉的占位静默挡住。
_insert_task(self.db_path, "coord", coordinator_parts=self._parts())
_insert_task(self.db_path, "c1", status="completed",
parent_task_id="coord", coordinator_part="后端",
result_text="后端好了")
_insert_task(self.db_path, "c2", status="completed",
parent_task_id="coord", coordinator_part="前端",
result_text="前端好了")
_insert_task(self.db_path, "next1")
worker = self._worker()
worker.run_one(worker.get_task("coord"))
self.assertTrue(
worker._claim("next1", executor=worker._executor_name()))
self.assertIn("next1", worker._running_snapshot())
worker._running_clear("next1")
def test_capacity_refused_log_throttled(self):
# 容量门被拦以前是静默返回;修法后 30 秒内只记一次,
# 避免池线程自旋把日志打爆(这里断言时间戳节流,
# 不捕获日志输出)。
_insert_task(self.db_path, "t1")
_insert_task(self.db_path, "t2")
_insert_task(self.db_path, "t3")
worker = self._worker()
self.assertTrue(
worker._claim("t1", executor=worker._executor_name()))
self.assertFalse(
worker._claim("t2", executor=worker._executor_name()))
first = worker._capacity_refused_logged_at
self.assertGreater(first, 0.0)
self.assertFalse(
worker._claim("t3", executor=worker._executor_name()))
self.assertEqual(worker._capacity_refused_logged_at, first)
def test_run_timeout_cancels_pending(self):
_insert_task(self.db_path, "coord", coordinator_parts=self._parts())
conn = sqlite3.connect(self.db_path)
+42 -3
View File
@@ -468,6 +468,10 @@ class Worker:
# 都走下面的 _running_set/_running_clear/_running_snapshot。
self._running = {}
self._running_lock = threading.Lock()
# 容量门被拦日志的节流时间戳:_claim 的容量门在池线程
# 自旋时会高频触发,不节流会把日志打爆;30 秒内只记
# 一次,够排查“worker 活着却再也认领不了”即可。
self._capacity_refused_logged_at = 0.0
# DB 写锁:cost 记录共用一条连接(CostStore._conn),
# usage 计数也是共享变量;模型回合本身在子进程/容器里
# 跑不受这把锁影响,锁只覆盖毫秒级的写库操作。
@@ -724,12 +728,36 @@ class Worker:
message="code signature missing or changed,"
" refusing claim")
return False
capacity_refused = False
capacity_log = None
with self._running_lock:
already_running = task_id in self._running
if not already_running and \
len(self._running) >= effective_capacity(self.capacity):
return False
self._running.setdefault(task_id, {})
# 容量门被拦以前是静默返回:worker 活着、心跳
# 新鲜、日志无错,却再也认领不了任务(协调员
# 槽位泄漏就是这么被掩盖的)。注意:节流的只
# 是日志,拒绝本身恒返回 False——若把节流套在
# 拒绝上,节流窗口内的任务会被 CAS 认领却不进
# _running,比静默更糟。
capacity_refused = True
now_gate = time.time()
last_gate = getattr(
self, "_capacity_refused_logged_at", 0.0)
if now_gate - last_gate >= 30.0:
self._capacity_refused_logged_at = now_gate
capacity_log = (
len(self._running),
effective_capacity(self.capacity))
else:
self._running.setdefault(task_id, {})
if capacity_log is not None:
log_event(self.logger, "worker_claim_refused_capacity",
task_id=task_id,
message="worker claim refused: capacity full,"
" running=%d capacity=%d" % capacity_log)
if capacity_refused:
return False
now = time.time()
conn = _db_connect(self.db_path)
try:
@@ -3436,6 +3464,8 @@ class Worker:
占一个 worker 槽位直到子任务全部终态:等齐期间每轮刷心跳
(sweep 不会误回收);子任务本身由各 worker 并行认领。
占位由调用方 run_one 的 finally 在终局后释放,本函数
不重复清(旧版直接 return 导致占位永不释放,见上)。
取消与超时都会尽力取消未终态的子任务,不留孤儿继续烧
模型费用。返回 run_one 同款结果 dict(无模型花费,
cost_record_id 为 None)。
@@ -3823,8 +3853,17 @@ class Worker:
# 合流汇总。已过 key 门与配额门;仓库门已对协调员任务本身
# 跑过(项目仓库解析失败会在前面直接 failed,不会建一堆
# 注定失败的子任务);子任务各自再过 key/配额/仓库门。
# 槽位清理:普通任务在 _run_attempt 的 finally 里清
# _running 占位,协调员不走 _run_attempt——旧版这里直接
# return _run_coordinator,协调员完成后续占位永不释放,
# 同进程 worker 的有效容量会逐个协调员递减直至再也
# 认领不了(2026-10-28 40 人与 18 人旧轮的第 4 槽/C-6
# 未认领即此漏)。finally 清槽,异常路径同样不漏。
if task.get("coordinator_parts"):
return self._run_coordinator(task, started_at)
try:
return self._run_coordinator(task, started_at)
finally:
self._running_clear(task_id)
events = []
fallback_count = 0