diff --git a/docs/testing.md b/docs/testing.md index 08445f0b1..fe3f8a538 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -301,6 +301,7 @@ def test_recognize_prefers_explicit_identity(sample_meta, monkeypatch): - **去重脚本验证**:`node --test .github/scripts/reuse.test.mjs` 离线覆盖成功复用及保守回退,两个检查工作流均在判定前执行。复用 job 仅持有 contents/actions/pull-requests 读取权限;没有修改分支保护设置。 - **门禁**:`.github/workflows/test.yml` 在指向 `v3` 的 `pull_request` / `push` 及手动触发时,从 `uv.lock` 同步环境。宿主架构门禁分为两个并行 job,`architecture` 运行宿主依赖、运行契约、事件政策测试和基线快照,`architecture-ratchets` 运行类型、复杂度、并发、Ruff/mypy ratchet 与启动性能检查;其余测试通过 `coverage run --parallel-mode tests/run.py --exclude-architecture-gate --shard N/6` 分到 6 个 job,一次执行同时验证单测并采集覆盖率;门禁已运行的架构测试不在分片中重复执行。每个分片都有独立进程和临时 `CONFIG_DIR`,不共用 SQLite 或进程级状态,由单一报告 job 合并后检查除 `app/plugins` 外全部 `app` Python 文件的逐文件低水位。 +- **启动性能**:`scripts/startup/performance.py --check --repeat 3` 对 `app.startup.lifecycle`、`app.factory`、`app.main` 分别执行三次冷导入采样。各入口的耗时上限为已提交基线中位数向上取整到整秒,即 `ceil(median_ms / 1000) * 1000` 毫秒,例如基线 1122.382ms 对应上限 2000ms,基线恰为 1000ms 时上限仍为 1000ms;本次采样中位数不超过对应上限即通过,超过则失败。宿主模块数量、生命周期组件集合与顺序、线程/任务释放和隔离采样无数据库连接的契约仍按原规则检查。 - **跨仓观察**:`.github/workflows/architecture-observe.yml` 每周或手工检出官方插件仓最新 `main`,使用 `--check-plugins` 比较公开导入、Hook 和动态 API 契约。它只上传 `official-plugin-architecture-report.json`,不会自动刷新 fixture;语义变化必须人工审查后显式执行 `--write-plugins`。 - **静态检查**:`.github/workflows/pylint.yml` 对指向 `v3` 的 PR、推送和手工触发运行 Pylint。PR/推送改动到的 Python 文件是硬门禁;`app/` 全量扫描保留为建议性 JSON 构建工件,存量告警不会掩盖或阻塞本次增量治理。 - **PR 本地验证**:按上文「验证范围与维护者安排」选择 contributor 默认检查或已记录的维护者安排,统一处理证据复用与失败归属;需要断点、输出顺序或测试污染诊断时使用 `--serial`。确认受影响路径与零真实出站,准确标注验证范围;本地执行安排不改变 CI 的全量验证与复用合同。 diff --git a/scripts/startup/performance.py b/scripts/startup/performance.py index e9111efca..5708312bd 100644 --- a/scripts/startup/performance.py +++ b/scripts/startup/performance.py @@ -5,6 +5,7 @@ import argparse import json +import math import os import platform import statistics @@ -29,13 +30,6 @@ IMPORT_TARGETS = ( ) RESULT_PREFIX = "MOVIEPILOT_IMPORT_BASELINE=" LIFECYCLE_RESULT_PREFIX = "MOVIEPILOT_LIFECYCLE_BASELINE=" -# 冷导入时间预算以历史最大耗时的两倍为候选上限。 -PERFORMANCE_FACTOR = 2.0 -# 另一个候选上限为历史最大耗时加 500 毫秒,取较大值后应用抖动系数。 -PERFORMANCE_SLACK_MS = 500.0 -# 基线由维护者平台生成、CI 在 Linux 托管执行器 检查;在既有 2 倍/500ms -# 硬预算之外保留 15% 的跨平台与持续调度抖动带,结构契约仍保持精确比较。 -PERFORMANCE_JITTER_FACTOR = 1.15 def measure_import(target: str) -> dict[str, Any]: @@ -311,7 +305,7 @@ def check_baseline( actual: dict[str, Any], ) -> list[str]: """ - 比较稳定资源契约与宽松耗时预算,返回所有不符合项。 + 比较稳定资源契约与基线中位数向上取整秒的耗时上限,返回所有不符合项。 """ errors: list[str] = [] if expected.get("schema_version") != actual.get("schema_version"): @@ -338,7 +332,7 @@ def check_baseline( if actual_target["median_ms"] > budget_ms: errors.append( f"{target} 冷导入中位数 {actual_target['median_ms']}ms " - f"超过预算 {round(budget_ms, 3)}ms" + f"超过预算 {budget_ms}ms" ) expected_modes = expected.get("lifecycle", {}).get("modes", {}) actual_modes = actual.get("lifecycle", {}).get("modes", {}) @@ -372,12 +366,12 @@ def check_baseline( def import_budget_ms(expected_target: dict[str, Any]) -> float: """ - 根据已提交样本计算跨平台冷导入中位数预算。 + 将已提交基线中位数向上取整到整秒,作为对应入口的耗时上限。 + + :param expected_target: 对应入口的已提交冷导入采样,median_ms 的单位为毫秒 + :return: 向上取整后的预算,单位为毫秒;整秒值保持不变 """ - return max( - expected_target["max_ms"] * PERFORMANCE_FACTOR, - expected_target["max_ms"] + PERFORMANCE_SLACK_MS, - ) * PERFORMANCE_JITTER_FACTOR + return math.ceil(expected_target["median_ms"] / 1000.0) * 1000.0 def describe_import_measurements( @@ -393,7 +387,7 @@ def describe_import_measurements( ( f"启动性能采样:{target} 中位数 " f"{actual_targets[target]['median_ms']}ms / 预算 " - f"{round(import_budget_ms(expected_targets[target]), 3)}ms" + f"{import_budget_ms(expected_targets[target])}ms" ) for target in sorted(set(expected_targets) & set(actual_targets)) ] @@ -417,7 +411,7 @@ def parse_args(argv: Optional[list[str]] = None) -> argparse.Namespace: dest="operation", action="store_const", const="check", - help="按已提交基线检查资源契约和宽松性能预算", + help="检查资源契约,冷导入耗时上限按基线中位数向上取整秒", ) action.add_argument( "--write", diff --git a/tests/test_architecture_baseline_cli.py b/tests/test_architecture_baseline_cli.py index ee4e06863..3cf5b8dbe 100644 --- a/tests/test_architecture_baseline_cli.py +++ b/tests/test_architecture_baseline_cli.py @@ -854,7 +854,7 @@ def test_performance_check_is_read_only(tmp_path: Path, monkeypatch, capsys): assert output.read_bytes() == content_before output_text = capsys.readouterr().out - assert "启动性能采样:app.factory 中位数 90.0ms / 预算 690.0ms" in output_text + assert "启动性能采样:app.factory 中位数 90.0ms / 预算 1000.0ms" in output_text assert "检查通过" in output_text @@ -897,19 +897,37 @@ def test_performance_check_ignores_environment_provenance_drift(): assert startup_performance.check_baseline(expected, actual) == [] -def test_performance_check_keeps_hosted_runner_jitter_margin(): +@pytest.mark.parametrize("target", startup_performance.IMPORT_TARGETS) +@pytest.mark.parametrize( + ("baseline_median_ms", "budget_ms"), + ( + (90.0, 1000.0), + (1000.0, 1000.0), + (1000.001, 2000.0), + (1122.382, 2000.0), + (2000.001, 3000.0), + (3000.0, 3000.0), + ), +) +def test_performance_check_rounds_baseline_median_up_to_whole_seconds( + target, baseline_median_ms, budget_ms +): """ - 跨平台托管 runner 可使用 15% 抖动带,但超过最终预算仍必须失败。 + 各入口按基线中位数向上取整秒,等于上限通过,超过失败,最大值不影响预算。 """ expected = _performance_sample() - within_margin = _performance_sample() - within_margin["targets"]["app.factory"]["median_ms"] = 690.0 - above_margin = _performance_sample() - above_margin["targets"]["app.factory"]["median_ms"] = 690.001 + expected_target = expected["targets"].pop("app.factory") + expected_target["median_ms"] = baseline_median_ms + expected_target["max_ms"] = baseline_median_ms + 5000.0 + expected["targets"][target] = expected_target + within_limit = json.loads(json.dumps(expected)) + within_limit["targets"][target]["median_ms"] = budget_ms + above_limit = json.loads(json.dumps(expected)) + above_limit["targets"][target]["median_ms"] = budget_ms + 0.001 - assert startup_performance.check_baseline(expected, within_margin) == [] - assert startup_performance.check_baseline(expected, above_margin) == [ - "app.factory 冷导入中位数 690.001ms 超过预算 690.0ms" + assert startup_performance.check_baseline(expected, within_limit) == [] + assert startup_performance.check_baseline(expected, above_limit) == [ + f"{target} 冷导入中位数 {budget_ms + 0.001}ms 超过预算 {budget_ms}ms" ]