#!/usr/bin/env python3 """记录 MoviePilot 关键入口的冷导入耗时基线。""" import argparse import json import os import platform import statistics import subprocess import sys from datetime import datetime, timezone from pathlib import Path from typing import Any, Optional PROJECT_ROOT = Path(__file__).resolve().parents[2] DEFAULT_OUTPUT = ( PROJECT_ROOT / "tests" / "fixtures" / "architecture" / "startup-performance-baseline.json" ) IMPORT_TARGETS = ( "app.startup.lifecycle", "app.factory", "app.main", ) RESULT_PREFIX = "MOVIEPILOT_IMPORT_BASELINE=" LIFECYCLE_RESULT_PREFIX = "MOVIEPILOT_LIFECYCLE_BASELINE=" PERFORMANCE_FACTOR = 2.0 PERFORMANCE_SLACK_MS = 500.0 def measure_import(target: str) -> dict[str, Any]: """在独立解释器中测量单个模块的冷导入耗时与模块增量。""" code = f""" import importlib import json import sys import time before = set(sys.modules) started_at = time.perf_counter() importlib.import_module({target!r}) elapsed_ms = (time.perf_counter() - started_at) * 1000 print({RESULT_PREFIX!r} + json.dumps({{ 'elapsed_ms': elapsed_ms, 'loaded_module_count': len(set(sys.modules) - before), }})) """ environment = os.environ.copy() environment["PYTHONHASHSEED"] = "0" result = subprocess.run( [sys.executable, "-c", code], cwd=PROJECT_ROOT, env=environment, capture_output=True, text=True, check=False, ) if result.returncode != 0: raise RuntimeError( f"冷导入 {target} 失败:{result.stderr.strip() or result.stdout.strip()}" ) payload_line = next( ( line for line in reversed(result.stdout.splitlines()) if line.startswith(RESULT_PREFIX) ), None, ) if payload_line is None: raise RuntimeError(f"冷导入 {target} 未输出测量结果") return json.loads(payload_line.removeprefix(RESULT_PREFIX)) def measure_lifecycle(safe_mode: bool) -> dict[str, Any]: """在隔离的无 I/O 生命周期中测量正常/安全模式编排和资源增量。 这里故意把每个组件回调替换为 no-op:基线用于比较生命周期编排、阶段计时、任务 和线程是否泄漏,不应在生成基线时启动真实插件、调度器或连接用户数据库。 """ code = f""" import asyncio import dataclasses import json import threading import time from fastapi import FastAPI from app.testing.bootstrap import ensure_sites_stub ensure_sites_stub() from app.startup import lifecycle def _noop(): return None async def _async_noop(): return None def _isolated_start(component, probe_app): # 保留 readiness 所需状态转换,其余真实启动回调替换为空操作。 if component.start is None: return None if component.name == '数据库准备': return lambda: lifecycle.get_application_health( probe_app ).mark_database_ready() return _noop async def _probe(): lifecycle.settings.MOVIEPILOT_SAFE_MODE = {safe_mode!r} lifecycle.init_extra = _async_noop lifecycle.global_vars.set_loop = lambda loop: None lifecycle.global_vars.stop_system = lambda: None lifecycle.LoggerManager.shutdown = lambda: None probe_app = FastAPI() original_components = lifecycle.build_lifecycle_components(probe_app) isolated_components = tuple( dataclasses.replace( component, start=_isolated_start(component, probe_app), stop=_noop if component.stop is not None else None, ) for component in original_components ) lifecycle.build_lifecycle_components = lambda _app: isolated_components stage_ms = {{}} original_step = lifecycle.run_startup_step async def timed_step(name, callback, timeout_seconds=None): started = time.perf_counter() result = await original_step(name, callback, timeout_seconds) stage_ms[name] = round((time.perf_counter() - started) * 1000, 3) return result lifecycle.run_startup_step = timed_step before_threads = threading.active_count() before_tasks = len(asyncio.all_tasks()) started = time.perf_counter() async with lifecycle.lifespan(probe_app): startup_ms = (time.perf_counter() - started) * 1000 started_threads = threading.active_count() started_tasks = len(asyncio.all_tasks()) finished_ms = (time.perf_counter() - started) * 1000 print({LIFECYCLE_RESULT_PREFIX!r} + json.dumps({{ 'mode': 'safe' if {safe_mode!r} else 'normal', 'enabled_component_count': len([ component for component in isolated_components if component.enabled({safe_mode!r}) ]), 'startup_ms': round(startup_ms, 3), 'full_lifespan_ms': round(finished_ms, 3), 'stage_ms': stage_ms, 'threads_before': before_threads, 'threads_started': started_threads, 'threads_after': threading.active_count(), 'tasks_before': before_tasks, 'tasks_started': started_tasks, 'tasks_after': len(asyncio.all_tasks()), # no-op 采样不建立数据库连接;字段显式记录采样范围,避免误读为生产连接数。 'database_connections_started': 0, }})) asyncio.run(_probe()) """ result = subprocess.run( [sys.executable, "-c", code], cwd=PROJECT_ROOT, env={**os.environ, "PYTHONHASHSEED": "0"}, capture_output=True, text=True, check=False, ) if result.returncode != 0: raise RuntimeError( f"{('安全' if safe_mode else '正常')}模式生命周期采样失败:" f"{result.stderr.strip() or result.stdout.strip()}" ) payload_line = next( ( line for line in reversed(result.stdout.splitlines()) if line.startswith(LIFECYCLE_RESULT_PREFIX) ), None, ) if payload_line is None: raise RuntimeError("生命周期采样未输出测量结果") return json.loads(payload_line.removeprefix(LIFECYCLE_RESULT_PREFIX)) def collect_baseline(repeat: int) -> dict[str, Any]: """按目标重复采样并生成便于后续对比的统计摘要。""" targets: dict[str, Any] = {} for target in IMPORT_TARGETS: samples = [measure_import(target) for _ in range(repeat)] elapsed = [sample["elapsed_ms"] for sample in samples] targets[target] = { "loaded_module_count": int( statistics.median( sample["loaded_module_count"] for sample in samples ) ), "max_ms": round(max(elapsed), 3), "median_ms": round(statistics.median(elapsed), 3), "min_ms": round(min(elapsed), 3), "samples_ms": [round(value, 3) for value in elapsed], } lifecycle_modes: dict[str, Any] = {} for safe_mode, mode_name in ((False, "normal"), (True, "safe")): samples = [measure_lifecycle(safe_mode) for _ in range(repeat)] lifecycle_modes[mode_name] = { "samples": samples, "median_startup_ms": round( statistics.median(sample["startup_ms"] for sample in samples), 3, ), "median_full_lifespan_ms": round( statistics.median(sample["full_lifespan_ms"] for sample in samples), 3, ), "enabled_component_count": samples[0]["enabled_component_count"], } return { "schema_version": 1, "generated_at": datetime.now(timezone.utc).isoformat(), "platform": platform.platform(), "python": platform.python_version(), "repeat": repeat, "targets": targets, "lifecycle": { "scope": "isolated no-op component callbacks; no plugin/network/database I/O", "modes": lifecycle_modes, }, } def check_baseline( expected: dict[str, Any], actual: dict[str, Any], ) -> list[str]: """比较稳定资源契约与宽松耗时预算,返回所有不符合项。""" errors: list[str] = [] expected_targets = expected.get("targets", {}) actual_targets = actual.get("targets", {}) if set(expected_targets) != set(actual_targets): errors.append("冷导入目标集合已变化") for target in sorted(set(expected_targets) & set(actual_targets)): expected_target = expected_targets[target] actual_target = actual_targets[target] if ( actual_target["loaded_module_count"] != expected_target["loaded_module_count"] ): errors.append( f"{target} 加载模块数变化:" f"{expected_target['loaded_module_count']} -> " f"{actual_target['loaded_module_count']}" ) budget_ms = max( expected_target["max_ms"] * PERFORMANCE_FACTOR, expected_target["max_ms"] + PERFORMANCE_SLACK_MS, ) if actual_target["median_ms"] > budget_ms: errors.append( f"{target} 冷导入中位数 {actual_target['median_ms']}ms " f"超过预算 {round(budget_ms, 3)}ms" ) expected_modes = expected.get("lifecycle", {}).get("modes", {}) actual_modes = actual.get("lifecycle", {}).get("modes", {}) if set(expected_modes) != set(actual_modes): errors.append("生命周期模式集合已变化") for mode_name in sorted(set(expected_modes) & set(actual_modes)): expected_mode = expected_modes[mode_name] actual_mode = actual_modes[mode_name] if ( actual_mode["enabled_component_count"] != expected_mode["enabled_component_count"] ): errors.append( f"{mode_name} 模式组件数变化:" f"{expected_mode['enabled_component_count']} -> " f"{actual_mode['enabled_component_count']}" ) for sample in actual_mode.get("samples", []): if sample["threads_after"] != sample["threads_before"]: errors.append(f"{mode_name} 模式存在未释放线程") if sample["tasks_after"] != sample["tasks_before"]: errors.append(f"{mode_name} 模式存在未释放异步任务") if sample["database_connections_started"] != 0: errors.append(f"{mode_name} 模式隔离采样建立了数据库连接") return errors def parse_args(argv: Optional[list[str]] = None) -> argparse.Namespace: """解析只读打印、检查或显式写入操作。""" parser = argparse.ArgumentParser(description=__doc__) action = parser.add_mutually_exclusive_group() action.add_argument( "--print", dest="operation", action="store_const", const="print", help="打印本次采样且不写文件(默认)", ) action.add_argument( "--check", dest="operation", action="store_const", const="check", help="按已提交基线检查资源契约和宽松性能预算", ) action.add_argument( "--write", dest="operation", action="store_const", const="write", help="显式写入采样基线", ) parser.add_argument("--repeat", type=int, default=3) parser.add_argument("--output", type=Path, default=DEFAULT_OUTPUT) parser.set_defaults(operation="print") return parser.parse_args(argv) def _display_path(path: Path) -> Path: """优先返回仓库相对路径,便于 CLI 输出稳定可读。""" try: return path.relative_to(PROJECT_ROOT) except ValueError: return path def main(argv: Optional[list[str]] = None) -> int: """采样关键入口,并按显式操作打印、检查或写入结果。""" args = parse_args(argv) if args.repeat < 1: raise SystemExit("--repeat 必须大于等于 1") baseline = collect_baseline(args.repeat) output = args.output.resolve() if args.operation == "print": print(json.dumps(baseline, ensure_ascii=False, indent=2)) return 0 if args.operation == "check": if not output.is_file(): raise SystemExit(f"性能基线不存在:{output}") expected = json.loads(output.read_text(encoding="utf-8")) errors = check_baseline(expected, baseline) if errors: for error in errors: print(f"性能基线检查失败:{error}", file=sys.stderr) return 1 print(f"性能基线检查通过:{_display_path(output)}") return 0 print(f"即将写入:{_display_path(output)}") output.parent.mkdir(parents=True, exist_ok=True) output.write_text( json.dumps(baseline, ensure_ascii=False, indent=2) + "\n", encoding="utf-8", ) print(f"已写入:{_display_path(output)}") return 0 if __name__ == "__main__": raise SystemExit(main())