refactor: govern background tasks and query ownership

This commit is contained in:
jxxghp
2026-08-23 13:24:04 +08:00
parent 43c173a0e7
commit f1e542bef0
37 changed files with 1570 additions and 510 deletions
+24 -3
View File
@@ -13,8 +13,8 @@
"runtime_to_db": [],
"workflow_to_db": []
},
"edge_count": 6444,
"edge_sha256": "b5db7b31c7ea4dd7311fcd9e11a49eb32feb6939f896ed85703b3573408752a8",
"edge_count": 6464,
"edge_sha256": "256ae6f9cd8950b0fe2743e81300551877eb595bda37b8ce114f439b33496af9",
"edges": [
"app -> app.runtime",
"app -> app.runtime.compat",
@@ -1510,6 +1510,8 @@
"app.api.context -> app.application.subscription.delete",
"app.api.context -> app.application.subscription.identity",
"app.api.context -> app.application.subscription.mutation",
"app.api.context -> app.runtime",
"app.api.context -> app.runtime.tasks",
"app.api.context -> app.startup",
"app.api.context -> app.startup.context",
"app.api.dependencies.agent -> app.api",
@@ -1599,6 +1601,7 @@
"app.api.dependencies.subscription -> app.runtime",
"app.api.dependencies.subscription -> app.runtime.events",
"app.api.dependencies.subscription -> app.runtime.log",
"app.api.dependencies.subscription -> app.runtime.tasks",
"app.api.dependencies.subscription -> app.schemas",
"app.api.dependencies.subscription -> app.schemas.types",
"app.api.dependencies.subscription -> app.startup",
@@ -1945,6 +1948,7 @@
"app.api.endpoints.message -> app.adapters.web.security",
"app.api.endpoints.message -> app.adapters.web.security.access",
"app.api.endpoints.message -> app.api",
"app.api.endpoints.message -> app.api.context",
"app.api.endpoints.message -> app.api.dependencies",
"app.api.endpoints.message -> app.api.dependencies.agent",
"app.api.endpoints.message -> app.api.dependencies.auth",
@@ -1961,6 +1965,7 @@
"app.api.endpoints.message -> app.runtime.extensions",
"app.api.endpoints.message -> app.runtime.extensions.service_config",
"app.api.endpoints.message -> app.runtime.log",
"app.api.endpoints.message -> app.runtime.tasks",
"app.api.endpoints.message -> app.schemas",
"app.api.endpoints.message -> app.schemas.message",
"app.api.endpoints.message -> app.schemas.response",
@@ -2048,6 +2053,7 @@
"app.api.endpoints.plugin -> app.adapters.web.security",
"app.api.endpoints.plugin -> app.adapters.web.security.access",
"app.api.endpoints.plugin -> app.api",
"app.api.endpoints.plugin -> app.api.context",
"app.api.endpoints.plugin -> app.api.dependencies",
"app.api.endpoints.plugin -> app.api.dependencies.auth",
"app.api.endpoints.plugin -> app.api.dependencies.plugin",
@@ -2070,6 +2076,7 @@
"app.api.endpoints.plugin -> app.runtime.extensions.plugin",
"app.api.endpoints.plugin -> app.runtime.extensions.plugin.contracts",
"app.api.endpoints.plugin -> app.runtime.log",
"app.api.endpoints.plugin -> app.runtime.tasks",
"app.api.endpoints.plugin -> app.schemas",
"app.api.endpoints.plugin -> app.schemas.common",
"app.api.endpoints.plugin -> app.schemas.plugin",
@@ -2122,6 +2129,7 @@
"app.api.endpoints.site -> app.adapters.web.security",
"app.api.endpoints.site -> app.adapters.web.security.access",
"app.api.endpoints.site -> app.api",
"app.api.endpoints.site -> app.api.context",
"app.api.endpoints.site -> app.api.dependencies",
"app.api.endpoints.site -> app.api.dependencies.auth",
"app.api.endpoints.site -> app.api.dependencies.site",
@@ -2145,6 +2153,7 @@
"app.api.endpoints.site -> app.domain.site",
"app.api.endpoints.site -> app.runtime",
"app.api.endpoints.site -> app.runtime.log",
"app.api.endpoints.site -> app.runtime.tasks",
"app.api.endpoints.site -> app.schemas",
"app.api.endpoints.site -> app.schemas.common",
"app.api.endpoints.site -> app.schemas.response",
@@ -2180,6 +2189,7 @@
"app.api.endpoints.subscribe -> app.adapters.web.security",
"app.api.endpoints.subscribe -> app.adapters.web.security.access",
"app.api.endpoints.subscribe -> app.api",
"app.api.endpoints.subscribe -> app.api.context",
"app.api.endpoints.subscribe -> app.api.dependencies",
"app.api.endpoints.subscribe -> app.api.dependencies.auth",
"app.api.endpoints.subscribe -> app.api.dependencies.subscription",
@@ -2201,6 +2211,7 @@
"app.api.endpoints.subscribe -> app.domain.metainfo",
"app.api.endpoints.subscribe -> app.runtime",
"app.api.endpoints.subscribe -> app.runtime.events",
"app.api.endpoints.subscribe -> app.runtime.tasks",
"app.api.endpoints.subscribe -> app.schemas",
"app.api.endpoints.subscribe -> app.schemas.common",
"app.api.endpoints.subscribe -> app.schemas.event",
@@ -2347,9 +2358,12 @@
"app.api.endpoints.webhook -> app.adapters.web.security",
"app.api.endpoints.webhook -> app.adapters.web.security.access",
"app.api.endpoints.webhook -> app.api",
"app.api.endpoints.webhook -> app.api.context",
"app.api.endpoints.webhook -> app.api.response",
"app.api.endpoints.webhook -> app.chain",
"app.api.endpoints.webhook -> app.chain.webhook",
"app.api.endpoints.webhook -> app.runtime",
"app.api.endpoints.webhook -> app.runtime.tasks",
"app.api.endpoints.webhook -> app.schemas",
"app.api.endpoints.webhook -> app.schemas.response",
"app.api.endpoints.workflow -> app.adapters",
@@ -2555,6 +2569,8 @@
"app.application.mediaserver -> app.schemas.mediaserver",
"app.application.mediaserver -> app.schemas.system",
"app.application.mediaserver -> app.schemas.types",
"app.application.messaging.agent -> app.runtime",
"app.application.messaging.agent -> app.runtime.tasks",
"app.application.messaging.agent -> app.schemas",
"app.application.messaging.agent -> app.schemas.types",
"app.application.messaging.chat -> app.application",
@@ -5988,6 +6004,8 @@
"app.startup.context -> app.application.subscription.identity",
"app.startup.context -> app.application.subscription.mutation",
"app.startup.context -> app.application.workflow",
"app.startup.context -> app.runtime",
"app.startup.context -> app.runtime.tasks",
"app.startup.database -> app.adapters",
"app.startup.database -> app.adapters.system",
"app.startup.database -> app.adapters.system.backup",
@@ -6050,6 +6068,7 @@
"app.startup.lifecycle -> app.runtime.log",
"app.startup.lifecycle -> app.runtime.settings",
"app.startup.lifecycle -> app.runtime.state",
"app.startup.lifecycle -> app.runtime.tasks",
"app.startup.lifecycle -> app.runtime.topology",
"app.startup.lifecycle -> app.startup",
"app.startup.lifecycle -> app.startup.cache_initializer",
@@ -6164,6 +6183,7 @@
"app.startup.modules_initializer -> app.runtime.observability",
"app.startup.modules_initializer -> app.runtime.settings",
"app.startup.modules_initializer -> app.runtime.state",
"app.startup.modules_initializer -> app.runtime.tasks",
"app.startup.modules_initializer -> app.runtime.thread",
"app.startup.modules_initializer -> app.scheduler",
"app.startup.modules_initializer -> app.schemas",
@@ -6461,7 +6481,7 @@
"app.workflow.actions.transfer_file -> app.workflow",
"app.workflow.actions.transfer_file -> app.workflow.actions"
],
"module_count": 798,
"module_count": 799,
"modules": [
"app",
"app.adapters",
@@ -7153,6 +7173,7 @@
"app.runtime.scheduling",
"app.runtime.settings",
"app.runtime.state",
"app.runtime.tasks",
"app.runtime.thread",
"app.runtime.topology",
"app.scheduler",
+1 -1
View File
@@ -4947,7 +4947,7 @@
}
},
"provenance": {
"head": "fd0363711dbba96e31a27ad56b2e87c3524096a4",
"head": "7d2d676d6f5139227050e9e71bd579943f45c0e8",
"python_file_count": 238,
"source_sha256": "f07ff2e8c95080cbbf6d229376464e61dc74061c1a8d371b5ea2cf00148413de"
},
File diff suppressed because it is too large Load Diff
+3 -158
View File
@@ -1,12 +1,12 @@
{
"model_decorators": {
"by_kind": {
"async_db_query": 44,
"async_db_query": 27,
"async_db_update": 0,
"db_query": 62,
"db_query": 48,
"db_update": 0
},
"count": 106,
"count": 75,
"methods": [
{
"decorator": "async_db_query",
@@ -153,26 +153,6 @@
"file": "app/db/models/mediaserver.py",
"method": "MediaServerItem.get_by_server_itemid"
},
{
"decorator": "async_db_query",
"file": "app/db/models/message.py",
"method": "Message.async_list_by_page"
},
{
"decorator": "async_db_query",
"file": "app/db/models/message.py",
"method": "Message.async_list_sent_by_page"
},
{
"decorator": "db_query",
"file": "app/db/models/message.py",
"method": "Message.exists_by_source"
},
{
"decorator": "db_query",
"file": "app/db/models/message.py",
"method": "Message.list_by_page"
},
{
"decorator": "async_db_query",
"file": "app/db/models/passkey.py",
@@ -193,46 +173,6 @@
"file": "app/db/models/passkey.py",
"method": "PassKey.get_by_id"
},
{
"decorator": "async_db_query",
"file": "app/db/models/site.py",
"method": "Site.async_get_actives"
},
{
"decorator": "async_db_query",
"file": "app/db/models/site.py",
"method": "Site.async_get_by_domain"
},
{
"decorator": "async_db_query",
"file": "app/db/models/site.py",
"method": "Site.async_get_by_name"
},
{
"decorator": "async_db_query",
"file": "app/db/models/site.py",
"method": "Site.async_list_order_by_pri"
},
{
"decorator": "db_query",
"file": "app/db/models/site.py",
"method": "Site.get_actives"
},
{
"decorator": "db_query",
"file": "app/db/models/site.py",
"method": "Site.get_by_domain"
},
{
"decorator": "db_query",
"file": "app/db/models/site.py",
"method": "Site.get_domains_by_ids"
},
{
"decorator": "db_query",
"file": "app/db/models/site.py",
"method": "Site.list_order_by_pri"
},
{
"decorator": "async_db_query",
"file": "app/db/models/siteuserdata.py",
@@ -258,91 +198,6 @@
"file": "app/db/models/siteuserdata.py",
"method": "SiteUserData.get_latest"
},
{
"decorator": "async_db_query",
"file": "app/db/models/subscribe.py",
"method": "Subscribe.async_exists"
},
{
"decorator": "async_db_query",
"file": "app/db/models/subscribe.py",
"method": "Subscribe.async_exists_by_username"
},
{
"decorator": "async_db_query",
"file": "app/db/models/subscribe.py",
"method": "Subscribe.async_get_by"
},
{
"decorator": "async_db_query",
"file": "app/db/models/subscribe.py",
"method": "Subscribe.async_get_by_state"
},
{
"decorator": "async_db_query",
"file": "app/db/models/subscribe.py",
"method": "Subscribe.async_get_by_title"
},
{
"decorator": "async_db_query",
"file": "app/db/models/subscribe.py",
"method": "Subscribe.async_list_by_media_identity"
},
{
"decorator": "async_db_query",
"file": "app/db/models/subscribe.py",
"method": "Subscribe.async_list_by_title"
},
{
"decorator": "async_db_query",
"file": "app/db/models/subscribe.py",
"method": "Subscribe.async_list_by_type"
},
{
"decorator": "async_db_query",
"file": "app/db/models/subscribe.py",
"method": "Subscribe.async_list_by_username"
},
{
"decorator": "db_query",
"file": "app/db/models/subscribe.py",
"method": "Subscribe.exists"
},
{
"decorator": "db_query",
"file": "app/db/models/subscribe.py",
"method": "Subscribe.exists_by_username"
},
{
"decorator": "db_query",
"file": "app/db/models/subscribe.py",
"method": "Subscribe.get_by"
},
{
"decorator": "db_query",
"file": "app/db/models/subscribe.py",
"method": "Subscribe.get_by_state"
},
{
"decorator": "db_query",
"file": "app/db/models/subscribe.py",
"method": "Subscribe.get_by_title"
},
{
"decorator": "db_query",
"file": "app/db/models/subscribe.py",
"method": "Subscribe.list_by_media_identity"
},
{
"decorator": "db_query",
"file": "app/db/models/subscribe.py",
"method": "Subscribe.list_by_type"
},
{
"decorator": "db_query",
"file": "app/db/models/subscribe.py",
"method": "Subscribe.list_by_username"
},
{
"decorator": "async_db_query",
"file": "app/db/models/subscribehistory.py",
@@ -488,16 +343,6 @@
"file": "app/db/models/transferpending.py",
"method": "TransferPending.list_all"
},
{
"decorator": "async_db_query",
"file": "app/db/models/user.py",
"method": "User.async_get_by_id"
},
{
"decorator": "async_db_query",
"file": "app/db/models/user.py",
"method": "User.async_get_by_name"
},
{
"decorator": "async_db_query",
"file": "app/db/models/workflow.py",
+141
View File
@@ -0,0 +1,141 @@
"""API 后台任务必须进入宿主 TaskRegistry 的回归测试。"""
import asyncio
from types import SimpleNamespace
from app.api.endpoints import message, site, subscribe, webhook
from app.runtime.tasks import TaskRegistry
class _TaskRegistry(TaskRegistry):
"""记录同步任务提交参数,不在端点测试中执行真实业务。"""
def __init__(self) -> None:
"""初始化调用记录。"""
super().__init__()
self.calls: list[tuple] = []
def create_sync(self, function, *args, owner: str, **kwargs) -> None:
"""保存函数、参数和 owner。"""
self.calls.append((function, args, kwargs, owner))
class _WebhookRequest:
"""提供 webhook 端点读取的最小请求接口。"""
query_params = {"source": "jellyfin"}
async def body(self) -> bytes:
"""返回最小请求体。"""
return b"{}"
async def form(self) -> dict:
"""返回空表单。"""
return {}
class _MessageRequest(_WebhookRequest):
"""复用 webhook 请求替身,覆盖用户消息入口所需字段。"""
headers = {"content-type": "application/json"}
class _SeerrRequest:
"""提供 Seerr 电影订阅 webhook 所需的最小 JSON 请求。"""
async def json(self) -> dict:
"""返回一个已批准的电影订阅通知。"""
return {
"notification_type": "MEDIA_APPROVED",
"subject": "测试电影",
"media": {"media_type": "movie", "tmdbId": 123},
"request": {"requestedBy_username": "tester"},
}
def test_webhook_post_uses_task_registry() -> None:
"""POST webhook 应登记解析任务,响应仍只表示宿主已接受。"""
registry = _TaskRegistry()
response = asyncio.run(
webhook.webhook_message(registry, _WebhookRequest(), "token")
)
function, args, kwargs, owner = registry.calls[0]
assert response.success is True
assert function is webhook.start_webhook_chain
assert args == (b"{}", {}, {"source": "jellyfin"})
assert kwargs == {}
assert owner == "api.webhook.message"
def test_webhook_get_uses_task_registry() -> None:
"""GET webhook 应保留旧参数形状并进入相同 owner。"""
registry = _TaskRegistry()
response = asyncio.run(
webhook.webhook_message_get(registry, _WebhookRequest(), "token")
)
function, args, kwargs, owner = registry.calls[0]
assert response.success is True
assert function is webhook.start_webhook_chain
assert args == (None, None, {"source": "jellyfin"})
assert kwargs == {}
assert owner == "api.webhook.message"
def test_cookiecloud_sync_uses_task_registry(monkeypatch) -> None:
"""CookieCloud 手工同步应登记 Scheduler E1 任务而非 Starlette 后台回调。"""
registry = _TaskRegistry()
scheduler = SimpleNamespace(start=lambda **_kwargs: None)
monkeypatch.setattr(site, "Scheduler", lambda: scheduler)
response = asyncio.run(site.cookie_cloud_sync(registry, SimpleNamespace()))
function, args, kwargs, owner = registry.calls[0]
assert response.success is True
assert function is scheduler.start
assert args == ()
assert kwargs == {"job_id": "cookiecloud"}
assert owner == "api.site.cookiecloud_sync"
def test_user_message_uses_task_registry() -> None:
"""消息入口应登记 E0 链任务并保持原始载荷。"""
registry = _TaskRegistry()
response = asyncio.run(message.user_message(registry, _MessageRequest(), None))
function, args, kwargs, owner = registry.calls[0]
assert response.success is True
assert function is message.start_message_chain
assert args == (b"{}", {}, {"source": "jellyfin"})
assert kwargs == {}
assert owner == "api.message.user"
def test_seerr_subscribe_uses_task_registry(monkeypatch) -> None:
"""Seerr webhook 应登记订阅创建任务且保持旧参数投影。"""
registry = _TaskRegistry()
monkeypatch.setattr(
subscribe,
"get_api_runtime_config_snapshot",
lambda: SimpleNamespace(api_token="token"),
)
response = asyncio.run(
subscribe.seerr_subscribe(_SeerrRequest(), registry, "token")
)
function, args, kwargs, owner = registry.calls[0]
assert response.success is True
assert function is subscribe.start_subscribe_add
assert args == ()
assert kwargs == {
"mtype": subscribe.MediaType.MOVIE,
"media_source": subscribe.MediaSource.TMDB,
"media_id": "123",
"title": "测试电影",
"year": "",
"season": None,
"username": "tester",
}
assert owner == "api.subscribe.seerr"
+2 -2
View File
@@ -126,8 +126,8 @@ def test_transaction_debt_baseline_is_a_model_and_oper_ratchet() -> None:
baseline = json.loads(baseline_path.read_text(encoding="utf-8"))
assert baseline["schema_version"] == 1
assert baseline["model_decorators"]["count"] == 106
assert sum(baseline["model_decorators"]["by_kind"].values()) == 106
assert baseline["model_decorators"]["count"] == 75
assert sum(baseline["model_decorators"]["by_kind"].values()) == 75
assert baseline["model_decorators"]["by_kind"]["db_update"] == 0
assert baseline["model_decorators"]["by_kind"]["async_db_update"] == 0
assert baseline["model_transaction_calls"] == {"count": 0, "calls": []}
+3
View File
@@ -253,6 +253,7 @@ def test_lifecycle_manifest_declares_normal_and_safe_mode_order() -> None:
safe_names = {item["name"] for item in safe}
assert normal_start == [
"后台任务登记器",
"数据库准备",
"HTTP 基础能力",
"领域依赖装配",
@@ -269,6 +270,7 @@ def test_lifecycle_manifest_declares_normal_and_safe_mode_order() -> None:
"工作流",
]
assert normal_stop == [
"后台任务登记器",
"插件备份",
"工作流",
"命令服务",
@@ -279,6 +281,7 @@ def test_lifecycle_manifest_declares_normal_and_safe_mode_order() -> None:
"HTTP 基础能力",
]
assert safe_names == {
"后台任务登记器",
"数据库准备",
"HTTP 基础能力",
"领域依赖装配",
+21
View File
@@ -7,7 +7,9 @@ from app.runtime.extensions.module.contracts import (
ModuleErrorPolicy,
ModuleExecutionMode,
ModuleResultAggregation,
ModuleResultShape,
diagnose_module_callable,
diagnose_module_result,
get_module_method_contract,
is_explicit_module_method,
list_explicit_module_contracts,
@@ -116,3 +118,22 @@ def test_signature_diagnostics_accept_keyword_compatibility_provider() -> None:
return kwargs
assert diagnose_module_callable("snapshot_storage", compatible_provider) == ()
def test_result_diagnostics_check_only_enabled_basic_shapes() -> None:
"""高频方法检查基础结果形状,业务对象合同仍留给逐族适配器。"""
assert get_module_method_contract("list_files").result_shape is ModuleResultShape.LIST
assert diagnose_module_result("list_files", [object()]) == ()
assert diagnose_module_result("list_files", None) == ()
assert diagnose_module_result("list_files", "legacy-value") == (
"unexpected-result:list:str",
)
assert diagnose_module_result("mediaserver_play_url", "https://example.test") == ()
assert diagnose_module_result("mediaserver_play_url", 7) == (
"unexpected-result:string:int",
)
def test_unknown_plugin_result_keeps_unchecked_legacy_compatibility() -> None:
"""未知第三方方法的任意返回值继续不做结果形状诊断。"""
assert diagnose_module_result("third_party_custom_method", object()) == ()
+9 -3
View File
@@ -14,6 +14,7 @@ from app.api.endpoints.system import sync_plugin_market_from_wiki
from app.application.plugin.config import PluginConfigCommand
from app.runtime.config import settings
from app.runtime.extensions.plugin_manager import PluginManager
from app.runtime.tasks import TaskRegistry
from app.schemas.event import PluginDataResetEventData
from app.schemas.plugin import PluginInstance, PluginRuntimeStatus
from app.schemas.types import ChainEventType, SystemConfigKey
@@ -312,8 +313,8 @@ def test_plugin_releases_force_uses_cached_release_response_and_schedules_refres
plugin_helper.async_get_plugin_release_versions = fake_releases
scheduled = []
def fake_schedule(plugin_id, repo_url):
scheduled.append((plugin_id, repo_url))
def fake_schedule(plugin_id, repo_url, task_registry):
scheduled.append((plugin_id, repo_url, task_registry))
with (
patch("app.api.endpoints.plugin.PluginManager", return_value=plugin_manager),
@@ -324,7 +325,12 @@ def test_plugin_releases_force_uses_cached_release_response_and_schedules_refres
assert result["release_supported"] is True
assert fresh_states == [False]
assert scheduled == [("DemoPlugin", "https://github.com/demo/plugins")]
assert len(scheduled) == 1
assert scheduled[0][:2] == (
"DemoPlugin",
"https://github.com/demo/plugins",
)
assert isinstance(scheduled[0][2], TaskRegistry)
plugin_helper.async_has_plugin_release_cache.assert_awaited_once_with(
"https://github.com/demo/plugins"
)
+86
View File
@@ -0,0 +1,86 @@
"""进程内后台任务登记与关停语义测试。"""
import asyncio
import pytest
from app.runtime.tasks import TaskRegistry
def test_task_registry_removes_completed_task() -> None:
"""正常完成的任务应自动退出登记表,避免长期持有请求对象。"""
async def scenario() -> None:
registry = TaskRegistry()
release = asyncio.Event()
async def worker() -> None:
"""等待测试释放信号。"""
await release.wait()
task = registry.create(worker(), owner="test.completed")
assert [record.owner for record in registry.records] == ["test.completed"]
release.set()
await task
await asyncio.sleep(0)
assert registry.records == ()
asyncio.run(scenario())
def test_task_registry_cancels_tasks_and_rejects_late_registration() -> None:
"""关停应取消存量任务,并拒绝在资源释放阶段继续产生新任务。"""
async def scenario() -> None:
registry = TaskRegistry()
started = asyncio.Event()
cancelled = asyncio.Event()
async def worker() -> None:
"""记录任务收到取消信号。"""
started.set()
try:
await asyncio.Event().wait()
except asyncio.CancelledError:
cancelled.set()
raise
task = registry.create(worker(), owner="test.shutdown")
await started.wait()
await registry.shutdown(timeout_seconds=1.0)
assert task.cancelled()
assert cancelled.is_set()
assert registry.records == ()
async def late_worker() -> None:
"""模拟关停开始后到达的晚任务。"""
with pytest.raises(RuntimeError, match="正在关闭"):
registry.create(late_worker(), owner="test.late")
asyncio.run(scenario())
def test_task_registry_runs_sync_function_and_tracks_until_completion() -> None:
"""同步任务应在线程池执行,并在真实完成前保留 owner 记录。"""
async def scenario() -> None:
registry = TaskRegistry()
release = asyncio.Event()
def worker(value: int) -> int:
"""返回传入值,验证参数和结果没有被登记器改写。"""
return value
task = registry.create_sync(worker, 7, owner="test.sync")
assert [record.owner for record in registry.records] == ["test.sync"]
assert await task == 7
await asyncio.sleep(0)
assert registry.records == ()
release.set()
asyncio.run(scenario())