Files
codex-app-server/cloud/tests/test_autoscaler.py
T
host 26daddf909 hotfix: autoscaler main() setup_logging arg
setup_logging() takes a log LEVEL, not a logger name; passing
'cloud.autoscaler' raised ValueError at startup, so the scaler
crash-looped under systemd and started zero workers (the host
had no worker at all until this fix). The '/opt/.../-m'
FileNotFoundError in the same journal lines is only Ubuntu's
apport hook misfiring on 'python3 -m', not a second bug.
Regression test: main() with mocked run_loop/signal must reach
the loop (this would have caught the crash before deploy).
2026-10-26 07:02:31 +08:00

182 lines
7.0 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Autoscaler 单测:深度计算、目标 worker 数、启停与缩容冷却。
只用临时 SQLite 与假 worker 命令(sleep 进程),不跑真实
worker、不联网、不碰生产库。假 worker 命令通过 worker_cmd
注入,scaler 的 worker_args 为空——真实 worker 命令构造
(强制 --loop/--worker-id/--worker-capacity)由构造测试覆盖。
"""
import os
import sqlite3
import sys
import tempfile
import time
import unittest
from pathlib import Path
try:
from cloud.autoscaler import Autoscaler, desired_workers, queue_depth
from cloud.server import init_db
except ImportError:
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from cloud.autoscaler import Autoscaler, desired_workers, queue_depth
from cloud.server import init_db
def _insert_task(db_path, task_id, status="queued", depends_on="[]"):
now = time.time()
conn = sqlite3.connect(db_path)
try:
conn.execute(
"INSERT INTO tasks(id, project_id, prompt, status,"
" idempotency_key, depends_on, priority, created_by,"
" created_at, updated_at, last_heartbeat)"
" VALUES(?, 'p1', 'x', ?, NULL, ?, 0, 'tester', ?, ?, NULL)",
(task_id, status, depends_on, now, now),
)
conn.commit()
finally:
conn.close()
def _set_status(db_path, task_id, status):
conn = sqlite3.connect(db_path)
try:
conn.execute("UPDATE tasks SET status = ? WHERE id = ?",
(status, task_id))
conn.commit()
finally:
conn.close()
class DesiredWorkersTestCase(unittest.TestCase):
def test_depth_zero_keeps_min(self):
self.assertEqual(desired_workers(0, 2, 1, 2), 1)
def test_depth_zero_min_zero_scales_to_zero(self):
self.assertEqual(desired_workers(0, 2, 0, 2), 0)
def test_depth_divides_by_capacity_and_caps(self):
self.assertEqual(desired_workers(1, 2, 1, 2), 1)
self.assertEqual(desired_workers(3, 2, 1, 2), 2)
self.assertEqual(desired_workers(10, 2, 1, 2), 2)
self.assertEqual(desired_workers(5, 1, 1, 3), 3)
class QueueDepthTestCase(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.db_path = os.path.join(self.tmp.name, "control.db")
init_db(self.db_path)
def tearDown(self):
self.tmp.cleanup()
def test_only_ready_tasks_count(self):
# A 就绪;B 依赖未完成的 A,不算;C 已完成,不算;
# D 在跑,不算;E 失败,不算。
_insert_task(self.db_path, "A")
_insert_task(self.db_path, "B", depends_on='["A"]')
_insert_task(self.db_path, "C", status="completed")
_insert_task(self.db_path, "D", status="running")
_insert_task(self.db_path, "E", status="failed")
self.assertEqual(queue_depth(self.db_path), 1)
# A 完成后,B 解锁,深度仍是 1(不是 2)。
_set_status(self.db_path, "A", "completed")
self.assertEqual(queue_depth(self.db_path), 1)
def test_dependency_cycle_counts_zero(self):
_insert_task(self.db_path, "A", depends_on='["B"]')
_insert_task(self.db_path, "B", depends_on='["A"]')
self.assertEqual(queue_depth(self.db_path), 0)
class AutoscalerReconcileTestCase(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.db_path = os.path.join(self.tmp.name, "control.db")
init_db(self.db_path)
# 假 worker:睡 120 秒的 python 进程,scaler 用 SIGTERM
# 停它(默认行为即退出,不用等 drain 超时)。
self.fake_cmd = [sys.executable, "-c",
"import time; time.sleep(120)"]
def tearDown(self):
# 测试结束时残留的假 worker 必须清掉,否则泄漏进程。
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("max_workers", 2)
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 test_scales_up_on_depth_and_down_after_cooldown(self):
scaler = self._scaler()
depth, desired, actual = scaler.reconcile()
self.assertEqual((depth, desired, actual), (0, 1, 1))
for tid in ("A", "B", "C", "D"):
_insert_task(self.db_path, tid)
depth, desired, actual = scaler.reconcile()
self.assertEqual((depth, desired, actual), (4, 2, 2))
# 任务全完成:scale_down_after=1,下一轮直接缩回 1。
for tid in ("A", "B", "C", "D"):
_set_status(self.db_path, tid, "completed")
depth, desired, actual = scaler.reconcile()
self.assertEqual((depth, desired, actual), (0, 1, 1))
def test_scale_down_waits_for_cooldown_streak(self):
scaler = self._scaler(scale_down_after=3)
for tid in ("A", "B", "C", "D"):
_insert_task(self.db_path, tid)
scaler.reconcile()
self.assertEqual(len(scaler.workers), 2)
for tid in ("A", "B", "C", "D"):
_set_status(self.db_path, tid, "completed")
scaler.reconcile()
self.assertEqual(len(scaler.workers), 2)
scaler.reconcile()
self.assertEqual(len(scaler.workers), 2)
scaler.reconcile()
self.assertEqual(len(scaler.workers), 1)
def test_worker_command_forces_loop_id_capacity(self):
scaler = Autoscaler(self.db_path, ["--interval", "10"])
cmd = scaler._worker_command("autoscale-1")
self.assertIn("--loop", cmd)
self.assertIn("autoscale-1", cmd)
self.assertIn("--worker-capacity", cmd)
self.assertEqual(cmd[cmd.index("--worker-capacity") + 1], "2")
self.assertIn("--db", cmd)
self.assertEqual(cmd[cmd.index("--db") + 1], self.db_path)
def test_main_parses_args_and_starts_loop(self):
# 回归:main() 里 setup_logging 曾错传 logger 名当 level,
# 直接 ValueError,scaler 在主机上 crash-loop、一个 worker
# 都没拉起来。本测试 mock 掉 run_loop 与 signal,只验证
# main() 能走到启动循环(即参数解析与日志初始化没炸)。
from unittest import mock
import cloud.autoscaler as autoscaler_mod
with mock.patch.object(
autoscaler_mod.Autoscaler, "run_loop") as mock_loop, \
mock.patch("signal.signal"):
autoscaler_mod.main([
"--db", self.db_path, "--interval", "30",
"--", "--interval", "10"])
mock_loop.assert_called_once()
if __name__ == "__main__":
unittest.main()