mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-06 16:07:01 +08:00
refactor: govern background tasks and query ownership
This commit is contained in:
+24
-3
@@ -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",
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
@@ -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": []}
|
||||
|
||||
@@ -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 基础能力",
|
||||
"领域依赖装配",
|
||||
|
||||
@@ -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()) == ()
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user