Merge pull request #117 from openqj/feat-autoscaler-coord-guard
autoscaler: guard scale-down against live coordinators and busy victims
This commit is contained in:
2 files changed
+332
-10
No files matched your search
+182
-10
@@ -22,6 +22,23 @@
|
||||
worker 不重复拉起,名字冲突的孤儿先 SIGTERM 清掉再重拉。
|
||||
- scaler 绝不凭猜测扩容:深度读库失败时保持现状并记日志,
|
||||
不扩也不缩(未知状态下乱动比不动更危险)。
|
||||
- 缩容护栏(两层 veto,见 _has_live_running_coordinator /
|
||||
_victim_is_busy):深度只算 queued 就绪任务,等待中的
|
||||
协调员对深度贡献为 0,且协调员等齐循环不响应停机信号、
|
||||
60 秒 drain 内等不完就会被 run_loop finally 标 interrupted,
|
||||
所以缩容绝不能只看深度就停 worker。第一层是全局活协调员
|
||||
veto:DB 里有 status=running、coordinator_parts 经
|
||||
cloud.coordinator.parse_parts 解析为非空列表、且
|
||||
last_heartbeat 在 120 秒内的任务时,本轮不执行任何缩容,
|
||||
low_streak 冻结不重置(veto 解除后下一轮低深度即可缩,
|
||||
不必再等 scale_down_after 轮)。第二层是受害者忙 veto:
|
||||
按 LIFO 选到受害者后查 workers 表,alive 且 running>0 时
|
||||
跳过该受害者;但注册表 running 有幽灵占位先例(报 busy
|
||||
却库里无 running 任务),所以只有 DB 里同时存在活 running
|
||||
任务时才拦,幽灵不拦。tasks 表没有 worker_id 列,无法把
|
||||
协调员归属到某个 worker,因此第一层只能是全局 veto,
|
||||
不能是按受害者 veto——保守一点,代价只是多留一个空闲
|
||||
worker 到协调员跑完。
|
||||
- worker 命令由调用方以 worker_args 传入,scaler 强制追加
|
||||
--loop、--worker-id、--worker-capacity(argparse 后出现
|
||||
的值胜出,所以追加在末尾一定生效);--db 若 worker_args
|
||||
@@ -39,9 +56,11 @@ import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
from cloud.coordinator import parse_parts as _coord_parse_parts
|
||||
from cloud.db import connect as _db_connect
|
||||
from cloud.logging_config import get_logger, log_event, setup_logging
|
||||
from cloud.queue import ready_tasks
|
||||
from cloud.workers import DEFAULT_ALIVE_SECONDS as _WORKER_ALIVE_SECONDS
|
||||
|
||||
|
||||
def queue_depth(db_path, executor=None):
|
||||
@@ -117,13 +136,129 @@ def desired_workers(depth, per_worker_capacity,
|
||||
return max(min_workers, min(max_workers, want))
|
||||
|
||||
|
||||
def _is_live_heartbeat(last_heartbeat, now, alive_seconds):
|
||||
"""心跳是否新鲜:NULL 算不新鲜;未来时间戳(时钟/精度舍入)
|
||||
也算新鲜,因为它一定是刚写过的。"""
|
||||
if last_heartbeat is None:
|
||||
return False
|
||||
try:
|
||||
age = now - float(last_heartbeat)
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
return age <= float(alive_seconds)
|
||||
|
||||
|
||||
def _running_task_rows(db_path):
|
||||
"""status=running 的任务行(coordinator_parts, last_heartbeat)。
|
||||
调用方按需过滤;读库失败时抛给调用方决定 fail-open/closed。"""
|
||||
conn = _db_connect(db_path)
|
||||
try:
|
||||
return conn.execute(
|
||||
"SELECT coordinator_parts, last_heartbeat FROM tasks"
|
||||
" WHERE status = 'running'").fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _has_live_running_coordinator(db_path, now=None,
|
||||
alive_seconds=_WORKER_ALIVE_SECONDS):
|
||||
"""DB 里是否存在活着的等待中协调员。
|
||||
|
||||
判定与 worker 侧一致:coordinator_parts 经 parse_parts
|
||||
解析为非空列表才算协调员(坏值解析为 [],worker 也不会
|
||||
把它当协调员跑等齐循环);last_heartbeat 必须新鲜,
|
||||
否则是死 worker 遗留的 running 行,300 秒陈旧回收会去
|
||||
处理它,不应由它永久拦住缩容。
|
||||
"""
|
||||
if now is None:
|
||||
now = time.time()
|
||||
for row in _running_task_rows(db_path):
|
||||
try:
|
||||
parts = row["coordinator_parts"]
|
||||
hb = row["last_heartbeat"]
|
||||
except (KeyError, IndexError, TypeError):
|
||||
continue
|
||||
if not _is_live_heartbeat(hb, now, alive_seconds):
|
||||
continue
|
||||
if _coord_parse_parts(parts):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _has_live_running_task(db_path, now=None,
|
||||
alive_seconds=_WORKER_ALIVE_SECONDS):
|
||||
"""DB 里是否存在心跳新鲜的 running 任务(不限协调员)。"""
|
||||
if now is None:
|
||||
now = time.time()
|
||||
for row in _running_task_rows(db_path):
|
||||
try:
|
||||
hb = row["last_heartbeat"]
|
||||
except (KeyError, IndexError, TypeError):
|
||||
continue
|
||||
if _is_live_heartbeat(hb, now, alive_seconds):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _worker_registry_running(db_path, worker_id, now=None,
|
||||
alive_seconds=_WORKER_ALIVE_SECONDS):
|
||||
"""workers 表里该 worker 上报的 running 数;行缺失、stale、
|
||||
或表缺失时一律按 0(空闲)处理——注册发生在 run_loop
|
||||
启动、认领之前,所以已认领任务的 worker 一定已有行。"""
|
||||
if now is None:
|
||||
now = time.time()
|
||||
try:
|
||||
conn = _db_connect(db_path)
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT running, last_seen FROM workers"
|
||||
" WHERE worker_id = ?", (worker_id,)).fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
except Exception:
|
||||
return 0
|
||||
if row is None:
|
||||
return 0
|
||||
try:
|
||||
running = row["running"]
|
||||
last_seen = row["last_seen"]
|
||||
except (KeyError, IndexError, TypeError):
|
||||
return 0
|
||||
if not _is_live_heartbeat(last_seen, now, alive_seconds):
|
||||
return 0
|
||||
try:
|
||||
return int(running) if running is not None else 0
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
|
||||
def _victim_is_busy(db_path, worker_id, now=None,
|
||||
alive_seconds=_WORKER_ALIVE_SECONDS):
|
||||
"""受害者是否真忙:注册表报 busy 且 DB 里确实有活 running
|
||||
任务时才算忙;注册表报 busy 但 DB 里一个活任务都没有时
|
||||
是幽灵占位,不拦(否则幽灵会把缩容永久卡死)。"""
|
||||
if now is None:
|
||||
now = time.time()
|
||||
if _worker_registry_running(db_path, worker_id, now,
|
||||
alive_seconds) <= 0:
|
||||
return False
|
||||
try:
|
||||
return _has_live_running_task(db_path, now, alive_seconds)
|
||||
except Exception:
|
||||
# 第二层 veto 读库失败时 fail-open:第一层协调员 veto
|
||||
# 仍是 fail-closed,见 reconcile;这里不因一次读失败
|
||||
# 就把一个可能空闲的 worker 永久留着。
|
||||
return False
|
||||
|
||||
|
||||
class Autoscaler:
|
||||
"""管理 worker 子进程:按深度启停,带缩容冷却与优雅停机。"""
|
||||
|
||||
def __init__(self, db_path, worker_args, min_workers=1, max_workers=2,
|
||||
per_worker_capacity=2, interval=30, scale_down_after=3,
|
||||
drain_timeout=60, state_file=None, log_dir=None,
|
||||
worker_cmd=None, executor=None):
|
||||
worker_cmd=None, executor=None,
|
||||
guard_alive_seconds=_WORKER_ALIVE_SECONDS):
|
||||
self.db_path = db_path
|
||||
self.worker_args = list(worker_args)
|
||||
self.min_workers = min_workers
|
||||
@@ -132,6 +267,7 @@ class Autoscaler:
|
||||
self.interval = interval
|
||||
self.scale_down_after = scale_down_after
|
||||
self.drain_timeout = drain_timeout
|
||||
self.guard_alive_seconds = guard_alive_seconds
|
||||
self.state_file = state_file
|
||||
self.log_dir = log_dir
|
||||
self.worker_cmd = worker_cmd
|
||||
@@ -273,15 +409,51 @@ class Autoscaler:
|
||||
# 缩容延迟:连续 low 轮都用不了这么多才缩。
|
||||
self.low_streak += 1
|
||||
if self.low_streak >= self.scale_down_after:
|
||||
self.low_streak = 0
|
||||
# LIFO:停最年轻的(它的任务最新,排空代价最小)。
|
||||
victims = sorted(
|
||||
self.workers,
|
||||
key=lambda wid: self.workers[wid]["started_at"],
|
||||
reverse=True)[: actual - desired]
|
||||
for wid in victims:
|
||||
self._stop_worker(wid)
|
||||
action = "scaled_down_to_%d" % desired
|
||||
# 缩容护栏(两层 veto):被拦时 low_streak 不重置
|
||||
# (冻结),veto 解除后下一轮低深度即可缩,不必
|
||||
# 再等 scale_down_after 轮;只有真正停了 worker
|
||||
# 才重置 streak。
|
||||
try:
|
||||
coord_blocked = _has_live_running_coordinator(
|
||||
self.db_path,
|
||||
alive_seconds=self.guard_alive_seconds)
|
||||
except Exception as exc:
|
||||
# 第一层读库失败时 fail-closed:未知状态下
|
||||
# 停 worker 比不动更危险,本轮不缩。
|
||||
self.last_error = str(exc)
|
||||
log_event(self.logger, "autoscaler_guard_failed",
|
||||
message="autoscaler guard failed: %s"
|
||||
% exc)
|
||||
self.last_action = "scale_down_blocked_by_guard_error"
|
||||
self._write_state()
|
||||
return depth, desired, len(self.workers)
|
||||
if coord_blocked:
|
||||
action = "scale_down_blocked_by_coordinator"
|
||||
else:
|
||||
# LIFO:停最年轻的(它的任务最新,排空代价最小)。
|
||||
victims = sorted(
|
||||
self.workers,
|
||||
key=lambda wid: self.workers[wid]["started_at"],
|
||||
reverse=True)[: actual - desired]
|
||||
stopped = []
|
||||
blocked = []
|
||||
for wid in victims:
|
||||
if _victim_is_busy(
|
||||
self.db_path, wid,
|
||||
alive_seconds=self.guard_alive_seconds):
|
||||
blocked.append(wid)
|
||||
continue
|
||||
self._stop_worker(wid)
|
||||
stopped.append(wid)
|
||||
if not stopped:
|
||||
action = "scale_down_blocked_by_busy_worker"
|
||||
else:
|
||||
self.low_streak = 0
|
||||
if blocked:
|
||||
action = "scaled_down_to_%d" % len(
|
||||
self.workers)
|
||||
else:
|
||||
action = "scaled_down_to_%d" % desired
|
||||
else:
|
||||
action = "scale_down_pending_%d/%d" % (
|
||||
self.low_streak, self.scale_down_after)
|
||||
|
||||
@@ -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