autoscaler: guard scale-down against live coordinators and busy victims

Two veto layers in reconcile(), worker.py untouched:
- Global live-coordinator veto: a running task whose
  coordinator_parts parse (via cloud.coordinator.parse_parts)
  to a non-empty list and whose last_heartbeat is within
  120s blocks all scale-down this round; low_streak is
  frozen, not reset, so scale-down resumes on the next
  low-depth round once the veto lifts.
- Per-victim busy veto: a LIFO victim whose workers row
  is alive with running>0 is skipped, but only when the
  DB also has a live running task (ghost registry rows
  with no live DB task do not block).
- Coordinator-veto query failure fails closed (no stop,
  guard error recorded); victim-veto read failure fails
  open for that layer only.
- A missing/stale workers row is treated as idle.

Tests: 7 new ScaleDownGuardTestCase cases (live/stale
coordinator, non-coordinator, invalid parts, busy/idle
victims, ghost registry, streak freeze); cloud suite
1077 tests OK (4 skipped).
This commit is contained in:
host committed 2026-10-29 00:19:30 +08:00
1 parent 4cf5888f94
commit 37b47cadc6
2 files changed
+332 -10

No files matched your search

+150
View File
@@ -49,6 +49,35 @@ def _set_status(db_path, task_id, status):
conn.close()
def _insert_running_task(db_path, task_id, parts=None,
heartbeat_offset=0):
"""插入一个 running 任务;parts 为 coordinator_parts 列的
原始值(None=普通任务,JSON 文本=协调员分工)。"""
_insert_task(db_path, task_id, status="running")
now = time.time()
conn = sqlite3.connect(db_path)
try:
conn.execute(
"UPDATE tasks SET coordinator_parts = ?,"
" last_heartbeat = ? WHERE id = ?",
(parts, now + heartbeat_offset, task_id))
conn.commit()
finally:
conn.close()
def _set_worker_running(db_path, worker_id, running):
"""在 workers 表里给 worker 写一行新鲜心跳 + running 数。
用 cloud.workers 的注册函数建表,避免测试自己拼表结构。"""
try:
from cloud.workers import register_worker
except ImportError:
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from cloud.workers import register_worker
register_worker(db_path, worker_id, executor="docker",
capacity=2, running=running)
class DesiredWorkersTestCase(unittest.TestCase):
def test_depth_zero_keeps_min(self):
self.assertEqual(desired_workers(0, 2, 1, 2), 1)
@@ -195,5 +224,126 @@ class AutoscalerReconcileTestCase(unittest.TestCase):
mock_loop.assert_called_once()
class ScaleDownGuardTestCase(unittest.TestCase):
"""缩容护栏单测:两层 veto 与 streak 冻结。
全部用临时 SQLite + 假 worker(sleep 进程),不跑真实
worker。注意:假 worker 没有 workers 表心跳,注册行
全部由 _set_worker_running 显式写;没写注册行的 worker
在护栏里按空闲处理(见 autoscaler docstring)。
"""
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.db_path = os.path.join(self.tmp.name, "control.db")
init_db(self.db_path)
self.fake_cmd = [sys.executable, "-c",
"import time; time.sleep(120)"]
def tearDown(self):
if hasattr(self, "scaler"):
for wid in list(self.scaler.workers):
try:
self.scaler._stop_worker(wid)
except Exception:
pass
self.tmp.cleanup()
def _scaler(self, **kwargs):
kwargs.setdefault("min_workers", 1)
kwargs.setdefault("per_worker_capacity", 2)
kwargs.setdefault("scale_down_after", 1)
kwargs.setdefault("drain_timeout", 5)
self.scaler = Autoscaler(
self.db_path, [], worker_cmd=self.fake_cmd, **kwargs)
return self.scaler
def _scale_to(self, scaler, count):
for i in range(count * 2):
_insert_task(self.db_path, "Q%d" % i)
scaler.reconcile()
self.assertEqual(len(scaler.workers), count)
for i in range(count * 2):
_set_status(self.db_path, "Q%d" % i, "completed")
def test_live_coordinator_blocks_scale_down(self):
scaler = self._scaler(max_workers=2)
self._scale_to(scaler, 2)
_insert_running_task(
self.db_path, "COORD",
parts='[{"name": "A", "prompt": "x"}]')
scaler.reconcile()
self.assertEqual(len(scaler.workers), 2)
self.assertEqual(scaler.last_action,
"scale_down_blocked_by_coordinator")
def test_stale_coordinator_does_not_block(self):
scaler = self._scaler(max_workers=2)
self._scale_to(scaler, 2)
_insert_running_task(
self.db_path, "COORD",
parts='[{"name": "A", "prompt": "x"}]',
heartbeat_offset=-300)
scaler.reconcile()
self.assertEqual(len(scaler.workers), 1)
def test_non_coordinator_running_does_not_trigger_coordinator_veto(self):
scaler = self._scaler(max_workers=2)
self._scale_to(scaler, 2)
_insert_running_task(self.db_path, "PLAIN", parts=None)
_insert_running_task(self.db_path, "EMPTY", parts="[]")
scaler.reconcile()
self.assertEqual(len(scaler.workers), 1)
self.assertEqual(scaler.last_action, "scaled_down_to_1")
def test_invalid_coordinator_parts_not_a_coordinator(self):
# 与 worker 侧一致:parse_parts 把坏值解析为 [],
# worker 不会把它当协调员跑等齐循环,护栏也不拦。
scaler = self._scaler(max_workers=2)
self._scale_to(scaler, 2)
_insert_running_task(self.db_path, "BAD", parts="not-json")
scaler.reconcile()
self.assertEqual(len(scaler.workers), 1)
def test_busy_victim_skipped_idle_victim_stopped(self):
scaler = self._scaler(max_workers=3)
self._scale_to(scaler, 3)
# DB 里有一个活 running 任务(幽灵交叉校验的另一半)。
_insert_running_task(self.db_path, "LIVE", parts=None)
_set_worker_running(self.db_path, "autoscale-1", 0)
_set_worker_running(self.db_path, "autoscale-2", 0)
_set_worker_running(self.db_path, "autoscale-3", 1)
scaler.reconcile()
# LIFO 受害者是 [3, 2]:3 忙跳过,2 闲停掉,实际 3->2。
self.assertEqual(len(scaler.workers), 2)
self.assertIn("autoscale-3", scaler.workers)
self.assertNotIn("autoscale-2", scaler.workers)
self.assertEqual(scaler.last_action, "scaled_down_to_2")
def test_ghost_registry_does_not_block(self):
scaler = self._scaler(max_workers=2)
self._scale_to(scaler, 2)
# 注册表报 busy,但 DB 里一个活 running 任务都没有:
# 幽灵占位,不拦。
_set_worker_running(self.db_path, "autoscale-2", 2)
scaler.reconcile()
self.assertEqual(len(scaler.workers), 1)
def test_coordinator_veto_freezes_low_streak(self):
scaler = self._scaler(max_workers=2, scale_down_after=3)
self._scale_to(scaler, 2)
_insert_running_task(
self.db_path, "COORD",
parts='[{"name": "A", "prompt": "x"}]')
for _ in range(3):
scaler.reconcile()
self.assertEqual(len(scaler.workers), 2)
# veto 解除后不需要再等 3 轮:streak 已冻结在阈值上,
# 下一轮低深度立即缩。
_set_status(self.db_path, "COORD", "completed")
scaler.reconcile()
self.assertEqual(len(scaler.workers), 1)
if __name__ == "__main__":
unittest.main()