Files
codex-app-server/cloud/tests/test_workers_hostpid.py
host 8ea54cf66b workers: land host/pid/address columns with idempotent migration and pid_verified stale classification
- ensure_workers_table: new tables include host TEXT, pid BIGINT,
  address TEXT; old tables gain them via _ensure_hostpid_columns
  (PRAGMA column check, missing-only ALTER, old rows keep NULL,
  never fabricates host/pid). SQLite and Postgres share this path
  (PG via the db adapter's PRAGMA translation); POSTGRES_SCHEMA
  deliberately still excludes workers, which is volatile heartbeat
  data in UNMIGRATED_TABLES (adding it broke the P2 migration
  alignment tests, verified and reverted).
- heartbeat/register: given host/pid/address refresh, omitted
  values preserve the old row; address has no derived source.
  Local worker passes host_name + own pid; HTTP heartbeat only
  stores caller-supplied values, never the server's own.
- classify_stale_workers: kill only when age strictly exceeds the
  threshold AND host matches local host AND pid is proven dead;
  equal threshold, foreign host, missing pid, or live/unknown pid
  are list_only. Classification only, never kills, never matches
  by name/prefix, never touches infra containers.
2026-11-03 02:48:55 +00:00

244 lines
9.7 KiB
Python
Raw Permalink 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.
"""workers 表 host/pid/address 落地单测:幂等迁移 + 心跳 + 失联判定。
只用标准库 + 本地 SQLite 临时库,不跑真实模型、不联网、不杀
任何进程或容器:失联判定只测 classify_stale_workers 的返回值,
pid 存活一律用注入的 pid_is_dead_fn,不依赖本机真实进程号;
pid_is_dead 本体只测非法 pid(False)与不存在的大进程号
(True),不测当前存活进程(环境相关)。
"""
import os
import sqlite3
import sys
import tempfile
import time
import unittest
from pathlib import Path
try:
from cloud.workers import (
classify_stale_workers,
ensure_workers_table,
heartbeat_worker,
list_workers,
pid_is_dead,
register_worker,
)
except ImportError:
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from cloud.workers import (
classify_stale_workers,
ensure_workers_table,
heartbeat_worker,
list_workers,
pid_is_dead,
register_worker,
)
def _cols(db_path):
conn = sqlite3.connect(db_path)
try:
rows = conn.execute("PRAGMA table_info(workers)").fetchall()
return {row[1]: row[2] for row in rows}
finally:
conn.close()
def _set_last_seen(db_path, worker_id, last_seen):
conn = sqlite3.connect(db_path)
try:
conn.execute("UPDATE workers SET last_seen = ? WHERE worker_id = ?",
(last_seen, worker_id))
conn.commit()
finally:
conn.close()
def _by_id(rows, worker_id):
for row in rows:
if row["worker_id"] == worker_id:
return row
raise AssertionError("missing worker %s" % worker_id)
class HostpidMigrationTestCase(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.db_path = os.path.join(self.tmp.name, "control.db")
def tearDown(self):
self.tmp.cleanup()
def test_new_db_table_has_host_pid_address(self):
ensure_workers_table(self.db_path)
cols = _cols(self.db_path)
self.assertIn("host", cols)
self.assertIn("pid", cols)
self.assertIn("address", cols)
def test_migration_is_idempotent(self):
ensure_workers_table(self.db_path)
register_worker(self.db_path, "w1", executor="local",
host="h1", pid=1234, address="10.0.0.1")
ensure_workers_table(self.db_path)
ensure_workers_table(self.db_path)
rows = list_workers(self.db_path)
self.assertEqual(len(rows), 1)
self.assertEqual(rows[0]["host"], "h1")
self.assertEqual(rows[0]["pid"], 1234)
self.assertEqual(rows[0]["address"], "10.0.0.1")
def test_old_db_upgrade_keeps_rows_and_leaves_new_cols_null(self):
# 手工建旧表(无 host/pid/address 列、无 running 列外的
# 旧列集),写一行,再跑迁移:旧行保留、新列为空。
conn = sqlite3.connect(self.db_path)
try:
conn.execute(
"CREATE TABLE workers("
"worker_id TEXT PRIMARY KEY, executor TEXT, "
"capacity INTEGER, last_seen DOUBLE PRECISION, "
"created_at DOUBLE PRECISION)")
conn.execute(
"INSERT INTO workers VALUES('w-old', 'docker', 1, ?, ?)",
(1000.0, 900.0))
conn.commit()
finally:
conn.close()
ensure_workers_table(self.db_path)
ensure_workers_table(self.db_path)
rows = list_workers(self.db_path)
self.assertEqual(len(rows), 1)
self.assertEqual(rows[0]["executor"], "docker")
self.assertEqual(rows[0]["last_seen"], 1000.0)
self.assertIsNone(rows[0]["host"])
self.assertIsNone(rows[0]["pid"])
self.assertIsNone(rows[0]["address"])
def test_heartbeat_refreshes_host_pid_and_preserves_when_omitted(self):
register_worker(self.db_path, "w1", executor="local",
host="h-old", pid=111, address="a-old")
row = heartbeat_worker(self.db_path, "w1", host="h-new", pid=222)
self.assertEqual(row["host"], "h-new")
self.assertEqual(row["pid"], 222)
# address 未给:保留旧值,不被洗成 None。
self.assertEqual(row["address"], "a-old")
row = heartbeat_worker(self.db_path, "w1")
self.assertEqual(row["host"], "h-new")
self.assertEqual(row["pid"], 222)
self.assertEqual(row["address"], "a-old")
def test_heartbeat_new_row_stores_given_values_and_nulls_when_omitted(self):
row = heartbeat_worker(self.db_path, "w-new", host="h1", pid=333)
self.assertEqual(row["host"], "h1")
self.assertEqual(row["pid"], 333)
self.assertIsNone(row["address"])
row = heartbeat_worker(self.db_path, "w-plain")
self.assertIsNone(row["host"])
self.assertIsNone(row["pid"])
self.assertIsNone(row["address"])
def test_invalid_pid_rejected(self):
with self.assertRaises(ValueError):
heartbeat_worker(self.db_path, "w1", pid=0)
with self.assertRaises(ValueError):
heartbeat_worker(self.db_path, "w1", pid="not-a-pid")
with self.assertRaises(ValueError):
heartbeat_worker(self.db_path, "w1", pid=True)
class StaleClassificationTestCase(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.db_path = os.path.join(self.tmp.name, "control.db")
self.now = time.time()
# 阈值 120 秒:w-kill 超阈 + 本机 + 已死;w-eq 恰等于阈值;
# w-foreign 超阈但异机;w-nopid 超阈但无 pid;w-alive 超阈
# 但 pid 还活着;w-fresh 未超阈。
register_worker(self.db_path, "w-kill", host="h-local", pid=9001)
register_worker(self.db_path, "w-eq", host="h-local", pid=9002)
register_worker(self.db_path, "w-foreign", host="h-other",
pid=9003)
register_worker(self.db_path, "w-nopid", host="h-local")
register_worker(self.db_path, "w-alive", host="h-local", pid=9004)
register_worker(self.db_path, "w-fresh", host="h-local", pid=9005)
_set_last_seen(self.db_path, "w-kill", self.now - 121)
_set_last_seen(self.db_path, "w-eq", self.now - 120)
_set_last_seen(self.db_path, "w-foreign", self.now - 200)
_set_last_seen(self.db_path, "w-nopid", self.now - 200)
_set_last_seen(self.db_path, "w-alive", self.now - 200)
_set_last_seen(self.db_path, "w-fresh", self.now - 10)
def tearDown(self):
self.tmp.cleanup()
def _classify(self):
dead = {9001, 9002, 9003}
return classify_stale_workers(
self.db_path, stale_seconds=120, local_host="h-local",
pid_is_dead_fn=lambda pid: pid in dead, now=self.now)
def test_only_pid_verified_stale_is_kill(self):
rows = self._classify()
self.assertEqual(_by_id(rows, "w-kill")["action"], "kill")
self.assertTrue(_by_id(rows, "w-kill")["pid_verified"])
self.assertEqual(_by_id(rows, "w-kill")["reason"],
"pid_verified_dead")
def test_equal_threshold_is_list_only(self):
rows = self._classify()
row = _by_id(rows, "w-eq")
self.assertEqual(row["action"], "list_only")
self.assertFalse(row["pid_verified"])
self.assertEqual(row["reason"], "age_equals_threshold")
def test_non_verified_stale_are_list_only(self):
rows = self._classify()
self.assertEqual(_by_id(rows, "w-foreign")["action"], "list_only")
self.assertEqual(_by_id(rows, "w-foreign")["reason"],
"host_mismatch")
self.assertEqual(_by_id(rows, "w-nopid")["action"], "list_only")
self.assertEqual(_by_id(rows, "w-nopid")["reason"], "pid_missing")
self.assertEqual(_by_id(rows, "w-alive")["action"], "list_only")
self.assertEqual(_by_id(rows, "w-alive")["reason"],
"pid_alive_or_unknown")
self.assertEqual(_by_id(rows, "w-fresh")["action"], "list_only")
self.assertEqual(_by_id(rows, "w-fresh")["reason"], "not_stale")
def test_classification_kills_nothing(self):
# 判定函数本身绝不杀进程:跑完后本进程仍在,且返回值里
# 除 w-kill 外无 kill 项;函数不接受任何容器名/前缀参数,
# 无法按名删容器(签名即证据,见 classify_stale_workers)。
rows = self._classify()
kills = [r for r in rows if r["action"] == "kill"]
self.assertEqual([r["worker_id"] for r in kills], ["w-kill"])
self.assertTrue(pid_is_dead(999999))
class PgBulkSchemaBoundaryTestCase(unittest.TestCase):
def test_workers_stays_out_of_postgres_bulk_schema(self):
# 界线钉住:workers 是易失心跳数据(migrations.py 的
# UNMIGRATED_TABLES),PG 批量 schema 刻意不含它;PG
# 生产库的 workers 表由 ensure_workers_table 懒建/补列。
# 本轨曾把 workers 加进 POSTGRES_SCHEMA,致 P2 迁移对齐
# 3 项变红(迁移表集、未迁移清单、覆盖率),已回退。
try:
from cloud.db import POSTGRES_SCHEMA
except ImportError:
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from cloud.db import POSTGRES_SCHEMA
joined = "\n".join(POSTGRES_SCHEMA)
self.assertNotIn("CREATE TABLE IF NOT EXISTS workers(",
joined)
class PidIsDeadTestCase(unittest.TestCase):
def test_invalid_pid_is_not_dead(self):
self.assertFalse(pid_is_dead(None))
self.assertFalse(pid_is_dead(0))
self.assertFalse(pid_is_dead("x"))
if __name__ == "__main__":
unittest.main()