Files
trumandu--myboot/tests/integration/test_multiworker_integration.py
2026-07-13 12:34:57 +08:00

241 lines
9.0 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.
"""
多 worker 真实进程集成测试(issue #11)
启动 tests/integration/fixtures/multiworker 下的 fixture 应用(workers=2),
通过 HTTP 与临时文件断言:
1. 每个 worker 进程独立实例化 client(实例 id 与创建 pid 互不相同)
2. @on_worker_start 钩子在每个 worker 各触发一次
3. 调度任务只在 primary worker 执行
4. POSIX@on_worker_stop 钩子在优雅关闭时每个 worker 各触发一次;
Windows 下父进程 terminate() 硬终止 worker,跳过 stop 断言
运行方式: uv run pytest tests/integration -m integration
"""
import json
import os
import socket
import subprocess
import sys
import time
import urllib.error
import urllib.request
from pathlib import Path
import pytest
FIXTURE_DIR = Path(__file__).parent / "fixtures" / "multiworker"
WORKERS = 2
STARTUP_TIMEOUT = 120 # spawn 模式启动较慢,宽松等待(轮询,就绪即继续)
def _free_port() -> int:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.bind(("127.0.0.1", 0))
return sock.getsockname()[1]
def _http_get_json(url: str, timeout: float = 5.0):
with urllib.request.urlopen(url, timeout=timeout) as resp:
payload = json.loads(resp.read().decode("utf-8"))
# ResponseFormatterMiddleware 会包一层 {success, code, message, data}
if isinstance(payload, dict) and "data" in payload and "success" in payload:
return payload["data"]
return payload
def _kill_process_tree(proc: subprocess.Popen) -> None:
"""终止 fixture 父进程及其全部 worker 子进程"""
if proc.poll() is not None:
return
try:
if sys.platform == "win32":
subprocess.run(
["taskkill", "/PID", str(proc.pid), "/T", "/F"],
capture_output=True,
timeout=30,
)
else:
import signal
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
except Exception:
proc.kill()
try:
proc.wait(timeout=15)
except subprocess.TimeoutExpired:
pass
def _tail(path: Path, lines: int = 60) -> str:
try:
content = path.read_text(encoding="utf-8", errors="replace").splitlines()
return "\n".join(content[-lines:])
except OSError:
return "<no log>"
@pytest.fixture
def fixture_app(tmp_path):
"""启动 workers=2 的 fixture 应用,返回 (port, hook_dir, job_dir, proc, log)"""
port = _free_port()
hook_dir = tmp_path / "hooks"
job_dir = tmp_path / "jobs"
hook_dir.mkdir()
job_dir.mkdir()
log_path = tmp_path / "fixture_app.log"
env = {
**os.environ,
"MW_PORT": str(port),
"MW_WORKERS": str(WORKERS),
"MW_HOOK_DIR": str(hook_dir),
"MW_JOB_DIR": str(job_dir),
}
popen_kwargs = {}
if sys.platform != "win32":
popen_kwargs["start_new_session"] = True
log_file = open(log_path, "w", encoding="utf-8")
proc = subprocess.Popen(
[sys.executable, "main.py"],
cwd=str(FIXTURE_DIR),
env=env,
stdout=log_file,
stderr=subprocess.STDOUT,
**popen_kwargs,
)
try:
yield port, hook_dir, job_dir, proc, log_path
finally:
_kill_process_tree(proc)
log_file.close()
def _wait_for_startup(port, hook_dir, proc, log_path) -> None:
"""轮询等待:两个 worker 的 start 钩子文件出现且 /health 可访问"""
deadline = time.time() + STARTUP_TIMEOUT
health_ok = False
while time.time() < deadline:
if proc.poll() is not None:
pytest.fail(
f"fixture 应用提前退出 (returncode={proc.returncode})"
f"日志尾部:\n{_tail(log_path)}"
)
start_files = list(hook_dir.glob("start_*.txt"))
if not health_ok:
try:
_http_get_json(f"http://127.0.0.1:{port}/health", timeout=2)
health_ok = True
except (urllib.error.URLError, OSError, ValueError):
pass
if health_ok and len(start_files) >= WORKERS:
return
time.sleep(0.5)
pytest.fail(
f"启动超时: health_ok={health_ok}, "
f"start_hooks={len(list(hook_dir.glob('start_*.txt')))}/{WORKERS}"
f"日志尾部:\n{_tail(log_path)}"
)
def _read_start_records(hook_dir: Path):
"""解析 start 钩子记录: {pid: (worker_id, client_instance_id)}"""
records = {}
duplicate_lines = []
for path in hook_dir.glob("start_*.txt"):
lines = [
line for line in
path.read_text(encoding="utf-8").splitlines() if line.strip()
]
if len(lines) != 1:
duplicate_lines.append((path.name, lines))
pid_str, worker_id_str, instance_id = lines[0].split(",")
records[int(pid_str)] = (int(worker_id_str), instance_id)
assert not duplicate_lines, f"start 钩子在同一 worker 内触发多次: {duplicate_lines}"
return records
@pytest.mark.integration
def test_multiworker_per_worker_instances_hooks_and_scheduler(fixture_app):
port, hook_dir, job_dir, proc, log_path = fixture_app
_wait_for_startup(port, hook_dir, proc, log_path)
# ---- 1. start 钩子:每个 worker 各触发一次,worker_id 覆盖 1..N ----
start_records = _read_start_records(hook_dir)
assert len(start_records) == WORKERS, f"期望 {WORKERS} 个 worker 触发 start 钩子: {start_records}"
worker_ids = sorted(wid for wid, _ in start_records.values())
assert worker_ids == list(range(1, WORKERS + 1)), start_records
# ---- 2. client 实例独立:不同 worker 的实例 id 互不相同 ----
instance_ids = [iid for _, iid in start_records.values()]
assert all(instance_ids), f"worker 内未解析到 client 实例: {start_records}"
assert len(set(instance_ids)) == WORKERS, (
f"不同 worker 共享了同一 client 实例(issue #11 回归): {start_records}"
)
# ---- 3. HTTP 轮询 /whoamipid -> 实例 id 是单射,且实例创建于本进程 ----
observed = {} # pid -> set(instance_id)
for _ in range(50):
data = _http_get_json(f"http://127.0.0.1:{port}/api/whoami")
pid = data["pid"]
observed.setdefault(pid, set()).add(data["client_instance_id"])
# client 实例必须创建于响应请求的同一进程(而非 fork 前的父进程)
assert data["client_created_pid"] == pid, data
# 响应进程必须是已知的 worker 进程,且实例 id 与钩子记录一致
assert pid in start_records, (pid, start_records)
assert data["client_instance_id"] == start_records[pid][1], data
assert data["worker_id"] == start_records[pid][0], data
for pid, ids in observed.items():
assert len(ids) == 1, f"同一 worker 返回了多个 client 实例 id: {pid} -> {ids}"
# 注:Windows 下多 socket 绑定同端口(SO_REUSEADDR)不保证连接均匀分发,
# HTTP 观测到的 pid 数量不做强制断言;跨 worker 独立性已由钩子记录覆盖
# ---- 4. 调度任务仅 primary worker 执行 ----
deadline = time.time() + 30
while time.time() < deadline and not list(job_dir.glob("job_*.txt")):
time.sleep(0.5)
job_files = list(job_dir.glob("job_*.txt"))
assert job_files, f"调度任务未执行,日志尾部:\n{_tail(log_path)}"
# 再观察几秒,确认没有其他 worker 也在跑任务
time.sleep(3)
job_pids = sorted(
int(path.stem.split("_")[1]) for path in job_dir.glob("job_*.txt")
)
primary_pid = next(
pid for pid, (wid, _) in start_records.items() if wid == 1
)
assert job_pids == [primary_pid], (
f"调度任务应只在 primary worker (pid={primary_pid}) 执行,"
f"实际执行进程: {job_pids}"
)
# ---- 4b. all_workers=True 任务在每个 worker 都执行 ----
deadline = time.time() + 30
while time.time() < deadline and len(list(job_dir.glob("awjob_*.txt"))) < WORKERS:
time.sleep(0.5)
awjob_pids = sorted(
int(path.stem.split("_")[1]) for path in job_dir.glob("awjob_*.txt")
)
assert awjob_pids == sorted(start_records.keys()), (
f"all_workers 任务应在全部 worker 执行 (pids={sorted(start_records.keys())})"
f"实际执行进程: {awjob_pids},日志尾部:\n{_tail(log_path)}"
)
# ---- 5. POSIX)优雅关闭后 stop 钩子每个 worker 各触发一次 ----
if sys.platform != "win32":
proc.terminate() # SIGTERM -> 优雅关闭 -> lifespan 关闭阶段
try:
proc.wait(timeout=30)
except subprocess.TimeoutExpired:
pass
deadline = time.time() + 15
while time.time() < deadline and len(list(hook_dir.glob("stop_*.txt"))) < WORKERS:
time.sleep(0.5)
stop_files = list(hook_dir.glob("stop_*.txt"))
assert len(stop_files) == WORKERS, (
f"期望 {WORKERS} 个 worker 触发 stop 钩子,实际 {len(stop_files)}"
)
# Windows: 父进程 terminate() 硬终止 workerstop 钩子不保证触发,跳过断言