mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-07 16:36:53 +08:00
refactor: enforce background task ownership
This commit is contained in:
@@ -4,6 +4,8 @@ import asyncio
|
||||
from types import SimpleNamespace
|
||||
|
||||
from app.api.endpoints import anthropic, history, message, openai, site, subscribe, webhook
|
||||
from app.api.dependencies import subscription as subscription_dependencies
|
||||
from app.application.subscription.search import SubscribeSearchActor
|
||||
from app.runtime.tasks import TaskRegistry
|
||||
|
||||
|
||||
@@ -186,6 +188,36 @@ def test_seerr_subscribe_uses_task_registry(monkeypatch) -> None:
|
||||
assert owner == "api.subscribe.seerr"
|
||||
|
||||
|
||||
def test_manual_subscription_search_uses_task_registry() -> None:
|
||||
"""手工订阅搜索命令应以稳定 owner 提交历史兼容的调度参数。"""
|
||||
registry = _TaskRegistry()
|
||||
repository = object()
|
||||
runtime = SimpleNamespace(
|
||||
subscription=SimpleNamespace(repository=lambda _db: repository)
|
||||
)
|
||||
command = subscription_dependencies.get_search_subscriptions_command(
|
||||
task_registry=registry,
|
||||
db=object(),
|
||||
runtime=runtime,
|
||||
)
|
||||
|
||||
found = asyncio.run(
|
||||
command.execute(SubscribeSearchActor(username="admin", is_superuser=True))
|
||||
)
|
||||
|
||||
function, args, kwargs, owner = registry.calls[0]
|
||||
assert found is True
|
||||
assert function is subscription_dependencies.start_scheduler_job
|
||||
assert args == ()
|
||||
assert kwargs == {
|
||||
"job_id": "subscribe_search",
|
||||
"sid": None,
|
||||
"state": "R",
|
||||
"manual": True,
|
||||
}
|
||||
assert owner == "api.subscription.search_schedule"
|
||||
|
||||
|
||||
def test_history_ai_redo_uses_task_registry() -> None:
|
||||
"""单条历史 AI 重做应登记宿主任务并使用稳定 owner。"""
|
||||
registry = _TaskRegistry()
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
"""TaskRegistry owner 静态门禁回归测试。"""
|
||||
|
||||
import textwrap
|
||||
from pathlib import Path
|
||||
|
||||
from scripts.architecture.task_ownership import collect_task_owner_violations
|
||||
|
||||
|
||||
def _scan_source(tmp_path: Path, source: str):
|
||||
"""构造最小宿主源码并通过公开扫描入口返回违规。"""
|
||||
source_path = tmp_path / "app/api/sample.py"
|
||||
source_path.parent.mkdir(parents=True)
|
||||
source_path.write_text(textwrap.dedent(source), encoding="utf-8")
|
||||
return collect_task_owner_violations(tmp_path)
|
||||
|
||||
|
||||
def test_host_task_registry_calls_use_literal_owner() -> None:
|
||||
"""当前 canonical 宿主的登记器调用必须保持 owner 零债务。"""
|
||||
assert collect_task_owner_violations() == []
|
||||
|
||||
|
||||
def test_owner_gate_tracks_known_registry_without_matching_same_named_methods(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""门禁只检查可证明的登记器接收者,并区分缺失、动态和空 owner。"""
|
||||
violations = _scan_source(
|
||||
tmp_path,
|
||||
"""
|
||||
from app.api.context import resolve_background_task_registry as resolve_registry
|
||||
from app.runtime.tasks import TaskRegistry, get_task_registry
|
||||
|
||||
def schedule(task_registry: TaskRegistry, unrelated, dynamic_owner):
|
||||
unrelated.create(work())
|
||||
task_registry.create(work())
|
||||
resolve_registry(task_registry).create_sync(work, owner=dynamic_owner)
|
||||
get_task_registry().register(task, owner=" ")
|
||||
""",
|
||||
)
|
||||
|
||||
assert [violation.method for violation in violations] == [
|
||||
"create",
|
||||
"create_sync",
|
||||
"register",
|
||||
]
|
||||
assert [violation.reason for violation in violations] == [
|
||||
"缺少显式 owner",
|
||||
"的 owner 必须是非空字符串字面量",
|
||||
"的 owner 必须是非空字符串字面量",
|
||||
]
|
||||
|
||||
|
||||
def test_owner_gate_accepts_aliases_and_stable_literal_owners(tmp_path: Path) -> None:
|
||||
"""类、模块和工厂别名仍应被识别,稳定字符串 owner 可以通过。"""
|
||||
violations = _scan_source(
|
||||
tmp_path,
|
||||
"""
|
||||
import app.runtime.tasks as runtime_tasks
|
||||
from app.runtime.tasks import TaskRegistry as Registry
|
||||
|
||||
def schedule(task_registry: Registry):
|
||||
local_registry = runtime_tasks.TaskRegistry()
|
||||
task_registry.create(work(), owner="api.example.async")
|
||||
local_registry.create_sync(work, owner="api.example.sync")
|
||||
runtime_tasks.get_task_registry().register(
|
||||
task,
|
||||
owner="api.example.existing",
|
||||
)
|
||||
""",
|
||||
)
|
||||
|
||||
assert violations == []
|
||||
@@ -1,6 +1,7 @@
|
||||
"""进程内后台任务登记与关停语义测试。"""
|
||||
|
||||
import asyncio
|
||||
import threading
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -84,3 +85,97 @@ def test_task_registry_runs_sync_function_and_tracks_until_completion() -> None:
|
||||
release.set()
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_task_registry_keeps_timed_out_sync_owner_until_real_completion() -> None:
|
||||
"""同步线程超过关停预算后仍应保留 owner,不能把包装任务取消成伪完成。"""
|
||||
|
||||
async def scenario() -> None:
|
||||
registry = TaskRegistry()
|
||||
started = threading.Event()
|
||||
release = threading.Event()
|
||||
reports: list[dict[str, object]] = []
|
||||
loop = asyncio.get_running_loop()
|
||||
previous_handler = loop.get_exception_handler()
|
||||
loop.set_exception_handler(lambda _, context: reports.append(context))
|
||||
|
||||
def worker() -> None:
|
||||
"""模拟无法由 asyncio 取消、需要外部资源自行结束的同步工作。"""
|
||||
started.set()
|
||||
release.wait()
|
||||
|
||||
try:
|
||||
task = registry.create_sync(worker, owner="test.sync-timeout")
|
||||
assert await asyncio.to_thread(started.wait, 1.0)
|
||||
|
||||
await registry.shutdown(timeout_seconds=0.001)
|
||||
|
||||
assert not task.done()
|
||||
assert [record.owner for record in registry.records] == [
|
||||
"test.sync-timeout"
|
||||
]
|
||||
assert reports[-1]["owners"] == ("test.sync-timeout",)
|
||||
|
||||
release.set()
|
||||
await task
|
||||
await asyncio.sleep(0)
|
||||
assert registry.records == ()
|
||||
finally:
|
||||
release.set()
|
||||
loop.set_exception_handler(previous_handler)
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_task_registry_keeps_stubborn_cancelled_task_visible() -> None:
|
||||
"""协程清理超过预算时只收一次取消,并在最终退出后自动清理。"""
|
||||
|
||||
async def scenario() -> None:
|
||||
registry = TaskRegistry()
|
||||
started = asyncio.Event()
|
||||
cleanup_started = asyncio.Event()
|
||||
release = asyncio.Event()
|
||||
cancellation_count = 0
|
||||
reports: list[dict[str, object]] = []
|
||||
loop = asyncio.get_running_loop()
|
||||
previous_handler = loop.get_exception_handler()
|
||||
loop.set_exception_handler(lambda _, context: reports.append(context))
|
||||
|
||||
async def worker() -> None:
|
||||
"""模拟收到取消后仍必须完成的异步清理。"""
|
||||
nonlocal cancellation_count
|
||||
started.set()
|
||||
try:
|
||||
await asyncio.Event().wait()
|
||||
except asyncio.CancelledError:
|
||||
cancellation_count += 1
|
||||
cleanup_started.set()
|
||||
await release.wait()
|
||||
|
||||
task = registry.create(worker(), owner="test.stubborn")
|
||||
await started.wait()
|
||||
try:
|
||||
await registry.shutdown(timeout_seconds=0.001)
|
||||
|
||||
assert cleanup_started.is_set()
|
||||
assert not task.done()
|
||||
assert cancellation_count == 1
|
||||
assert [record.owner for record in registry.records] == [
|
||||
"test.stubborn"
|
||||
]
|
||||
assert reports[-1]["owners"] == ("test.stubborn",)
|
||||
|
||||
await registry.shutdown(timeout_seconds=0.001)
|
||||
assert not task.done()
|
||||
assert cancellation_count == 1
|
||||
assert len(reports) == 1
|
||||
|
||||
release.set()
|
||||
await task
|
||||
await asyncio.sleep(0)
|
||||
assert registry.records == ()
|
||||
finally:
|
||||
release.set()
|
||||
loop.set_exception_handler(previous_handler)
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
Reference in New Issue
Block a user