mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-29 03:56:43 +08:00
* fix(resource): select free-threaded extension ABI * chore(deps): require moviepilot-rust 0.2.9 * perf: add free-threaded runtime comparison * feat: add free-threaded runtime profile * chore(deps): require moviepilot-rust 0.3.0 * test: isolate system endpoint import graph * fix(docker): preserve runtime profile during recovery * feat(runtime): expose active GIL state * feat(plugin): log GIL fallback attribution * test: refresh runtime observability dependency baseline * fix(plugin): validate the active uv runtime profile * test(runtime): expand free-threaded benchmark evidence * docs(runtime): define v3t governance gates * test(runtime): separate rust benchmark modes * docs: sync free-threaded architecture baseline * perf: add PostgreSQL driver comparison * docs(runtime): record final free-threaded evidence * fix(runtime): converge dual-profile dependency verification * fix(runtime): scope Python 3.14 warning filter * fix(runtime): match actual oss2 syntax warning * docs(runtime): refresh free-threaded benchmark evidence * test(architecture): refresh runtime dependency baseline * ci: skip unused Trivy Java database * build: exclude local verification artifacts * docs(runtime): refresh PostgreSQL driver benchmarks * docs(runtime): record plugin restore acceptance * docs(runtime): record amd64 candidate acceptance * ci: pin beta image publisher action * test(architecture): merge runtime dependency baseline * feat(runtime): expose Python GIL status * docs(runtime): document Python runtime status fields
458 lines
16 KiB
Python
458 lines
16 KiB
Python
"""free-threaded 镜像 A/B harness 的无 Docker 合同测试。"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import importlib.util
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
from unittest.mock import Mock
|
|
|
|
import pytest
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
SCRIPT = ROOT / "scripts" / "perf" / "free_threaded_ab.py"
|
|
|
|
|
|
def load_harness(name: str = "free_threaded_ab_test"):
|
|
"""从脚本路径加载 harness,避免把 scripts 变成运行时 package。"""
|
|
spec = importlib.util.spec_from_file_location(name, SCRIPT)
|
|
assert spec is not None and spec.loader is not None
|
|
module = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(module)
|
|
return module
|
|
|
|
|
|
def digest_image(repository: str, character: str) -> str:
|
|
"""构造测试用不可变镜像引用。"""
|
|
return f"registry.example/{repository}@sha256:{character * 64}"
|
|
|
|
|
|
def valid_preflight(variant: str) -> dict:
|
|
"""构造满足目标镜像合同的 preflight。"""
|
|
common = {
|
|
"python_version": "3.14.7",
|
|
"python_implementation": "CPython",
|
|
"moviepilot_rust_version": "0.3.0",
|
|
"rust_available": True,
|
|
"has_jieba_cut": True,
|
|
"packages": {},
|
|
"native": {},
|
|
"installed_packages": ["moviepilot-rust==0.3.0"],
|
|
"installed_packages_sha256": "package-hash",
|
|
"native_distributions": [],
|
|
"uv_pip_check": {
|
|
"returncode": 1,
|
|
"stderr": "The package `oss2` requires `crcmod>=1.7`, but it's not installed",
|
|
},
|
|
"uv_project_sync_check": {"returncode": 0},
|
|
}
|
|
if variant == "v3":
|
|
common.update(
|
|
{
|
|
"gil_disabled": False,
|
|
"gil_enabled": True,
|
|
"thread_inherit_context": 0,
|
|
"has_zhconv_fast": False,
|
|
"gil_enabled_after_imports": True,
|
|
"packages": {
|
|
"bcrypt": "4.3.0",
|
|
"brotli": "1.2.0",
|
|
"crcmod": None,
|
|
"crcmod-plus": "2.3.1",
|
|
"lxml": "6.1.2",
|
|
"orjson": "3.12.0",
|
|
"psycopg": None,
|
|
"psycopg2-binary": "2.9.12",
|
|
"zhconv-rs": "0.4.1",
|
|
},
|
|
"native": {
|
|
"crcmod_extension": True,
|
|
},
|
|
"imports": {
|
|
"moviepilot-rust": {"imported": True, "gil_after": True},
|
|
"psycopg2-binary": {"imported": True, "gil_after": True},
|
|
},
|
|
}
|
|
)
|
|
else:
|
|
common.update(
|
|
{
|
|
"gil_disabled": True,
|
|
"gil_enabled": False,
|
|
"thread_inherit_context": 0,
|
|
"has_zhconv_fast": True,
|
|
"gil_enabled_after_imports": False,
|
|
"packages": {
|
|
"bcrypt": "5.0.0",
|
|
"brotli": "1.2.0",
|
|
"crcmod": None,
|
|
"lxml": "7.0.0b1",
|
|
"orjson": "3.12.0",
|
|
"crcmod-plus": "2.3.1",
|
|
"psycopg": "3.3.4",
|
|
"psycopg2-binary": None,
|
|
"zhconv-rs": None,
|
|
},
|
|
"native": {
|
|
"crcmod_extension": True,
|
|
"psycopg_impl": "c",
|
|
},
|
|
"imports": {
|
|
"moviepilot-rust": {"imported": True, "gil_after": False},
|
|
"psycopg": {"imported": True, "gil_after": False},
|
|
},
|
|
}
|
|
)
|
|
expected_imports = {
|
|
"asyncpg",
|
|
"bcrypt",
|
|
"brotli",
|
|
"crcmod-plus",
|
|
"cryptography",
|
|
"greenlet",
|
|
"lxml",
|
|
"moviepilot-rust",
|
|
"orjson",
|
|
"oss2",
|
|
"pillow",
|
|
"pillow-avif-plugin",
|
|
"pydantic-core",
|
|
"site-resource",
|
|
"zstandard",
|
|
}
|
|
expected_imports.update(
|
|
{"psycopg2-binary", "zhconv-rs"} if variant == "v3" else {"psycopg"}
|
|
)
|
|
common["imports"] = {
|
|
name: {"imported": True, "gil_after": variant == "v3"}
|
|
for name in expected_imports
|
|
}
|
|
return common
|
|
|
|
|
|
def sample(harness, variant: str, index: int, multiplier: float = 1.0) -> dict:
|
|
"""构造可汇总的真实字段形状。"""
|
|
checksum = "same-checksum"
|
|
application = {
|
|
"rust_on": {
|
|
"seconds": 0.8 * multiplier,
|
|
"checksum": checksum,
|
|
}
|
|
}
|
|
if variant == "v3":
|
|
application["rust_off"] = {"seconds": 1.0, "checksum": checksum}
|
|
throughput = 100.0 if variant == "v3" else 120.0
|
|
return {
|
|
"variant": variant,
|
|
"sample_index": index,
|
|
"startup": {
|
|
"ready_seconds": 10.0 * multiplier,
|
|
"idle": {
|
|
"engine": {"working_set_bytes": 100_000_000},
|
|
"processes": {
|
|
"totals": {
|
|
"rss_kib": 100_000,
|
|
"pss_kib": 90_000,
|
|
"uss_kib": 80_000,
|
|
"threads": 20,
|
|
},
|
|
"main_python": {"pid": 1},
|
|
},
|
|
},
|
|
"api": {
|
|
endpoint: {
|
|
"status": 200,
|
|
"p50_ms": 1.0,
|
|
"p95_ms": 2.0,
|
|
"max_ms": 3.0,
|
|
**(
|
|
{
|
|
"runtime": {
|
|
"gil_enabled": variant == "v3",
|
|
"rust_enabled": True,
|
|
"rust_required": variant == "v3t",
|
|
}
|
|
}
|
|
if endpoint == "system_env"
|
|
else {}
|
|
),
|
|
}
|
|
for endpoint in (
|
|
"health_ready",
|
|
"dashboard_statistic",
|
|
"subscribe_list",
|
|
"system_env",
|
|
)
|
|
},
|
|
},
|
|
"hotspots": {
|
|
"fixture_sha256": harness.FIXTURE_SHA256,
|
|
"jieba_cut": {"available": True},
|
|
"gil_enabled_after_hotspots": variant == "v3",
|
|
"application": application,
|
|
"rust_concurrency": {
|
|
"1": {"throughput_ops_s": throughput, "checksum": checksum},
|
|
"32": {"throughput_ops_s": throughput, "checksum": checksum},
|
|
},
|
|
"python_concurrency": {
|
|
"1": {"throughput_ops_s": throughput, "checksum": checksum},
|
|
"32": {"throughput_ops_s": throughput, "checksum": checksum},
|
|
},
|
|
},
|
|
"postgresql": {
|
|
"probe": "app.db.engine._sync_postgresql_driver",
|
|
"result": {
|
|
"driver": None if variant == "v3" else "psycopg",
|
|
"scheme": "postgresql" if variant == "v3" else "postgresql+psycopg",
|
|
},
|
|
},
|
|
"sqlite": {
|
|
"sync": {"throughput_ops_s": 1000.0, "checksum": checksum},
|
|
"async": {"throughput_ops_s": 900.0, "checksum": checksum},
|
|
},
|
|
}
|
|
|
|
|
|
def result_for_evaluation(harness, ft_multiplier: float = 1.0) -> dict:
|
|
"""按平衡顺序构造三组 A/B。"""
|
|
samples = [
|
|
sample(harness, variant, index, ft_multiplier if variant == "v3t" else 1.0)
|
|
for variant, index in harness.SAMPLE_ORDER
|
|
]
|
|
return {
|
|
"samples": samples,
|
|
"workers": [1, 32],
|
|
"thresholds": {
|
|
"max_startup_ratio": 1.25,
|
|
"max_v3_rust_over_python_ratio": 1.10,
|
|
"max_v3t_rust_over_v3_rust_ratio": 1.25,
|
|
"min_ft_max_worker_throughput_ratio": 1.05,
|
|
"max_idle_memory_ratio": 1.25,
|
|
"max_api_p95_ratio": 1.25,
|
|
},
|
|
"preflight": {
|
|
variant: {"payload": valid_preflight(variant)}
|
|
for variant in ("v3", "v3t")
|
|
},
|
|
"images": {
|
|
"v3": {"size_bytes": 600_000_000},
|
|
"v3t": {"size_bytes": 630_000_000},
|
|
},
|
|
}
|
|
|
|
|
|
def test_digest_inputs_and_public_image_names_are_strict() -> None:
|
|
"""拒绝可变 tag、短 digest、旧 v3t 名称及参数传反。"""
|
|
harness = load_harness("free_threaded_ab_images")
|
|
standard = digest_image("moviepilot-v3", "a")
|
|
free_threaded = digest_image("moviepilot-v3t", "b")
|
|
|
|
assert harness.immutable_image(standard) == standard
|
|
harness.assert_expected_repository(standard, "moviepilot-v3")
|
|
harness.assert_expected_repository(free_threaded, "moviepilot-v3t")
|
|
|
|
for invalid in ("moviepilot-v3:latest", "moviepilot-v3@sha256:abc"):
|
|
with pytest.raises(argparse.ArgumentTypeError):
|
|
harness.immutable_image(invalid)
|
|
with pytest.raises(harness.HarnessInvalid, match="moviepilot-v3t"):
|
|
harness.assert_expected_repository(
|
|
digest_image("moviepilot-v3-ft", "c"), "moviepilot-v3t"
|
|
)
|
|
|
|
|
|
def test_local_image_id_is_an_immutable_offline_fallback() -> None:
|
|
"""未拉取时允许用本地 image ID 验收尚未发布的候选。"""
|
|
harness = load_harness("free_threaded_ab_local_image")
|
|
digest = f"sha256:{'a' * 64}"
|
|
reference = f"moviepilot-v3@{digest}"
|
|
image = SimpleNamespace(
|
|
id=digest,
|
|
attrs={
|
|
"Id": digest,
|
|
"RepoDigests": [],
|
|
"Config": {
|
|
"Labels": {
|
|
"org.moviepilot.source-revision": "local-source",
|
|
"org.opencontainers.image.version": "3.0.0-local",
|
|
}
|
|
},
|
|
},
|
|
)
|
|
images = Mock()
|
|
images.get.side_effect = [RuntimeError("no manifest digest"), image]
|
|
client = SimpleNamespace(images=images)
|
|
|
|
identity = harness.image_identity(client, reference, pull=False)
|
|
|
|
assert identity["image_id"] == digest
|
|
assert identity["runtime_reference"] == digest
|
|
assert identity["source_revision"] == "local-source"
|
|
assert identity["version"] == "3.0.0-local"
|
|
assert images.get.call_args_list[1].args == (digest,)
|
|
|
|
|
|
def test_docker_client_uses_current_cli_context_when_default_socket_fails(
|
|
monkeypatch,
|
|
) -> None:
|
|
"""Docker Desktop 等非默认 socket 由当前 CLI context 统一定位。"""
|
|
harness = load_harness("free_threaded_ab_docker_context")
|
|
monkeypatch.delenv("DOCKER_HOST", raising=False)
|
|
default_client = Mock()
|
|
default_client.ping.side_effect = RuntimeError("default socket missing")
|
|
context_client = Mock()
|
|
docker_module = SimpleNamespace(
|
|
from_env=Mock(return_value=default_client),
|
|
DockerClient=Mock(return_value=context_client),
|
|
)
|
|
monkeypatch.setattr(harness, "docker", docker_module)
|
|
run = Mock(return_value=SimpleNamespace(stdout="unix:///current/docker.sock\n"))
|
|
monkeypatch.setattr(harness.subprocess, "run", run)
|
|
|
|
assert harness.require_docker_client() is context_client
|
|
docker_module.DockerClient.assert_called_once_with(
|
|
base_url="unix:///current/docker.sock"
|
|
)
|
|
context_client.ping.assert_called_once_with()
|
|
|
|
|
|
def test_fixture_and_sample_order_are_stable() -> None:
|
|
"""固定 seed、内容 hash 和交替顺序防止样本漂移。"""
|
|
harness = load_harness("free_threaded_ab_fixture")
|
|
|
|
assert harness.fixture_hash(harness.build_fixture()) == harness.FIXTURE_SHA256
|
|
assert harness.FIXTURE_SHA256 == harness.EXPECTED_FIXTURE_SHA256
|
|
assert len(harness.FIXTURE) == 64
|
|
assert harness.SAMPLE_ORDER == (
|
|
("v3", 1),
|
|
("v3t", 1),
|
|
("v3t", 2),
|
|
("v3", 2),
|
|
("v3", 3),
|
|
("v3t", 3),
|
|
)
|
|
|
|
|
|
def test_preflight_enforces_runtime_and_native_profiles() -> None:
|
|
"""标准与 FT 镜像必须满足互斥 ABI、GIL、Rust 和原生依赖合同。"""
|
|
harness = load_harness("free_threaded_ab_preflight")
|
|
|
|
assert harness.validate_preflight("v3", valid_preflight("v3")) == []
|
|
assert harness.validate_preflight("v3t", valid_preflight("v3t")) == []
|
|
|
|
leaked = valid_preflight("v3")
|
|
leaked["has_zhconv_fast"] = True
|
|
assert any("has_zhconv_fast" in item for item in harness.validate_preflight("v3", leaked))
|
|
|
|
unsafe = valid_preflight("v3t")
|
|
unsafe["gil_enabled_after_imports"] = True
|
|
unsafe["packages"]["psycopg2-binary"] = "2.9.12"
|
|
errors = harness.validate_preflight("v3t", unsafe)
|
|
assert any("GIL" in item or "gil_enabled_after_imports" in item for item in errors)
|
|
assert any("标准原生依赖" in item for item in errors)
|
|
|
|
incomplete = valid_preflight("v3t")
|
|
incomplete["imports"].pop("lxml")
|
|
errors = harness.validate_preflight("v3t", incomplete)
|
|
assert any("缺少核心组件导入结果" in item and "lxml" in item for item in errors)
|
|
|
|
|
|
def test_evaluation_distinguishes_invalid_regression_and_pass() -> None:
|
|
"""合同错误优先 invalid,有效性能失败才归为 regression。"""
|
|
harness = load_harness("free_threaded_ab_evaluation")
|
|
|
|
summary, invalid, regressions = harness.evaluate_samples(result_for_evaluation(harness))
|
|
assert summary["ratios"]["ft_max_worker_throughput_over_v3"] == pytest.approx(1.2)
|
|
assert set(summary["application_seconds"]) == {
|
|
"v3_python",
|
|
"v3_rust",
|
|
"v3t_rust",
|
|
}
|
|
assert summary["ratios"]["v3_rust_over_python"] == pytest.approx(0.8)
|
|
assert summary["ratios"]["v3t_rust_over_v3_rust"] == pytest.approx(1.0)
|
|
assert summary["installed_packages"]["v3"]["count"] == 1
|
|
assert invalid == []
|
|
assert regressions == []
|
|
|
|
regression = result_for_evaluation(harness, ft_multiplier=1.5)
|
|
_, invalid, regressions = harness.evaluate_samples(regression)
|
|
assert invalid == []
|
|
assert any("startup" in item for item in regressions)
|
|
assert any("V3t Rust" in item for item in regressions)
|
|
|
|
v3_rust_regression = result_for_evaluation(harness)
|
|
for item in v3_rust_regression["samples"]:
|
|
if item["variant"] == "v3":
|
|
item["hotspots"]["application"]["rust_on"]["seconds"] = 1.2
|
|
_, invalid, regressions = harness.evaluate_samples(v3_rust_regression)
|
|
assert invalid == []
|
|
assert any("标准镜像启用 Rust" in item for item in regressions)
|
|
|
|
broken = result_for_evaluation(harness)
|
|
broken["samples"][0]["postgresql"]["result"]["scheme"] = "postgresql+psycopg"
|
|
summary, invalid, regressions = harness.evaluate_samples(broken)
|
|
assert summary == {}
|
|
assert any("PostgreSQL" in item for item in invalid)
|
|
assert regressions == []
|
|
|
|
incomplete_api = result_for_evaluation(harness)
|
|
incomplete_api["samples"][0]["startup"]["api"].pop("subscribe_list")
|
|
summary, invalid, regressions = harness.evaluate_samples(incomplete_api)
|
|
assert summary == {}
|
|
assert any("缺少 API 样本" in item and "subscribe_list" in item for item in invalid)
|
|
assert regressions == []
|
|
|
|
|
|
def test_markdown_and_exit_codes_preserve_machine_verdict(tmp_path: Path, monkeypatch) -> None:
|
|
"""JSON verdict、Markdown 摘要与 0/1/2 进程状态保持一致。"""
|
|
harness = load_harness("free_threaded_ab_exit")
|
|
base = {
|
|
"schema_version": harness.SCHEMA_VERSION,
|
|
"fixture": {"sha256": harness.FIXTURE_SHA256, "count": 64},
|
|
"source_revision": "abc123",
|
|
"images": {
|
|
"v3": {"reference": digest_image("moviepilot-v3", "a")},
|
|
"v3t": {"reference": digest_image("moviepilot-v3t", "b")},
|
|
},
|
|
"summary": {},
|
|
"invalid_reasons": [],
|
|
"regressions": [],
|
|
}
|
|
markdown = harness.build_markdown({**base, "verdict": "pass"})
|
|
assert "moviepilot-v3t@sha256" in markdown
|
|
assert harness.FIXTURE_SHA256 in markdown
|
|
|
|
evaluated = result_for_evaluation(harness)
|
|
summary, invalid, regressions = harness.evaluate_samples(evaluated)
|
|
report = harness.build_markdown(
|
|
{
|
|
**base,
|
|
"verdict": "pass",
|
|
"summary": summary,
|
|
"invalid_reasons": invalid,
|
|
"regressions": regressions,
|
|
}
|
|
)
|
|
assert "V3 + Python | V3 + Rust | V3t + Rust" in report
|
|
assert "Pure Python CPU probe" in report
|
|
assert "Direct Rust ABI probe" in report
|
|
|
|
argv = [
|
|
"--standard-image",
|
|
digest_image("moviepilot-v3", "a"),
|
|
"--free-threaded-image",
|
|
digest_image("moviepilot-v3t", "b"),
|
|
"--campaign",
|
|
"fake",
|
|
"--output-dir",
|
|
str(tmp_path),
|
|
]
|
|
for verdict, expected in (("pass", 0), ("regression", 1), ("invalid", 2)):
|
|
monkeypatch.setattr(
|
|
harness,
|
|
"execute_campaign",
|
|
lambda _args, value=verdict: {"verdict": value},
|
|
)
|
|
assert harness.main(argv) == expected
|