From 305f8e007cb9220abe5263cfe25e41b0b93650dc Mon Sep 17 00:00:00 2001 From: jxxghp Date: Sat, 29 Aug 2026 17:19:28 +0800 Subject: [PATCH] refactor(startup): isolate outbox composition --- app/startup/composition/outbox.py | 155 +++++++++ app/startup/initializers/modules.py | 150 +-------- docs/architecture-optimization-checklist.md | 2 +- docs/architecture-overview.md | 4 +- docs/rules/05-architecture.md | 13 +- scripts/architecture/service_locator.py | 1 + .../architecture/dependency-baseline.json | 60 +++- .../runtime-contract-baseline.json | 296 +++++++++--------- tests/test_architecture_dependencies.py | 26 ++ tests/test_outbox.py | 27 +- tests/test_service_locator_gate.py | 1 + 11 files changed, 418 insertions(+), 317 deletions(-) create mode 100644 app/startup/composition/outbox.py diff --git a/app/startup/composition/outbox.py b/app/startup/composition/outbox.py new file mode 100644 index 000000000..527c09216 --- /dev/null +++ b/app/startup/composition/outbox.py @@ -0,0 +1,155 @@ +"""持久化 Outbox handler 与 dispatcher 的宿主组合装配。""" + +from collections.abc import Callable + +from app.adapters.external.server import MoviePilotServerHelper +from app.application.chain.events import ( + restore_download_added, + restore_transfer_result, +) +from app.application.outbox import ( + ClaimedOutboxMessage, + OutboxDispatcher, + durable_event_topic, + validate_durable_event_handlers, +) +from app.command import CommandChain +from app.db.adapters.outbox import SqlAlchemyOutboxDispatchStore +from app.db.session import SessionFactory +from app.runtime.events import EventManager +from app.runtime.observability import record_metric +from app.schemas.message import Message +from app.schemas.types import EventType + + +def build_outbox_handlers() -> dict[ + str, + Callable[[ClaimedOutboxMessage], None], +]: + """构造等待真实执行边界的 at-least-once 通知、事件和统计 handler。""" + + def discard_event_receipt(_event: object) -> None: + """丢弃普通事件 API 的回执,使 outbox handler 仅表达结算成功。""" + + def dispatch_subscribe_deleted_report(message: ClaimedOutboxMessage) -> None: + """重放订阅删除统计;未确认时抛错以进入有限重试。""" + if not MoviePilotServerHelper.sub_done_durable(message.payload.get("subscribe_info") or {}): + raise RuntimeError("订阅删除统计上报未确认") + + def dispatch_subscribe_added_report(message: ClaimedOutboxMessage) -> None: + """重放订阅新增统计;未确认时抛错以进入有限重试。""" + if not MoviePilotServerHelper.sub_reg_durable(message.payload.get("subscribe_info") or {}): + raise RuntimeError("订阅新增统计上报未确认") + + def dispatch_subscribe_complete_report(message: ClaimedOutboxMessage) -> None: + """重放订阅完成统计;未确认时抛错以进入有限重试。""" + if not MoviePilotServerHelper.sub_done_durable(message.payload.get("subscribe_info") or {}): + raise RuntimeError("订阅完成统计上报未确认") + + def dispatch_subscribe_notification(message: ClaimedOutboxMessage) -> None: + """恢复订阅完成通知;消息快照无需重建领域对象。""" + snapshot = message.payload.get("message") or {} + if not isinstance(snapshot, dict): + raise RuntimeError("订阅完成通知快照格式无效") + CommandChain().post_message_strict( + Message.model_validate(snapshot), + event_key=message.event_key, + ) + + def dispatch_subscribe_added_notification(message: ClaimedOutboxMessage) -> None: + """恢复订阅新增通知;恢复使用提交前冻结的渲染消息快照。""" + snapshot = message.payload.get("message") or {} + if not isinstance(snapshot, dict): + raise RuntimeError("订阅新增通知快照格式无效") + CommandChain().post_message_strict( + Message.model_validate(snapshot), + event_key=message.event_key, + ) + + handlers: dict[str, Callable[[ClaimedOutboxMessage], None]] = { + durable_event_topic(EventType.SubscribeAdded): lambda message: discard_event_receipt( + EventManager().send_event_strict( + EventType.SubscribeAdded, + message.payload, + ) + ), + "subscribe.added.report": dispatch_subscribe_added_report, + "subscribe.added.notification": dispatch_subscribe_added_notification, + durable_event_topic(EventType.SubscribeModified): lambda message: discard_event_receipt( + EventManager().send_event_strict( + EventType.SubscribeModified, + message.payload, + ) + ), + durable_event_topic(EventType.SubscribeDeleted): lambda message: discard_event_receipt( + EventManager().send_event_strict( + EventType.SubscribeDeleted, + message.payload, + ) + ), + "subscribe.deleted.report": dispatch_subscribe_deleted_report, + durable_event_topic(EventType.SubscribeComplete): lambda message: discard_event_receipt( + EventManager().send_event_strict( + EventType.SubscribeComplete, + message.payload, + ) + ), + "subscribe.complete.report": dispatch_subscribe_complete_report, + "subscribe.complete.notification": dispatch_subscribe_notification, + durable_event_topic(EventType.DownloadAdded): lambda message: discard_event_receipt( + EventManager().send_event_strict( + EventType.DownloadAdded, + restore_download_added(message.payload), + ) + ), + durable_event_topic(EventType.TransferComplete): lambda message: discard_event_receipt( + EventManager().send_event_strict( + EventType.TransferComplete, + restore_transfer_result(message.payload), + ) + ), + durable_event_topic(EventType.TransferFailed): lambda message: discard_event_receipt( + EventManager().send_event_strict( + EventType.TransferFailed, + restore_transfer_result(message.payload), + ) + ), + durable_event_topic(EventType.SubtitleTransferComplete): lambda message: discard_event_receipt( + EventManager().send_event_strict( + EventType.SubtitleTransferComplete, + restore_transfer_result(message.payload), + ) + ), + durable_event_topic(EventType.SubtitleTransferFailed): lambda message: discard_event_receipt( + EventManager().send_event_strict( + EventType.SubtitleTransferFailed, + restore_transfer_result(message.payload), + ) + ), + durable_event_topic(EventType.AudioTransferComplete): lambda message: discard_event_receipt( + EventManager().send_event_strict( + EventType.AudioTransferComplete, + restore_transfer_result(message.payload), + ) + ), + durable_event_topic(EventType.AudioTransferFailed): lambda message: discard_event_receipt( + EventManager().send_event_strict( + EventType.AudioTransferFailed, + restore_transfer_result(message.payload), + ) + ), + } + validate_durable_event_handlers(handlers) + return handlers + + +def build_outbox_dispatcher() -> OutboxDispatcher: + """创建使用独立短事务和 attempt fencing 的恢复 dispatcher。""" + return OutboxDispatcher( + repository=SqlAlchemyOutboxDispatchStore(SessionFactory), + handlers=build_outbox_handlers(), + failure_observer=lambda dead: record_metric( + "scheduler.job.dead_letter" if dead else "scheduler.job.retry", + owner="outbox", + ), + ) diff --git a/app/startup/initializers/modules.py b/app/startup/initializers/modules.py index d3aadf06e..75f1af82d 100644 --- a/app/startup/initializers/modules.py +++ b/app/startup/initializers/modules.py @@ -37,10 +37,6 @@ from app.application.chain.context import ( ChainRuntimeContext, configure_chain_runtime_context_provider, ) -from app.application.chain.events import ( - restore_download_added, - restore_transfer_result, -) from app.application.configuration import ( TransferRetryConfig, configure_transfer_retry_config, @@ -66,13 +62,7 @@ from app.application.messaging.message import ( stop_message, ) from app.application.module import configure_module_runtime -from app.application.outbox import ( - ClaimedOutboxMessage, - OutboxDispatcher, - configure_outbox_dispatcher, - durable_event_topic, - validate_durable_event_handlers, -) +from app.application.outbox import configure_outbox_dispatcher from app.application.security.url import close_image_proxy_block_log_coalescer from app.application.server.report import ServerReportService from app.application.server.share import ServerSharingService @@ -104,7 +94,6 @@ from app.db.adapters.mediaserver import TransactionalMediaServerRepository from app.db.adapters.outbox import ( SqlAlchemyAsyncOutboxDispatchStore, SqlAlchemyAsyncOutboxStager, - SqlAlchemyOutboxDispatchStore, ) from app.db.adapters.site import SessionSiteRepository, TransactionalSiteRepository from app.db.adapters.subscription import ( @@ -148,7 +137,6 @@ from app.runtime.extensions.service_config import ( configure_service_config_reader, ) from app.runtime.log import logger -from app.runtime.observability import record_metric from app.runtime.settings import ( get_runtime_setting, ) @@ -185,6 +173,7 @@ from app.startup.composition.database import ( stop_database_runtime, ) from app.startup.composition.network import configure_application_network_ports +from app.startup.composition.outbox import build_outbox_dispatcher from app.startup.composition.security import ( configure_security_access, configure_security_services, @@ -309,139 +298,6 @@ def configure_runtime_data_providers( ) -def _build_outbox_handlers() -> dict[ - str, - Callable[[ClaimedOutboxMessage], None], -]: - """构造等待真实执行边界的 at-least-once 通知、事件和统计 handler。""" - - def discard_event_receipt(_event: object) -> None: - """丢弃普通事件 API 的回执,使 outbox handler 仅表达结算成功。""" - - def dispatch_subscribe_deleted_report(message: ClaimedOutboxMessage) -> None: - """重放订阅删除统计;未确认时抛错以进入有限重试。""" - if not MoviePilotServerHelper.sub_done_durable(message.payload.get("subscribe_info") or {}): - raise RuntimeError("订阅删除统计上报未确认") - - def dispatch_subscribe_added_report(message: ClaimedOutboxMessage) -> None: - """重放订阅新增统计;未确认时抛错以进入有限重试。""" - if not MoviePilotServerHelper.sub_reg_durable(message.payload.get("subscribe_info") or {}): - raise RuntimeError("订阅新增统计上报未确认") - - def dispatch_subscribe_complete_report(message: ClaimedOutboxMessage) -> None: - """重放订阅完成统计;未确认时抛错以进入有限重试。""" - if not MoviePilotServerHelper.sub_done_durable(message.payload.get("subscribe_info") or {}): - raise RuntimeError("订阅完成统计上报未确认") - - def dispatch_subscribe_notification(message: ClaimedOutboxMessage) -> None: - """恢复订阅完成通知;消息快照无需重建领域对象。""" - snapshot = message.payload.get("message") or {} - if not isinstance(snapshot, dict): - raise RuntimeError("订阅完成通知快照格式无效") - CommandChain().post_message_strict( - Message.model_validate(snapshot), - event_key=message.event_key, - ) - - def dispatch_subscribe_added_notification(message: ClaimedOutboxMessage) -> None: - """恢复订阅新增通知;恢复使用提交前冻结的渲染消息快照。""" - snapshot = message.payload.get("message") or {} - if not isinstance(snapshot, dict): - raise RuntimeError("订阅新增通知快照格式无效") - CommandChain().post_message_strict( - Message.model_validate(snapshot), - event_key=message.event_key, - ) - - handlers: dict[str, Callable[[ClaimedOutboxMessage], None]] = { - durable_event_topic(EventType.SubscribeAdded): lambda message: discard_event_receipt( - EventManager().send_event_strict( - EventType.SubscribeAdded, - message.payload, - ) - ), - "subscribe.added.report": dispatch_subscribe_added_report, - "subscribe.added.notification": dispatch_subscribe_added_notification, - durable_event_topic(EventType.SubscribeModified): lambda message: discard_event_receipt( - EventManager().send_event_strict( - EventType.SubscribeModified, - message.payload, - ) - ), - durable_event_topic(EventType.SubscribeDeleted): lambda message: discard_event_receipt( - EventManager().send_event_strict( - EventType.SubscribeDeleted, - message.payload, - ) - ), - "subscribe.deleted.report": dispatch_subscribe_deleted_report, - durable_event_topic(EventType.SubscribeComplete): lambda message: discard_event_receipt( - EventManager().send_event_strict( - EventType.SubscribeComplete, - message.payload, - ) - ), - "subscribe.complete.report": dispatch_subscribe_complete_report, - "subscribe.complete.notification": dispatch_subscribe_notification, - durable_event_topic(EventType.DownloadAdded): lambda message: discard_event_receipt( - EventManager().send_event_strict( - EventType.DownloadAdded, - restore_download_added(message.payload), - ) - ), - durable_event_topic(EventType.TransferComplete): lambda message: discard_event_receipt( - EventManager().send_event_strict( - EventType.TransferComplete, - restore_transfer_result(message.payload), - ) - ), - durable_event_topic(EventType.TransferFailed): lambda message: discard_event_receipt( - EventManager().send_event_strict( - EventType.TransferFailed, - restore_transfer_result(message.payload), - ) - ), - durable_event_topic(EventType.SubtitleTransferComplete): lambda message: discard_event_receipt( - EventManager().send_event_strict( - EventType.SubtitleTransferComplete, - restore_transfer_result(message.payload), - ) - ), - durable_event_topic(EventType.SubtitleTransferFailed): lambda message: discard_event_receipt( - EventManager().send_event_strict( - EventType.SubtitleTransferFailed, - restore_transfer_result(message.payload), - ) - ), - durable_event_topic(EventType.AudioTransferComplete): lambda message: discard_event_receipt( - EventManager().send_event_strict( - EventType.AudioTransferComplete, - restore_transfer_result(message.payload), - ) - ), - durable_event_topic(EventType.AudioTransferFailed): lambda message: discard_event_receipt( - EventManager().send_event_strict( - EventType.AudioTransferFailed, - restore_transfer_result(message.payload), - ) - ), - } - validate_durable_event_handlers(handlers) - return handlers - - -def _build_outbox_dispatcher() -> OutboxDispatcher: - """创建使用独立短事务和 attempt fencing 的恢复 dispatcher。""" - return OutboxDispatcher( - repository=SqlAlchemyOutboxDispatchStore(SessionFactory), - handlers=_build_outbox_handlers(), - failure_observer=lambda dead: record_metric( - "scheduler.job.dead_letter" if dead else "scheduler.job.retry", - owner="outbox", - ), - ) - - def configure_wallpaper_services() -> None: """把需要 Chain 编排的壁纸来源注入图片服务。""" configure_wallpaper_providers( @@ -861,7 +717,7 @@ async def _initialize_modules() -> HostRuntime: configure_runtime_data_providers(workflow_query, subscription_repository) workflow_execution = TransactionalWorkflowExecutionService(SessionFactory) configure_workflow_execution(workflow_execution) - configure_outbox_dispatcher(_build_outbox_dispatcher) + configure_outbox_dispatcher(build_outbox_dispatcher) configure_transfer_retry_config( lambda: TransferRetryConfig( max_failed_retries=get_runtime_setting("TRANSFER_MAX_FAILED_RETRIES"), diff --git a/docs/architecture-optimization-checklist.md b/docs/architecture-optimization-checklist.md index 187ef9b1f..18928ec1d 100644 --- a/docs/architecture-optimization-checklist.md +++ b/docs/architecture-optimization-checklist.md @@ -70,7 +70,7 @@ MoviePilot V3 已经形成较清晰的模块化单体:`foundation`、`domain` | 指标 | 当前值 | 解释 | |---|---:|---| -| 宿主 Python 模块 / 内部依赖边 | 966 / 8,216 | `dependency-baseline.json` 当前快照 | +| 宿主 Python 模块 / 内部依赖边 | 968 / 8,248 | `dependency-baseline.json` 当前快照 | | 非平凡 SCC | 1 | 仅保留精确 containment 的 29 模块 TMDB 移植包环 | | 跨层 DB 边界债务 | 0 | Application、Chain、API、Agent、Runtime、Workflow 到 DB 的受控债务均为零 | | Model/Oper 事务债务 | 0 | 自建 Session、自动事务装饰器、直接 commit/rollback 等基线均为零 | diff --git a/docs/architecture-overview.md b/docs/architecture-overview.md index ca4e7daa1..7b44e82f3 100644 --- a/docs/architecture-overview.md +++ b/docs/architecture-overview.md @@ -745,8 +745,8 @@ flowchart LR | 指标 | 当前值 | |---|---:| -| Python 模块 | 966 | -| 内部导入边 | 8,216 | +| Python 模块 | 968 | +| 内部导入边 | 8,248 | | 非平凡 SCC | 1(精确 containment 的 TMDB 移植包环) | | Application / Chain 具体 Adapter 直连 | 0 / 0 | | Direct egress | 53(债务已清零,53 条精确 containment) | diff --git a/docs/rules/05-architecture.md b/docs/rules/05-architecture.md index cf31103f7..229c96500 100644 --- a/docs/rules/05-architecture.md +++ b/docs/rules/05-architecture.md @@ -144,9 +144,13 @@ startup order and must not recreate their concrete construction. `startup/composition/security.py` owns authentication, user lookup, PassKey and Web access provider assembly; `initializers/modules.py` only invokes that owner and places its returned `AuthenticationRuntime` into the host runtime. -`startup/composition/network.py` -owns concrete network-test, image, internal-address and message-ingress Adapter wiring +`startup/composition/network.py` owns concrete network-test, image, internal-address +and message-ingress Adapter wiring while retaining lazy RuntimeSettings reads. Initializers call these owners in +startup order and must not recreate their concrete construction. +`startup/composition/outbox.py` +owns durable handler validation, lazy event/notification replay binding and the +short-transaction dispatcher factory. Initializers call this owner in startup order and must not recreate their concrete construction. Database runtime ownership rejects overlapping lifespans; after a successful worker shutdown, the configuration/database composition owners must revoke every provider they published @@ -686,8 +690,9 @@ Durable post-commit side effects have a separate boundary: caller's business transaction, from `OutboxDispatchStore`, whose claim, complete and retry operations own independent short transactions. - `app/db/adapters/outbox.py` implements both roles with SQLAlchemy; - `app/startup/composition/subscription.py` and the other composition modules - inject the stager, dispatch-store factory, UoW and handlers. + `app/startup/composition/subscription.py` injects transaction-scoped stagers and + stores, while `app/startup/composition/outbox.py` owns the validated replay handlers + and dispatcher factory. - Immediate request-thread delivery and the dispatcher both claim before calling a handler. Lease acquisition is atomic, while complete/retry is fenced by the claimed attempt so an expired owner cannot settle a newer claim. diff --git a/scripts/architecture/service_locator.py b/scripts/architecture/service_locator.py index aaed8b69b..c8b3611df 100644 --- a/scripts/architecture/service_locator.py +++ b/scripts/architecture/service_locator.py @@ -67,6 +67,7 @@ RUNTIME_FACADE_POLICIES = ( dependency="app.command", exact_consumers=frozenset( { + "app.startup.composition.outbox", "app.startup.initializers.command", "app.startup.initializers.modules", } diff --git a/tests/fixtures/architecture/dependency-baseline.json b/tests/fixtures/architecture/dependency-baseline.json index 5964731ba..405ff3ffd 100644 --- a/tests/fixtures/architecture/dependency-baseline.json +++ b/tests/fixtures/architecture/dependency-baseline.json @@ -1089,8 +1089,8 @@ "runtime_only": true } }, - "edge_count": 8216, - "edge_sha256": "4a7246f19b3fc9ec6e71558d2da17b186c5feb273a44e3160206b3517754e8eb", + "edge_count": 8248, + "edge_sha256": "224c93fde383ba0fa2e5f5fba873b06a53bf9eaa17c28f18b285a5048dd071a8", "edges": [ "app -> app.foundation", "app -> app.foundation.environment", @@ -8714,6 +8714,46 @@ "app.startup.composition.network -> app.application.network", "app.startup.composition.network -> app.runtime", "app.startup.composition.network -> app.runtime.log", + "app.startup.composition.outbox -> app.adapters", + "app.startup.composition.outbox -> app.adapters.external", + "app.startup.composition.outbox -> app.adapters.external.server", + "app.startup.composition.outbox -> app.application", + "app.startup.composition.outbox -> app.application.chain", + "app.startup.composition.outbox -> app.application.chain.events", + "app.startup.composition.outbox -> app.application.outbox", + "app.startup.composition.outbox -> app.command", + "app.startup.composition.outbox -> app.db", + "app.startup.composition.outbox -> app.db.adapters", + "app.startup.composition.outbox -> app.db.adapters.outbox", + "app.startup.composition.outbox -> app.db.session", + "app.startup.composition.outbox -> app.runtime", + "app.startup.composition.outbox -> app.runtime.events", + "app.startup.composition.outbox -> app.runtime.observability", + "app.startup.composition.outbox -> app.schemas", + "app.startup.composition.outbox -> app.schemas.message", + "app.startup.composition.outbox -> app.schemas.types", + "app.startup.composition.security -> app.adapters", + "app.startup.composition.security -> app.adapters.web", + "app.startup.composition.security -> app.adapters.web.security", + "app.startup.composition.security -> app.adapters.web.security.access", + "app.startup.composition.security -> app.application", + "app.startup.composition.security -> app.application.configuration", + "app.startup.composition.security -> app.application.security", + "app.startup.composition.security -> app.application.security.auth", + "app.startup.composition.security -> app.application.security.passkey", + "app.startup.composition.security -> app.application.security.user", + "app.startup.composition.security -> app.db", + "app.startup.composition.security -> app.db.adapters", + "app.startup.composition.security -> app.db.adapters.user", + "app.startup.composition.security -> app.db.oper", + "app.startup.composition.security -> app.db.oper.passkey", + "app.startup.composition.security -> app.db.oper.systemconfig", + "app.startup.composition.security -> app.runtime", + "app.startup.composition.security -> app.runtime.cache", + "app.startup.composition.security -> app.startup", + "app.startup.composition.security -> app.startup.composition", + "app.startup.composition.security -> app.startup.composition.context", + "app.startup.composition.security -> app.startup.composition.database", "app.startup.composition.subscription -> app.adapters", "app.startup.composition.subscription -> app.adapters.external", "app.startup.composition.subscription -> app.adapters.external.server", @@ -8839,9 +8879,6 @@ "app.startup.initializers.modules -> app.adapters.system", "app.startup.initializers.modules -> app.adapters.system.host", "app.startup.initializers.modules -> app.adapters.system.resource", - "app.startup.initializers.modules -> app.adapters.web", - "app.startup.initializers.modules -> app.adapters.web.security", - "app.startup.initializers.modules -> app.adapters.web.security.access", "app.startup.initializers.modules -> app.agent", "app.startup.initializers.modules -> app.agent.tools", "app.startup.initializers.modules -> app.agent.tools.manager", @@ -8850,7 +8887,6 @@ "app.startup.initializers.modules -> app.application.agenttask", "app.startup.initializers.modules -> app.application.chain", "app.startup.initializers.modules -> app.application.chain.context", - "app.startup.initializers.modules -> app.application.chain.events", "app.startup.initializers.modules -> app.application.configuration", "app.startup.initializers.modules -> app.application.history", "app.startup.initializers.modules -> app.application.image", @@ -8861,10 +8897,7 @@ "app.startup.initializers.modules -> app.application.module", "app.startup.initializers.modules -> app.application.outbox", "app.startup.initializers.modules -> app.application.security", - "app.startup.initializers.modules -> app.application.security.auth", - "app.startup.initializers.modules -> app.application.security.passkey", "app.startup.initializers.modules -> app.application.security.url", - "app.startup.initializers.modules -> app.application.security.user", "app.startup.initializers.modules -> app.application.server", "app.startup.initializers.modules -> app.application.server.report", "app.startup.initializers.modules -> app.application.server.share", @@ -8903,13 +8936,11 @@ "app.startup.initializers.modules -> app.db.adapters.transfer", "app.startup.initializers.modules -> app.db.adapters.transfer.admission", "app.startup.initializers.modules -> app.db.adapters.transfer.execution", - "app.startup.initializers.modules -> app.db.adapters.user", "app.startup.initializers.modules -> app.db.adapters.workflow", "app.startup.initializers.modules -> app.db.oper", "app.startup.initializers.modules -> app.db.oper.agentchat", "app.startup.initializers.modules -> app.db.oper.mediaserver", "app.startup.initializers.modules -> app.db.oper.message", - "app.startup.initializers.modules -> app.db.oper.passkey", "app.startup.initializers.modules -> app.db.oper.systemconfig", "app.startup.initializers.modules -> app.db.oper.workflow", "app.startup.initializers.modules -> app.db.session", @@ -8926,7 +8957,6 @@ "app.startup.initializers.modules -> app.runtime.extensions.plugin_manager", "app.startup.initializers.modules -> app.runtime.extensions.service_config", "app.startup.initializers.modules -> app.runtime.log", - "app.startup.initializers.modules -> app.runtime.observability", "app.startup.initializers.modules -> app.runtime.settings", "app.startup.initializers.modules -> app.runtime.state", "app.startup.initializers.modules -> app.runtime.stop", @@ -8943,6 +8973,8 @@ "app.startup.initializers.modules -> app.startup.composition.context", "app.startup.initializers.modules -> app.startup.composition.database", "app.startup.initializers.modules -> app.startup.composition.network", + "app.startup.initializers.modules -> app.startup.composition.outbox", + "app.startup.initializers.modules -> app.startup.composition.security", "app.startup.initializers.modules -> app.startup.composition.subscription", "app.startup.initializers.modules -> app.startup.initializers", "app.startup.initializers.modules -> app.startup.initializers.agent", @@ -9309,7 +9341,7 @@ "app.workflow.actions.transfer_file -> app.workflow", "app.workflow.actions.transfer_file -> app.workflow.actions" ], - "module_count": 966, + "module_count": 968, "modules": [ "app", "app.adapters", @@ -10236,6 +10268,8 @@ "app.startup.composition.context", "app.startup.composition.database", "app.startup.composition.network", + "app.startup.composition.outbox", + "app.startup.composition.security", "app.startup.composition.subscription", "app.startup.initializers", "app.startup.initializers.agent", diff --git a/tests/fixtures/architecture/runtime-contract-baseline.json b/tests/fixtures/architecture/runtime-contract-baseline.json index 4af0d09a4..452e07e43 100644 --- a/tests/fixtures/architecture/runtime-contract-baseline.json +++ b/tests/fixtures/architecture/runtime-contract-baseline.json @@ -1736,15 +1736,15 @@ "EventType.AudioTransferComplete": { "consumer_fingerprints": [], "producer_fingerprints": [ - "93c221b3f8700f899d9613ba205f36d521c76ed5681048c0a754bcb73e6749bf", - "e4ecf33c77ee608912dc20e6840caf13f01a6ad250867a7766e92fef4da17b4b" + "75aa6118952ed941e778b0347b281cf07cff05858b658c811fdbead76a23341f", + "93c221b3f8700f899d9613ba205f36d521c76ed5681048c0a754bcb73e6749bf" ] }, "EventType.AudioTransferFailed": { "consumer_fingerprints": [], "producer_fingerprints": [ "174dd87157d87d94e5656f4251b33005914e52e2c5fa1b53342188a4641b3225", - "e024ae64f7aa0245c005d1173d203a4aaf8aedaa18fb38a3fbe63776c96f160b" + "57da97424ec6e2f81255d39105e0e4047226b47c05ecd5e9ab64b1164e79de53" ] }, "EventType.CommandExcute": { @@ -1775,9 +1775,9 @@ "EventType.DownloadAdded": { "consumer_fingerprints": [], "producer_fingerprints": [ + "35375727017385323cceeeb4db950615a604b51c6a4783171cc506256f3002d6", "6ab7645a6d0422f873242c8c9162c67904948890f429467a7f533e218aec1554", - "6ab7645a6d0422f873242c8c9162c67904948890f429467a7f533e218aec1554", - "912f9d1ffc673cab43f157ea3674122c84591b695761275cee39cc74ced7011d" + "6ab7645a6d0422f873242c8c9162c67904948890f429467a7f533e218aec1554" ] }, "EventType.DownloadDeleted": { @@ -1881,15 +1881,15 @@ "consumer_fingerprints": [], "producer_fingerprints": [ "019a094d2d0fd4fa84076bfab9263c2bf33ece199f4d5a9a5b57f68e07c4a9c0", - "1398d8796771fb8848bf67228b6de8a9d7047f681bab989eefe845f58ad5184c", - "97717bff58a8806d2696054f1a1c9865954d2f0218dab51fb27027f0d560fbea" + "97717bff58a8806d2696054f1a1c9865954d2f0218dab51fb27027f0d560fbea", + "cc7bd553745df756f0a69b1cf42852ab166c9fc6a58a546998b2956a0b5d7305" ] }, "EventType.SubscribeComplete": { "consumer_fingerprints": [], "producer_fingerprints": [ - "eba9652f1bf1fab63b1ab459a1636e2167e03142099cfd2dd665dab6c88989f9", - "ee17c108da3b9e5515176692af946dbff2d54372dc487c1a1880bc8f495dad7b" + "c86fcb5d64d2367e007f01ecdccf01de80f3e0a0dae625c77246d698e1ca9d4b", + "eba9652f1bf1fab63b1ab459a1636e2167e03142099cfd2dd665dab6c88989f9" ] }, "EventType.SubscribeDeleted": { @@ -1897,15 +1897,15 @@ "producer_fingerprints": [ "7ee0ba0bfdba520a389d45c33d361120a0d710c3ea37b1e03532dd7364a52d47", "a08051c5da0861d7198f3f5aa1b4ce6ed054abfbeab35e06ecb64dea71ba8c5e", - "a2252a9f53ea9382e0a9a05564a04440cf1ba9fdf6a8e2fa6c426a20b473bf3b", + "b0e58582f05faa7b8385a100589bcd724072a41cd3523615d7ac927e3272c7f8", "ea7538a3f2ad3ddbf8de6264e0525f065eec338d2ee003a501b725e3194d490a" ] }, "EventType.SubscribeModified": { "consumer_fingerprints": [], "producer_fingerprints": [ - "3561ca8eed8a851c88889ded56d3188b89ad8c7d6c833d3aca24c5c8e9bbc22b", "89165b03827d9801a260dd54dd03e77f903795b8872bd043a79e8283181c786f", + "b71e46714bd811b9c1703fecb112d0de83dd12e89ee70529702817958a9759a8", "ca9bf55c3886cd02fa31feade91fe333377fa8bf7bc9cc2d1bbdfb45214acfa3", "dbd5afd0aa1c2b449880ad3853e7e988dc29d799d40d2309780cf133a7e3c4a5" ] @@ -1913,7 +1913,7 @@ "EventType.SubtitleTransferComplete": { "consumer_fingerprints": [], "producer_fingerprints": [ - "814318ca189db145cccf38f4ae2fb972988af23e13c39d1c3b267bd9fa5bc976", + "b2bacfe7cc27d289b4744dc2f7b904fb8f20368e79c60adab6988188cc011eea", "f8ed3d63cdd6f007dd4f281c1e493b5fa5ffd7dc1591576a0224618315531fa7" ] }, @@ -1921,7 +1921,7 @@ "consumer_fingerprints": [], "producer_fingerprints": [ "45f2e225d176c9d7d49feedd0e9b3e7510760a286e89ec26b57d8173b6d4c002", - "f2fa83463c2e723b08aef8c1ca4c3286fa18a7e1caffa3a24a656d728d47db0d" + "7acfc1c2dd0ca50b8f9b72d55de769507e74851fb88381d66298f649078f5d9b" ] }, "EventType.SystemError": { @@ -1936,15 +1936,15 @@ "EventType.TransferComplete": { "consumer_fingerprints": [], "producer_fingerprints": [ - "b9c576056cd1bea0a768eea4e927144e393e194c44a0f0b6f3fe1a86fe91ddd5", - "f9e1d110de9e4a9c8490f09e5f3b7312b293f51ad95180f8fe7af15607c12307" + "6b3cb59b259d9ca82a52fe8376504ad848351c433d4427efd9b496874acdb1b2", + "b9c576056cd1bea0a768eea4e927144e393e194c44a0f0b6f3fe1a86fe91ddd5" ] }, "EventType.TransferFailed": { "consumer_fingerprints": [], "producer_fingerprints": [ "8ac0a3e2e96daa1e00ed16560f57bbe1ad55263753b46d1f8f196c69d80e978f", - "be58b678d9a1f8c403e535c1836d2c509c7d34b664253cfe54da276e0d8c889c" + "d0fb23c410c6fda68c9e676f107666e31e0828886f5d9aeb4f5d3dcd9fc2bda7" ] }, "EventType.UserMessage": { @@ -2886,6 +2886,138 @@ "qualname": "SchedulerExecutionOwner._handle_job_error", "receiver_kind": "canonical_singleton" }, + { + "caller": "app.startup.composition.outbox", + "dynamic": false, + "events": [ + "EventType.DownloadAdded" + ], + "fingerprint": "35375727017385323cceeeb4db950615a604b51c6a4783171cc506256f3002d6", + "invalid": false, + "method": "send_event_strict", + "qualname": "build_outbox_handlers", + "receiver_kind": "constructed_manager" + }, + { + "caller": "app.startup.composition.outbox", + "dynamic": false, + "events": [ + "EventType.AudioTransferFailed" + ], + "fingerprint": "57da97424ec6e2f81255d39105e0e4047226b47c05ecd5e9ab64b1164e79de53", + "invalid": false, + "method": "send_event_strict", + "qualname": "build_outbox_handlers", + "receiver_kind": "constructed_manager" + }, + { + "caller": "app.startup.composition.outbox", + "dynamic": false, + "events": [ + "EventType.TransferComplete" + ], + "fingerprint": "6b3cb59b259d9ca82a52fe8376504ad848351c433d4427efd9b496874acdb1b2", + "invalid": false, + "method": "send_event_strict", + "qualname": "build_outbox_handlers", + "receiver_kind": "constructed_manager" + }, + { + "caller": "app.startup.composition.outbox", + "dynamic": false, + "events": [ + "EventType.AudioTransferComplete" + ], + "fingerprint": "75aa6118952ed941e778b0347b281cf07cff05858b658c811fdbead76a23341f", + "invalid": false, + "method": "send_event_strict", + "qualname": "build_outbox_handlers", + "receiver_kind": "constructed_manager" + }, + { + "caller": "app.startup.composition.outbox", + "dynamic": false, + "events": [ + "EventType.SubtitleTransferFailed" + ], + "fingerprint": "7acfc1c2dd0ca50b8f9b72d55de769507e74851fb88381d66298f649078f5d9b", + "invalid": false, + "method": "send_event_strict", + "qualname": "build_outbox_handlers", + "receiver_kind": "constructed_manager" + }, + { + "caller": "app.startup.composition.outbox", + "dynamic": false, + "events": [ + "EventType.SubscribeDeleted" + ], + "fingerprint": "b0e58582f05faa7b8385a100589bcd724072a41cd3523615d7ac927e3272c7f8", + "invalid": false, + "method": "send_event_strict", + "qualname": "build_outbox_handlers", + "receiver_kind": "constructed_manager" + }, + { + "caller": "app.startup.composition.outbox", + "dynamic": false, + "events": [ + "EventType.SubtitleTransferComplete" + ], + "fingerprint": "b2bacfe7cc27d289b4744dc2f7b904fb8f20368e79c60adab6988188cc011eea", + "invalid": false, + "method": "send_event_strict", + "qualname": "build_outbox_handlers", + "receiver_kind": "constructed_manager" + }, + { + "caller": "app.startup.composition.outbox", + "dynamic": false, + "events": [ + "EventType.SubscribeModified" + ], + "fingerprint": "b71e46714bd811b9c1703fecb112d0de83dd12e89ee70529702817958a9759a8", + "invalid": false, + "method": "send_event_strict", + "qualname": "build_outbox_handlers", + "receiver_kind": "constructed_manager" + }, + { + "caller": "app.startup.composition.outbox", + "dynamic": false, + "events": [ + "EventType.SubscribeComplete" + ], + "fingerprint": "c86fcb5d64d2367e007f01ecdccf01de80f3e0a0dae625c77246d698e1ca9d4b", + "invalid": false, + "method": "send_event_strict", + "qualname": "build_outbox_handlers", + "receiver_kind": "constructed_manager" + }, + { + "caller": "app.startup.composition.outbox", + "dynamic": false, + "events": [ + "EventType.SubscribeAdded" + ], + "fingerprint": "cc7bd553745df756f0a69b1cf42852ab166c9fc6a58a546998b2956a0b5d7305", + "invalid": false, + "method": "send_event_strict", + "qualname": "build_outbox_handlers", + "receiver_kind": "constructed_manager" + }, + { + "caller": "app.startup.composition.outbox", + "dynamic": false, + "events": [ + "EventType.TransferFailed" + ], + "fingerprint": "d0fb23c410c6fda68c9e676f107666e31e0828886f5d9aeb4f5d3dcd9fc2bda7", + "invalid": false, + "method": "send_event_strict", + "qualname": "build_outbox_handlers", + "receiver_kind": "constructed_manager" + }, { "caller": "app.startup.composition.subscription", "dynamic": false, @@ -2946,138 +3078,6 @@ "qualname": "_publish_modified_sync", "receiver_kind": "canonical_singleton" }, - { - "caller": "app.startup.initializers.modules", - "dynamic": false, - "events": [ - "EventType.SubscribeAdded" - ], - "fingerprint": "1398d8796771fb8848bf67228b6de8a9d7047f681bab989eefe845f58ad5184c", - "invalid": false, - "method": "send_event_strict", - "qualname": "_build_outbox_handlers", - "receiver_kind": "constructed_manager" - }, - { - "caller": "app.startup.initializers.modules", - "dynamic": false, - "events": [ - "EventType.SubscribeModified" - ], - "fingerprint": "3561ca8eed8a851c88889ded56d3188b89ad8c7d6c833d3aca24c5c8e9bbc22b", - "invalid": false, - "method": "send_event_strict", - "qualname": "_build_outbox_handlers", - "receiver_kind": "constructed_manager" - }, - { - "caller": "app.startup.initializers.modules", - "dynamic": false, - "events": [ - "EventType.SubtitleTransferComplete" - ], - "fingerprint": "814318ca189db145cccf38f4ae2fb972988af23e13c39d1c3b267bd9fa5bc976", - "invalid": false, - "method": "send_event_strict", - "qualname": "_build_outbox_handlers", - "receiver_kind": "constructed_manager" - }, - { - "caller": "app.startup.initializers.modules", - "dynamic": false, - "events": [ - "EventType.DownloadAdded" - ], - "fingerprint": "912f9d1ffc673cab43f157ea3674122c84591b695761275cee39cc74ced7011d", - "invalid": false, - "method": "send_event_strict", - "qualname": "_build_outbox_handlers", - "receiver_kind": "constructed_manager" - }, - { - "caller": "app.startup.initializers.modules", - "dynamic": false, - "events": [ - "EventType.SubscribeDeleted" - ], - "fingerprint": "a2252a9f53ea9382e0a9a05564a04440cf1ba9fdf6a8e2fa6c426a20b473bf3b", - "invalid": false, - "method": "send_event_strict", - "qualname": "_build_outbox_handlers", - "receiver_kind": "constructed_manager" - }, - { - "caller": "app.startup.initializers.modules", - "dynamic": false, - "events": [ - "EventType.TransferFailed" - ], - "fingerprint": "be58b678d9a1f8c403e535c1836d2c509c7d34b664253cfe54da276e0d8c889c", - "invalid": false, - "method": "send_event_strict", - "qualname": "_build_outbox_handlers", - "receiver_kind": "constructed_manager" - }, - { - "caller": "app.startup.initializers.modules", - "dynamic": false, - "events": [ - "EventType.AudioTransferFailed" - ], - "fingerprint": "e024ae64f7aa0245c005d1173d203a4aaf8aedaa18fb38a3fbe63776c96f160b", - "invalid": false, - "method": "send_event_strict", - "qualname": "_build_outbox_handlers", - "receiver_kind": "constructed_manager" - }, - { - "caller": "app.startup.initializers.modules", - "dynamic": false, - "events": [ - "EventType.AudioTransferComplete" - ], - "fingerprint": "e4ecf33c77ee608912dc20e6840caf13f01a6ad250867a7766e92fef4da17b4b", - "invalid": false, - "method": "send_event_strict", - "qualname": "_build_outbox_handlers", - "receiver_kind": "constructed_manager" - }, - { - "caller": "app.startup.initializers.modules", - "dynamic": false, - "events": [ - "EventType.SubscribeComplete" - ], - "fingerprint": "ee17c108da3b9e5515176692af946dbff2d54372dc487c1a1880bc8f495dad7b", - "invalid": false, - "method": "send_event_strict", - "qualname": "_build_outbox_handlers", - "receiver_kind": "constructed_manager" - }, - { - "caller": "app.startup.initializers.modules", - "dynamic": false, - "events": [ - "EventType.SubtitleTransferFailed" - ], - "fingerprint": "f2fa83463c2e723b08aef8c1ca4c3286fa18a7e1caffa3a24a656d728d47db0d", - "invalid": false, - "method": "send_event_strict", - "qualname": "_build_outbox_handlers", - "receiver_kind": "constructed_manager" - }, - { - "caller": "app.startup.initializers.modules", - "dynamic": false, - "events": [ - "EventType.TransferComplete" - ], - "fingerprint": "f9e1d110de9e4a9c8490f09e5f3b7312b293f51ad95180f8fe7af15607c12307", - "invalid": false, - "method": "send_event_strict", - "qualname": "_build_outbox_handlers", - "receiver_kind": "constructed_manager" - }, { "caller": "app.workflow", "dynamic": false, diff --git a/tests/test_architecture_dependencies.py b/tests/test_architecture_dependencies.py index c29f14e88..e8dd53535 100644 --- a/tests/test_architecture_dependencies.py +++ b/tests/test_architecture_dependencies.py @@ -822,6 +822,32 @@ def test_modules_initializer_delegates_network_composition(): assert detail in network_source +def test_modules_initializer_delegates_outbox_composition(): + """模块初始化器只发布 Outbox dispatcher,不再内联 handler 与存储装配。""" + initializer_source = ( + APP_ROOT / "startup" / "initializers" / "modules.py" + ).read_text(encoding="utf-8") + composition_source = ( + APP_ROOT / "startup" / "composition" / "outbox.py" + ).read_text(encoding="utf-8") + package_source = ( + APP_ROOT / "startup" / "composition" / "__init__.py" + ).read_text(encoding="utf-8") + + assert "configure_outbox_dispatcher(build_outbox_dispatcher)" in initializer_source + for retired_name in ("_build_outbox_handlers", "_build_outbox_dispatcher"): + assert retired_name not in initializer_source + for detail in ( + "def build_outbox_handlers()", + "def build_outbox_dispatcher()", + "validate_durable_event_handlers(handlers)", + "SqlAlchemyOutboxDispatchStore(SessionFactory)", + ): + assert detail in composition_source + assert "build_outbox_handlers" not in package_source + assert "build_outbox_dispatcher" not in package_source + + def test_canonical_workflow_oper_has_no_legacy_writer_or_duplicate_exports(): """工作流旧写入口只能存在于 SDK Legacy facade。""" oper_path = APP_ROOT / "db" / "oper" / "workflow.py" diff --git a/tests/test_outbox.py b/tests/test_outbox.py index c0c943402..f10dbfbfd 100644 --- a/tests/test_outbox.py +++ b/tests/test_outbox.py @@ -334,7 +334,7 @@ def test_startup_handler_replays_strict_boundary_with_stable_key( """真实 startup handler 等待执行边界,并以同一键诚实重放。""" from app.command import CommandChain from app.runtime.events import EventManager - from app.startup.initializers.modules import _build_outbox_handlers + from app.startup.composition.outbox import build_outbox_handlers calls = [] if handler_kind == "event": @@ -353,7 +353,7 @@ def test_startup_handler_replays_strict_boundary_with_stable_key( "post_message_strict", lambda _self, _message, *, event_key: calls.append(event_key), ) - handlers = _build_outbox_handlers() + handlers = build_outbox_handlers() engine = create_engine("sqlite+pysqlite:///:memory:") Base.metadata.create_all(engine) factory = sessionmaker(bind=engine) @@ -379,6 +379,29 @@ def test_startup_handler_replays_strict_boundary_with_stable_key( assert calls == [event_key, event_key] +def test_outbox_handler_builder_keeps_runtime_owners_lazy(monkeypatch) -> None: + """构造 handler 映射时先校验完整性,且不提前实例化运行 owner。""" + import app.startup.composition.outbox as outbox_composition + + command_chain = MagicMock(side_effect=AssertionError("CommandChain 不应提前实例化")) + event_manager = MagicMock(side_effect=AssertionError("EventManager 不应提前实例化")) + validate_handlers = MagicMock() + monkeypatch.setattr(outbox_composition, "CommandChain", command_chain) + monkeypatch.setattr(outbox_composition, "EventManager", event_manager) + monkeypatch.setattr( + outbox_composition, + "validate_durable_event_handlers", + validate_handlers, + ) + + handlers = outbox_composition.build_outbox_handlers() + + assert handlers + validate_handlers.assert_called_once_with(handlers) + command_chain.assert_not_called() + event_manager.assert_not_called() + + def test_strict_notification_preserves_legacy_provider_signature(monkeypatch) -> None: """durable 通知只传既有 message 参数,并在调用上下文携带稳定键。""" from app.command import CommandChain diff --git a/tests/test_service_locator_gate.py b/tests/test_service_locator_gate.py index 36219a904..de590d36a 100644 --- a/tests/test_service_locator_gate.py +++ b/tests/test_service_locator_gate.py @@ -32,6 +32,7 @@ def test_service_locator_gate_detects_each_concrete_runtime_family() -> None: "app.runtime.extensions.plugin_manager", "app.scheduler.facade", }, + "app.startup.composition.outbox": {"app.command"}, "app.workflow.manager": {"app.workflow"}, }