Files
MoviePilot/app/startup/composition/subscription.py
Aqr-K 951cdc5b27 fix: arbitrate subscription identity and restore workflow state (#7147)
* fix: arbitrate subscription identity and restore workflow state

* test: preserve legacy empty-owner subscription transactions

* fix: align shared atomic configuration method with lifecycle prerequisite

---------

Co-authored-by: jxxghp <jxxghp@gmail.com>
2026-10-23 08:14:15 +08:00

291 lines
9.8 KiB
Python

"""
订阅事务作用域及提交后回调的组合装配。
"""
import asyncio
import threading
from collections.abc import AsyncIterator, Callable, Iterator, Mapping
from contextlib import asynccontextmanager, contextmanager
from typing import Any
from app.adapters.external.server import MoviePilotServerHelper
from app.application.outbox import AsyncOutboxDispatchStore
from app.application.rules import (
AsyncRuleGroupMutationService,
SyncRuleGroupMutationService,
)
from app.application.site.mutation import SyncSiteReferenceMutationService
from app.application.subscription.complete import CompleteSubscriptionCommand
from app.application.subscription.contract import SubscriptionStagingPort
from app.application.subscription.delete import (
DeleteSubscribeCommand,
SyncDeleteSubscribeCommand,
)
from app.application.subscription.mutation import (
SubscriptionMutationService,
SyncSubscriptionMutationService,
)
from app.application.subscription.write import (
AsyncSubscriptionOutboxStager,
AsyncUnitOfWork,
SubscriptionBatchWritePort,
)
from app.db.adapters.configuration import SessionSystemConfigurationRepository
from app.db.adapters.outbox import (
SqlAlchemyAsyncOutboxDispatchStore,
SqlAlchemyAsyncOutboxStager,
SqlAlchemyOutboxDispatchStore,
SqlAlchemyOutboxStager,
)
from app.db.adapters.subscription import (
SessionSubscriptionBatchWriter,
SessionSubscriptionHistoryRepository,
SessionSubscriptionRepository,
)
from app.db.session import SessionFactory, async_session_scope
from app.db.uow import SqlAlchemyAsyncUnitOfWork, SqlAlchemyUnitOfWork
from app.runtime.events import eventmanager
from app.runtime.tasks import get_task_registry
from app.schemas.common import JsonData
from app.schemas.types import EventType, SystemConfigKey
_reference_mutation_lock = threading.Lock()
SystemConfigPublisher = Callable[[Mapping[SystemConfigKey, JsonData]], None]
def build_subscription_batch_writer(
*,
repository: SubscriptionStagingPort,
unit_of_work: AsyncUnitOfWork,
outbox: AsyncSubscriptionOutboxStager,
dispatch_store: AsyncOutboxDispatchStore,
) -> SubscriptionBatchWritePort:
"""
组装复用请求级事务且在提交后结算 durable intents 的批量写端口。
"""
return SessionSubscriptionBatchWriter(
repository=repository,
unit_of_work=unit_of_work,
outbox=outbox,
dispatch_store=dispatch_store,
)
@contextmanager
def rule_group_mutation_scope(
publish: SystemConfigPublisher,
) -> Iterator[SyncRuleGroupMutationService]:
"""
串行构造共享同步 Session 的规则定义与引用原子服务。
"""
_reference_mutation_lock.acquire()
session = SessionFactory()
try:
yield SyncRuleGroupMutationService(
configuration=SessionSystemConfigurationRepository(session),
subscriptions=SessionSubscriptionRepository(session),
unit_of_work=SqlAlchemyUnitOfWork(session),
publish=publish,
)
finally:
session.close()
_reference_mutation_lock.release()
@asynccontextmanager
async def async_rule_group_mutation_scope(
publish: SystemConfigPublisher,
) -> AsyncIterator[AsyncRuleGroupMutationService]:
"""
非阻塞等待跨入口锁,并构造共享 AsyncSession 的规则原子服务。
"""
lock = _reference_mutation_lock
acquisition = get_task_registry().create(
asyncio.to_thread(lock.acquire), owner="subscription.reference-lock", cancel_on_shutdown=False,
)
try:
await asyncio.shield(acquisition)
except asyncio.CancelledError:
# shield 保留线程的迟到结果,回调负责释放已经没有请求所有者的锁。
acquisition.add_done_callback(lambda result: lock.release() if result.result() else None)
raise
try:
async with async_session_scope() as session:
async def publish_async(
values: Mapping[SystemConfigKey, JsonData],
) -> None:
"""
适配同步快照发布器到异步命令提交后合同。
"""
publish(values)
yield AsyncRuleGroupMutationService(
configuration=SessionSystemConfigurationRepository(session),
subscriptions=SessionSubscriptionRepository(session),
unit_of_work=SqlAlchemyAsyncUnitOfWork(session),
publish=publish_async,
)
finally:
lock.release()
@contextmanager
def site_reference_mutation_scope(
publish: SystemConfigPublisher,
) -> Iterator[SyncSiteReferenceMutationService]:
"""
构造共享同步 Session 的 RSS 与订阅站点引用原子服务。
"""
_reference_mutation_lock.acquire()
session = SessionFactory()
try:
yield SyncSiteReferenceMutationService(
configuration=SessionSystemConfigurationRepository(session),
subscriptions=SessionSubscriptionRepository(session),
unit_of_work=SqlAlchemyUnitOfWork(session),
publish=publish,
)
finally:
session.close()
_reference_mutation_lock.release()
async def _publish_modified(payload: dict[str, Any]) -> None:
"""
发布事务已提交的订阅修改事件。
"""
await eventmanager.async_send_event(EventType.SubscribeModified, payload)
def _publish_modified_sync(payload: dict[str, Any]) -> None:
"""
为同步 Chain 入口发布事务已提交的订阅修改事件。
"""
eventmanager.send_event(EventType.SubscribeModified, payload)
async def _publish_deleted(payload: dict[str, Any]) -> None:
"""
发布事务已提交的订阅删除事件。
"""
await eventmanager.async_send_event(EventType.SubscribeDeleted, payload)
def _publish_deleted_sync(payload: dict[str, Any]) -> None:
"""
为同步消息入口发布事务已提交的订阅删除事件。
"""
eventmanager.send_event(EventType.SubscribeDeleted, payload)
async def _report_deleted(payload: Mapping[str, JsonData]) -> object:
"""
把删除统计端口的只读映射转换为服务端兼容的普通字典。
"""
return await MoviePilotServerHelper.async_sub_done_durable(dict(payload))
def _report_deleted_sync(payload: Mapping[str, JsonData]) -> object:
"""
把同步删除统计端口的只读映射转换为服务端兼容的普通字典。
"""
return MoviePilotServerHelper.sub_done_durable(dict(payload))
def _publish_completed(payload: dict[str, Any]) -> None:
"""
发布已提交的订阅完成事件。
"""
eventmanager.send_event(EventType.SubscribeComplete, payload)
@contextmanager
def subscription_completion_scope() -> Iterator[CompleteSubscriptionCommand]:
"""
为同步完成链创建独占 Session、UoW 与 durable outbox。
"""
session = SessionFactory()
try:
yield CompleteSubscriptionCommand(
repository=SessionSubscriptionRepository(session),
unit_of_work=SqlAlchemyUnitOfWork(session),
outbox=SqlAlchemyOutboxStager(session),
dispatch_store=SqlAlchemyOutboxDispatchStore(SessionFactory),
publish=_publish_completed,
)
finally:
session.close()
@asynccontextmanager
async def subscription_mutation_scope() -> AsyncIterator[SubscriptionMutationService]:
"""
为非 HTTP 入口创建独占订阅修改会话、UoW 与 outbox。
"""
async with async_session_scope() as session:
yield SubscriptionMutationService(
repository=SessionSubscriptionRepository(session),
history_repository=SessionSubscriptionHistoryRepository(session),
unit_of_work=SqlAlchemyAsyncUnitOfWork(session),
outbox=SqlAlchemyAsyncOutboxStager(session),
dispatch_store=SqlAlchemyAsyncOutboxDispatchStore(
async_session_scope
),
publish_modified=_publish_modified,
)
@contextmanager
def sync_subscription_mutation_scope() -> Iterator[SyncSubscriptionMutationService]:
"""
为同步 Chain 入口创建独占 Session、UoW 与 durable outbox。
"""
session = SessionFactory()
try:
yield SyncSubscriptionMutationService(
repository=SessionSubscriptionRepository(session),
unit_of_work=SqlAlchemyUnitOfWork(session),
outbox=SqlAlchemyOutboxStager(session),
dispatch_store=SqlAlchemyOutboxDispatchStore(SessionFactory),
publish_modified=_publish_modified_sync,
)
finally:
session.close()
@asynccontextmanager
async def delete_subscribe_scope() -> AsyncIterator[DeleteSubscribeCommand]:
"""
为非 HTTP 入口创建独占订阅删除会话、UoW 与 outbox。
"""
async with async_session_scope() as session:
yield DeleteSubscribeCommand(
repository=SessionSubscriptionRepository(session),
unit_of_work=SqlAlchemyAsyncUnitOfWork(session),
publish_deleted=_publish_deleted,
report_deleted=_report_deleted,
outbox=SqlAlchemyAsyncOutboxStager(session),
dispatch_store=SqlAlchemyAsyncOutboxDispatchStore(
async_session_scope
),
)
@contextmanager
def sync_delete_subscribe_scope() -> Iterator[SyncDeleteSubscribeCommand]:
"""
为同步消息入口创建独占 Session、UoW 与 durable outbox。
"""
session = SessionFactory()
try:
yield SyncDeleteSubscribeCommand(
repository=SessionSubscriptionRepository(session),
unit_of_work=SqlAlchemyUnitOfWork(session),
publish_deleted=_publish_deleted_sync,
report_deleted=_report_deleted_sync,
outbox=SqlAlchemyOutboxStager(session),
dispatch_store=SqlAlchemyOutboxDispatchStore(SessionFactory),
)
finally:
session.close()