Files
codex-app-server/cloud/tests/test_prod_gaps.py

356 lines
14 KiB
Python

"""生产级缺口单测:worker 常驻/真取消、tasks 迁移、metrics、备份、日志。
只用标准库,不跑真实模型回合、不联网、不登录任何服务;
process_factory 返回的长跑子进程只是 `sleep`,取消后必须消失。
"""
import http.client
import io
import json
import logging
import os
import sqlite3
import subprocess
import sys
import tempfile
import threading
import time
import unittest
from unittest import mock
from pathlib import Path
from types import SimpleNamespace
try:
from cloud.auth import AuthStore
from cloud.backup import backup_db
from cloud.cost import CostStore
from cloud.db import PostgresDriverMissingError
from cloud.logging_config import JsonFormatter, log_event, setup_logging
from cloud.server import create_server, init_db
from cloud.worker import Worker
except ImportError:
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from cloud.auth import AuthStore
from cloud.backup import backup_db
from cloud.cost import CostStore
from cloud.db import PostgresDriverMissingError
from cloud.logging_config import JsonFormatter, log_event, setup_logging
from cloud.server import create_server, init_db
from cloud.worker import Worker
def _insert_task(db_path, task_id, prompt="do work", status="queued",
project_id="p1", started=None, finished=None):
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(?, ?, ?, ?, NULL, '[]', 0, 'tester', ?, ?, NULL)",
(task_id, project_id, prompt, status, now, now),
)
if started is not None:
conn.execute(
"UPDATE tasks SET started_at = ?, finished_at = ?"
" WHERE id = ?", (started, finished, task_id))
conn.commit()
finally:
conn.close()
def _row(db_path, task_id):
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
try:
row = conn.execute(
"SELECT * FROM tasks WHERE id = ?", (task_id,)).fetchone()
return dict(row) if row else None
finally:
conn.close()
def _completed_runner(cmd):
return SimpleNamespace(
returncode=0,
stdout=json.dumps({"task": "x", "status": "completed",
"text": "done"}),
stderr="")
class MigrationTestCase(unittest.TestCase):
def test_old_db_migrates_without_data_loss(self):
tmp = tempfile.TemporaryDirectory()
db_path = os.path.join(tmp.name, "old.db")
conn = sqlite3.connect(db_path)
try:
conn.execute(
"CREATE TABLE projects(id TEXT PRIMARY KEY, name TEXT,"
" owner_id TEXT, created_at REAL)")
conn.execute(
"CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT,"
" prompt TEXT, status TEXT, idempotency_key TEXT,"
" depends_on TEXT, priority INTEGER, created_by TEXT,"
" created_at REAL, updated_at REAL, last_heartbeat REAL,"
" UNIQUE(project_id, idempotency_key))")
conn.execute(
"INSERT INTO tasks(id, project_id, prompt, status,"
" depends_on, priority, created_by, created_at,"
" updated_at, last_heartbeat)"
" VALUES('old1', 'p1', 'old prompt', 'completed', '[]',"
" 0, 'tester', 1, 1, NULL)")
conn.commit()
finally:
conn.close()
init_db(db_path)
cols = {r[1] for r in sqlite3.connect(db_path).execute(
"PRAGMA table_info(tasks)").fetchall()}
for col in ("result_text", "error_text", "actual_model",
"started_at", "finished_at", "child_pid"):
self.assertIn(col, cols)
row = _row(db_path, "old1")
self.assertEqual(row["prompt"], "old prompt")
self.assertEqual(row["status"], "completed")
# 再跑一次 init_db 是 no-op,不炸、不丢数据。
init_db(db_path)
self.assertEqual(_row(db_path, "old1")["prompt"], "old prompt")
tmp.cleanup()
class WorkerResultColumnsTestCase(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_completed_task_writes_result_columns(self):
_insert_task(self.db_path, "t1")
worker = Worker(self.db_path, "/fake/host.py",
runner=_completed_runner,
env={"XCM_API_KEY": "test-placeholder"})
result = worker.run_one()
self.assertEqual(result["status"], "completed")
row = _row(self.db_path, "t1")
self.assertIn("done", row["result_text"])
self.assertIsNone(row["error_text"])
self.assertEqual(row["actual_model"], worker.model)
self.assertIsNotNone(row["started_at"])
self.assertIsNotNone(row["finished_at"])
self.assertGreaterEqual(row["finished_at"], row["started_at"])
def test_missing_key_writes_error_text_and_calls_runner_zero_times(self):
calls = []
def counting(cmd):
calls.append(cmd)
return _completed_runner(cmd)
_insert_task(self.db_path, "t2")
worker = Worker(self.db_path, "/fake/host.py",
runner=counting, env={})
result = worker.run_one()
self.assertEqual(result["status"], "failed")
self.assertEqual(calls, [])
row = _row(self.db_path, "t2")
self.assertIn("Missing environment variable", row["error_text"])
self.assertIsNotNone(row["started_at"])
class WorkerTrueCancelTestCase(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 _sleep_factory(self, task):
return subprocess.Popen(
[sys.executable, "-c", "import time; time.sleep(30)"],
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True,
start_new_session=True)
def test_cancel_kills_process_group_and_keeps_interrupted(self):
_insert_task(self.db_path, "long1")
worker = Worker(self.db_path, "/fake/host.py",
process_factory=self._sleep_factory,
env={"XCM_API_KEY": "test-placeholder"})
results = []
thread = threading.Thread(
target=lambda: results.append(worker.run_one()), daemon=True)
thread.start()
pgid = None
deadline = time.time() + 5
while time.time() < deadline:
row = _row(self.db_path, "long1")
if row and row["child_pid"]:
pgid = row["child_pid"]
break
time.sleep(0.05)
self.assertIsNotNone(pgid, "子进程 pid 未写进任务记录")
# 进程组当前存在(killpg 0 不抛)。
os.killpg(os.getpgid(pgid), 0)
self.assertTrue(worker.cancel("long1"))
thread.join(timeout=10)
self.assertFalse(thread.is_alive())
self.assertEqual(results[0]["status"], "interrupted")
self.assertEqual(_row(self.db_path, "long1")["status"], "interrupted")
# 进程组确实消失。
with self.assertRaises((ProcessLookupError, PermissionError, OSError)):
os.killpg(os.getpgid(pgid), 0)
def test_loop_stops_and_marks_running_interrupted(self):
_insert_task(self.db_path, "loop1")
worker = Worker(self.db_path, "/fake/host.py",
process_factory=self._sleep_factory,
env={"XCM_API_KEY": "test-placeholder"})
stop = threading.Event()
results = []
thread = threading.Thread(
target=lambda: results.extend(
worker.run_loop(interval=0.2, stop_event=stop)),
daemon=True)
thread.start()
time.sleep(1.0)
stop.set()
# 循环在任务进行中收到停止信号:_loop_stop 由 run_loop 的
# finally 外的轮询感知——这里直接置位模拟 SIGTERM 处理。
worker._loop_stop = True
thread.join(timeout=10)
self.assertFalse(thread.is_alive())
self.assertEqual(_row(self.db_path, "loop1")["status"], "interrupted")
class MetricsAndBackupTestCase(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.db_path = os.path.join(self.tmp.name, "control.db")
self.auth = AuthStore(self.db_path)
self.owner_id, self.owner_key = self.auth.create_user("owner")
self.server = create_server(self.db_path, "127.0.0.1", 0)
self.port = self.server.server_address[1]
self.thread = threading.Thread(
target=self.server.serve_forever, daemon=True)
self.thread.start()
def tearDown(self):
self.server.shutdown()
self.server.server_close()
self.thread.join(timeout=5)
self.tmp.cleanup()
def _req(self, method, path, key=None, body=None):
conn = http.client.HTTPConnection("127.0.0.1", self.port, timeout=10)
hdrs = {}
payload = None
if body is not None:
payload = json.dumps(body).encode()
hdrs["Content-Type"] = "application/json"
if key:
hdrs["X-API-Key"] = key
try:
conn.request(method, path, body=payload, headers=hdrs)
resp = conn.getresponse()
data = resp.read()
finally:
conn.close()
return resp.status, json.loads(data) if data else None
def test_metrics_counts_match_db(self):
status, proj = self._req("POST", "/projects", self.owner_key,
{"name": "m"})
self.assertEqual(status, 201)
pid = proj["id"]
_insert_task(self.db_path, "m1", status="completed",
project_id=pid, started=1000.0, finished=1002.0)
_insert_task(self.db_path, "m2", status="failed",
project_id=pid, started=1000.0, finished=1001.0)
_insert_task(self.db_path, "other", status="completed",
project_id="someone-else")
costs = CostStore(self.db_path)
costs.record("m1", pid, "gpt-6.1-sol", input_tokens=10,
output_tokens=5, source="actual", fallback_count=1)
costs.record("m2", pid, "gpt-6.1-sol", input_tokens=20,
output_tokens=0, source="estimated")
status, metrics = self._req("GET", "/metrics", self.owner_key)
self.assertEqual(status, 200)
self.assertEqual(metrics["total_tasks"], 2)
self.assertEqual(metrics["by_status"]["completed"], 1)
self.assertEqual(metrics["by_status"]["failed"], 1)
self.assertAlmostEqual(metrics["success_rate"], 0.5)
self.assertAlmostEqual(metrics["avg_duration_seconds"], 1.5)
self.assertEqual(metrics["fallback_total"], 1)
self.assertEqual(metrics["cost"]["actual_tokens"], 15)
self.assertEqual(metrics["cost"]["estimated_tokens"], 20)
# 新增观测键:队列深度、worker 聚合、分模型、未定价占比。
self.assertEqual(metrics["queue_depth"], {"queued": 0, "running": 0})
self.assertEqual(metrics["workers"]["total"], 0)
self.assertEqual(metrics["failures_last_24h"], 0)
models = {m["model"]: m for m in metrics["cost"]["by_model"]}
self.assertEqual(models["gpt-6.1-sol"]["total_tokens"], 35)
self.assertIsNotNone(metrics["cost"]["unpriced_share"])
# 无 key 401。
status, _ = self._req("GET", "/metrics")
self.assertEqual(status, 401)
def test_backup_rotation_keeps_three(self):
init_db(self.db_path)
_insert_task(self.db_path, "b1")
out_dir = os.path.join(self.tmp.name, "backups")
for stamp in ("20260101000000", "20260102000000", "20260103000000",
"20260104000000", "20260105000000"):
backup_db(self.db_path, out_dir, keep=3, timestamp=stamp)
files = sorted(os.listdir(out_dir))
self.assertEqual(len(files), 3)
self.assertEqual(files[0], "cloud-backup-20260103000000.db")
conn = sqlite3.connect(os.path.join(out_dir, files[-1]))
try:
row = conn.execute(
"SELECT id FROM tasks WHERE id = 'b1'").fetchone()
self.assertIsNotNone(row)
finally:
conn.close()
def test_postgres_backend_without_driver_fails_before_file(self):
# 环境无关:用 mock 模拟驱动缺失,而不是依赖本机真的没装驱动
# (另一任务已在本 VM 装了 psycopg2 + Postgres,原写法在该环境下
# 前提不存在而失败,并非代码回归;CI runner 无驱动时原逻辑同样成立)。
other_db = os.path.join(self.tmp.name, "pg.db")
with mock.patch("cloud.db._load_postgres_driver",
side_effect=PostgresDriverMissingError("no driver")):
with self.assertRaises(PostgresDriverMissingError):
create_server(other_db, "127.0.0.1", 0, backend="postgres")
self.assertFalse(os.path.exists(other_db))
class LoggingTestCase(unittest.TestCase):
def test_json_log_contains_task_and_event_without_secret(self):
stream = io.StringIO()
handler = logging.StreamHandler(stream)
handler.setFormatter(JsonFormatter())
logger = logging.getLogger("cloud.test-prod-gaps")
logger.handlers = [handler]
logger.setLevel(logging.INFO)
logger.propagate = False
try:
log_event(logger, "worker_claim", task_id="t9",
project_id="p9", status="running")
finally:
logger.handlers = []
payload = json.loads(stream.getvalue().strip())
self.assertEqual(payload["task_id"], "t9")
self.assertEqual(payload["event"], "worker_claim")
self.assertEqual(payload["service"], "cloud-control-plane")
# setup_logging 本身不重复加 handler。
root = setup_logging(stream=io.StringIO())
self.assertEqual(len(root.handlers), 1)
if __name__ == "__main__":
unittest.main()