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:
1 parent
4cf5888f94
commit
37b47cadc6
2 files changed
+332
-10
No files matched your search
@@ -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()
|
||||
Reference in new issue
Block a user