refactor(startup): add symmetric composition resets

This commit is contained in:
jxxghp
2026-08-29 18:17:55 +08:00
parent 8bc7cf93bc
commit 5a4a4e4cca
7 changed files with 329 additions and 3 deletions

View File

@@ -11,6 +11,7 @@ from app.application.agent import AgentDataContext
from app.application.agenttask import (
AgentTaskExecutionService,
configure_agent_task_execution,
reset_agent_task_execution,
)
from app.application.history import DownloadHistoryRepository, TransferHistoryRepository
from app.application.messaging.chat import (
@@ -18,6 +19,8 @@ from app.application.messaging.chat import (
AgentChatService,
configure_agent_chat_persistence,
configure_agent_chat_service,
reset_agent_chat_persistence,
reset_agent_chat_service,
)
from app.application.site.contract import SiteRepository
from app.application.subscription.contract import (
@@ -49,6 +52,7 @@ from app.startup.composition.subscription import (
)
AgentDataContextRegistrar = Callable[[AgentDataContext], None]
AgentDataContextResetter = Callable[[], None]
@dataclass(frozen=True, slots=True)
@@ -128,3 +132,14 @@ def publish_agent_services(
configure_agent_chat_persistence(composition.data.chat_persistence)
configure_agent_task_execution(composition.execution)
data_context_registrar(composition.data)
def reset_agent_services(
*,
data_context_resetter: AgentDataContextResetter,
) -> None:
"""按发布逆序撤销当前 lifespan 的 Agent 数据、任务与会话服务。"""
data_context_resetter()
reset_agent_task_execution()
reset_agent_chat_persistence()
reset_agent_chat_service()

View File

@@ -8,7 +8,10 @@ from app.application.chain.context import (
configure_chain_runtime_context_provider,
)
from app.application.configuration import ChainRuntimeConfig
from app.application.image import configure_wallpaper_providers
from app.application.image import (
configure_wallpaper_providers,
reset_wallpaper_providers,
)
from app.application.messaging.message import MessageHelper, MessageQueueManager
from app.application.transfer.execution import TransferExecutionRepository
from app.chain.mediaserver import MediaServerChain
@@ -139,3 +142,9 @@ def configure_chain_runtime_context(
configuration=configuration,
)
)
def reset_chain_services() -> None:
"""按发布逆序撤销 Chain 无参上下文和壁纸来源。"""
configure_chain_runtime_context_provider(None)
reset_wallpaper_providers()

View File

@@ -11,16 +11,19 @@ from app.application.image import (
ImageTransport,
InternalAddressPort,
configure_image_ports,
reset_image_ports,
)
from app.application.messaging.ingress import (
MessageIngressPort,
configure_message_ingress_port,
reset_message_ingress_port,
)
from app.application.network import (
NetworkTestResponse,
NetworkTestService,
NetworkTestTransport,
configure_network_test_service,
reset_network_test_service,
)
from app.runtime.log import logger
@@ -155,3 +158,10 @@ def configure_application_network_ports() -> None:
internal_address=internal_address,
)
configure_message_ingress_port(message_ingress)
def reset_application_network_ports() -> None:
"""按发布逆序撤销消息回环、图片与网络探测应用端口。"""
reset_message_ingress_port()
reset_image_ports()
reset_network_test_service()

View File

@@ -11,6 +11,7 @@ from app.application.outbox import (
ClaimedOutboxMessage,
OutboxDispatcher,
durable_event_topic,
reset_outbox_dispatcher,
validate_durable_event_handlers,
)
from app.command import CommandChain
@@ -154,3 +155,8 @@ def build_outbox_dispatcher() -> OutboxDispatcher:
owner="outbox",
),
)
def reset_outbox_services() -> None:
"""撤销当前 lifespan 发布的 Outbox dispatcher 工厂。"""
reset_outbox_dispatcher()

View File

@@ -1,19 +1,25 @@
"""认证、用户与 PassKey 服务的启动组合。"""
from app.adapters.web.security.access import set_superuser_token_payload_provider
from app.adapters.web.security.access import (
reset_superuser_token_payload_provider,
set_superuser_token_payload_provider,
)
from app.application.configuration import get_configured_system_config
from app.application.security.auth import (
AuthService,
build_superuser_token_payload,
configure_auth_service,
reset_auth_service,
)
from app.application.security.passkey import (
PASSKEY_CHALLENGE_TTL_SECONDS,
PasskeyService,
configure_passkey_challenge_cache,
configure_passkey_service,
reset_passkey_challenge_cache,
reset_passkey_service,
)
from app.application.security.user import configure_user_lookups
from app.application.security.user import configure_user_lookups, reset_user_lookups
from app.db.adapters.user import SqlAlchemyUserRepository
from app.db.oper.passkey import PassKeyOper
from app.db.oper.systemconfig import SystemConfigOper
@@ -57,3 +63,16 @@ def configure_security_services() -> AuthenticationRuntime:
def configure_security_access() -> None:
"""登记 Web 认证边界所需的超级用户令牌载荷提供器。"""
set_superuser_token_payload_provider(build_superuser_token_payload)
def reset_security_services() -> None:
"""按发布逆序撤销认证、用户查询和 PassKey 服务。"""
reset_passkey_service()
reset_passkey_challenge_cache()
reset_auth_service()
reset_user_lookups()
def reset_security_access() -> None:
"""撤销 Web 认证边界的超级用户令牌载荷提供器及其缓存。"""
reset_superuser_token_payload_provider()

View File

@@ -3,6 +3,7 @@
from app.adapters.external.server import (
MoviePilotServerHelper,
configure_server_application_services,
reset_server_application_services,
)
from app.application.configuration import get_configured_system_config
from app.application.server.report import ServerReportService
@@ -48,3 +49,8 @@ def configure_server_services(
workflow_cache_clearer=MoviePilotServerHelper._clear_workflow_share_cache,
),
)
def reset_server_services() -> None:
"""撤销当前 lifespan 发布的中心服务上报与分享用例。"""
reset_server_application_services()

View File

@@ -0,0 +1,261 @@
"""验证 composition owner 对称撤销其发布的 lifespan provider。"""
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import Mock
import pytest
from fastapi import HTTPException
from app.adapters.external import server as server_provider
from app.adapters.web.security import access as access_provider
from app.application import agenttask as task_provider
from app.application import image as image_provider
from app.application import network as network_provider
from app.application import outbox as outbox_provider
from app.application.chain import context as chain_provider
from app.application.messaging import chat as chat_provider
from app.application.messaging import ingress as ingress_provider
from app.application.security import auth as auth_provider
from app.application.security import passkey as passkey_provider
from app.application.security import user as user_provider
from app.startup.composition import agent as agent_composition
from app.startup.composition import chain as chain_composition
from app.startup.composition import network as network_composition
from app.startup.composition import outbox as outbox_composition
from app.startup.composition import security as security_composition
from app.startup.composition import server as server_composition
def test_agent_owner_reset_is_symmetric_and_idempotent(monkeypatch) -> None:
"""Agent owner 撤销数据上下文、任务执行与两类会话服务。"""
monkeypatch.setattr(chat_provider, "_configured_agent_chat_service", None)
monkeypatch.setattr(chat_provider, "_configured_agent_chat_persistence", None)
monkeypatch.setattr(task_provider, "_service", None)
data_state: dict[str, object] = {}
composition = SimpleNamespace(
data=SimpleNamespace(chat=object(), chat_persistence=object()),
execution=object(),
)
agent_composition.publish_agent_services(
composition,
data_context_registrar=lambda data: data_state.update(current=data),
)
assert chat_provider.get_configured_agent_chat_service() is composition.data.chat
assert (
chat_provider.get_configured_agent_chat_persistence()
is composition.data.chat_persistence
)
assert task_provider.get_agent_task_execution_service() is composition.execution
assert data_state["current"] is composition.data
def reset_data() -> None:
"""撤销测试中的 Agent 数据上下文。"""
data_state.pop("current", None)
agent_composition.reset_agent_services(data_context_resetter=reset_data)
agent_composition.reset_agent_services(data_context_resetter=reset_data)
assert data_state == {}
for getter in (
chat_provider.get_configured_agent_chat_service,
chat_provider.get_configured_agent_chat_persistence,
task_provider.get_agent_task_execution_service,
):
with pytest.raises(RuntimeError):
getter()
def test_security_owner_reset_is_symmetric_and_idempotent(monkeypatch) -> None:
"""Security owner 撤销身份查询、认证、PassKey 与 Web 访问 provider。"""
monkeypatch.setattr(user_provider, "_configured_user_id_lookup", None)
monkeypatch.setattr(user_provider, "_configured_user_name_lookup", None)
monkeypatch.setattr(user_provider, "_configured_user_channel_lookup", None)
monkeypatch.setattr(auth_provider, "_configured_auth_service", None)
monkeypatch.setattr(passkey_provider.PasskeyChallengeStore, "_cache", None)
monkeypatch.setattr(passkey_provider, "_configured_passkey_service", None)
monkeypatch.setattr(access_provider, "_superuser_token_payload_provider", None)
access_provider._create_superuser_token_payload.cache_clear()
user_provider.configure_user_lookups(
by_id=lambda _user_id: object(),
by_name=lambda _username: object(),
by_channel=lambda **_bindings: "user",
)
auth_provider.configure_auth_service(object()) # type: ignore[arg-type]
passkey_provider.configure_passkey_challenge_cache(object()) # type: ignore[arg-type]
passkey_provider.configure_passkey_service(object()) # type: ignore[arg-type]
access_provider.set_superuser_token_payload_provider(lambda: object())
security_composition.reset_security_access()
security_composition.reset_security_access()
security_composition.reset_security_services()
security_composition.reset_security_services()
for getter in (
user_provider.get_configured_user_id_lookup,
user_provider.get_configured_user_name_lookup,
user_provider.get_configured_user_channel_lookup,
auth_provider.get_configured_auth_service,
passkey_provider.get_configured_passkey_service,
passkey_provider.PasskeyChallengeStore._get_cache,
):
with pytest.raises(RuntimeError):
getter()
with pytest.raises(HTTPException) as error:
access_provider._create_superuser_token_payload()
assert error.value.status_code == 503
def test_network_owner_reset_is_symmetric_and_idempotent(monkeypatch) -> None:
"""Network owner 撤销探测、图片和消息回环端口。"""
monkeypatch.setattr(network_provider, "_configured_network_test_service", None)
monkeypatch.setattr(image_provider, "_image_transport", None)
monkeypatch.setattr(image_provider, "_internal_address", None)
monkeypatch.setattr(ingress_provider, "_message_ingress_port", None)
network_provider.configure_network_test_service(object()) # type: ignore[arg-type]
image_provider.configure_image_ports(
transport=object(), # type: ignore[arg-type]
internal_address=object(), # type: ignore[arg-type]
)
ingress_provider.configure_message_ingress_port(object()) # type: ignore[arg-type]
network_composition.reset_application_network_ports()
network_composition.reset_application_network_ports()
for getter in (
network_provider.get_configured_network_test_service,
image_provider._image_ports_snapshot,
ingress_provider._message_ingress_snapshot,
):
with pytest.raises(RuntimeError):
getter()
def test_outbox_owner_reset_is_symmetric_and_idempotent(monkeypatch) -> None:
"""Outbox owner 撤销 dispatcher 工厂并恢复显式未配置错误。"""
monkeypatch.setattr(outbox_provider, "_configured_dispatcher", None)
outbox_provider.configure_outbox_dispatcher(Mock())
outbox_composition.reset_outbox_services()
outbox_composition.reset_outbox_services()
with pytest.raises(RuntimeError, match="Outbox dispatcher 尚未配置"):
outbox_provider.dispatch_pending_outbox()
def test_server_owner_reset_is_symmetric_and_idempotent(monkeypatch) -> None:
"""Server owner 同时撤销上报与分享服务。"""
monkeypatch.setattr(server_provider, "_server_report_service", None)
monkeypatch.setattr(server_provider, "_server_sharing_service", None)
server_provider.configure_server_application_services(
report_service=object(),
sharing_service=object(),
)
server_composition.reset_server_services()
server_composition.reset_server_services()
with pytest.raises(RuntimeError):
server_provider.MoviePilotServerHelper._report_service()
with pytest.raises(RuntimeError):
server_provider.MoviePilotServerHelper._sharing_service()
def test_chain_owner_reset_is_symmetric_and_idempotent(monkeypatch) -> None:
"""Chain owner 撤销无参上下文,并把壁纸来源恢复为空结果。"""
monkeypatch.setattr(
chain_provider,
"_context_provider",
chain_provider._unconfigured_chain_runtime_context,
)
chain_provider.configure_chain_runtime_context_provider(lambda: object())
image_provider.configure_wallpaper_providers(
tmdb_wallpaper=lambda: "tmdb",
tmdb_wallpapers=lambda _count: ["tmdb"],
mediaserver_wallpaper=lambda: "media",
mediaserver_wallpapers=lambda _count: ["media"],
)
chain_composition.reset_chain_services()
chain_composition.reset_chain_services()
with pytest.raises(RuntimeError, match="Chain 运行上下文尚未由启动组合根配置"):
chain_provider.get_chain_runtime_context()
wallpaper = image_provider.WallpaperHelper()
assert wallpaper.get_tmdb_wallpaper() is None
assert wallpaper.get_tmdb_wallpapers() == []
assert wallpaper.get_mediaserver_wallpaper() is None
assert wallpaper.get_mediaserver_wallpapers() == []
def test_composition_owner_resets_run_in_reverse_publication_order(monkeypatch) -> None:
"""多 provider owner 必须按发布逆序撤销,避免依赖先于消费者消失。"""
calls: list[str] = []
for module, names in (
(
agent_composition,
(
"reset_agent_task_execution",
"reset_agent_chat_persistence",
"reset_agent_chat_service",
),
),
(
security_composition,
(
"reset_passkey_service",
"reset_passkey_challenge_cache",
"reset_auth_service",
"reset_user_lookups",
),
),
(
network_composition,
(
"reset_message_ingress_port",
"reset_image_ports",
"reset_network_test_service",
),
),
(
chain_composition,
(
"configure_chain_runtime_context_provider",
"reset_wallpaper_providers",
),
),
):
for name in names:
monkeypatch.setattr(
module,
name,
lambda *args, _name=name, **kwargs: calls.append(_name),
)
agent_composition.reset_agent_services(
data_context_resetter=lambda: calls.append("reset_agent_data_context"),
)
security_composition.reset_security_services()
network_composition.reset_application_network_ports()
chain_composition.reset_chain_services()
assert calls == [
"reset_agent_data_context",
"reset_agent_task_execution",
"reset_agent_chat_persistence",
"reset_agent_chat_service",
"reset_passkey_service",
"reset_passkey_challenge_cache",
"reset_auth_service",
"reset_user_lookups",
"reset_message_ingress_port",
"reset_image_ports",
"reset_network_test_service",
"configure_chain_runtime_context_provider",
"reset_wallpaper_providers",
]