mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-05 23:47:41 +08:00
feat: persist subscription side-effect outbox
This commit is contained in:
@@ -54,6 +54,8 @@ from app.application.security.userconfig import (
|
||||
configure_user_configuration,
|
||||
)
|
||||
from app.application.history import configure_transfer_history_provider
|
||||
from app.application.outbox import OutboxDispatcher, configure_outbox_dispatcher
|
||||
from app.startup.outbox import SqlAlchemyOutboxRepository
|
||||
from app.application.site.query import SiteQueryService, configure_site_query_service
|
||||
from app.application.site.health import SiteHealthService, configure_site_health_service
|
||||
from app.application.workflow import WorkflowQueryService, configure_workflow_query
|
||||
@@ -94,7 +96,7 @@ from app.db.oper.workflow import WorkflowOper
|
||||
from app.command import CommandChain
|
||||
from app.schemas.message import Message
|
||||
from app.schemas.message import MessageType
|
||||
from app.schemas.types import SystemConfigKey
|
||||
from app.schemas.types import EventType, SystemConfigKey
|
||||
from app.startup.agent_initializer import init_agent, stop_agent
|
||||
from app.startup.database import build_database_governance
|
||||
from app.startup.managed_resources_initializer import (
|
||||
@@ -194,6 +196,21 @@ def configure_runtime_data_providers() -> None:
|
||||
)
|
||||
|
||||
|
||||
def _build_outbox_dispatcher() -> OutboxDispatcher:
|
||||
"""创建一次恢复批次独占的 Session、Repository 和事件 handler。"""
|
||||
session = SessionFactory()
|
||||
return OutboxDispatcher(
|
||||
repository=SqlAlchemyOutboxRepository(session),
|
||||
handlers={
|
||||
"subscribe.added": lambda message: EventManager().send_event(
|
||||
EventType.SubscribeAdded,
|
||||
message.payload,
|
||||
)
|
||||
},
|
||||
close=session.close,
|
||||
)
|
||||
|
||||
|
||||
def configure_wallpaper_services() -> None:
|
||||
"""把需要 Chain 编排的壁纸来源注入图片服务。"""
|
||||
configure_wallpaper_providers(
|
||||
@@ -454,6 +471,7 @@ async def init_modules() -> HostRuntime:
|
||||
user=lambda: UserOper(),
|
||||
)
|
||||
configure_system_config(SystemConfigService(repository=SystemConfigOper()))
|
||||
configure_outbox_dispatcher(_build_outbox_dispatcher)
|
||||
configure_transfer_retry_config(
|
||||
lambda: TransferRetryConfig(
|
||||
max_failed_retries=settings.TRANSFER_MAX_FAILED_RETRIES,
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
"""启动组合层使用的 SQLAlchemy outbox 持久化适配器。"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import or_, select, update
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.application.outbox import ClaimedOutboxMessage, OutboxIntent
|
||||
from app.db.base import execute_dml
|
||||
from app.db.models.outbox import OutboxMessage
|
||||
|
||||
|
||||
def _iso(value: datetime) -> str:
|
||||
"""将带时区时间统一序列化为可排序 ISO 字符串。"""
|
||||
return value.isoformat()
|
||||
|
||||
|
||||
class SqlAlchemyOutboxRepository:
|
||||
"""使用调用方 Session 原子暂存并条件认领 outbox。"""
|
||||
|
||||
def __init__(self, session: Session) -> None:
|
||||
"""保存由调用方拥有的 SQLAlchemy Session。"""
|
||||
self._session = session
|
||||
|
||||
def stage(self, intent: OutboxIntent, now: datetime) -> None:
|
||||
"""加入当前事务并 flush,使唯一键冲突在业务 commit 前暴露。"""
|
||||
self._session.add(
|
||||
OutboxMessage(
|
||||
event_key=intent.event_key,
|
||||
topic=intent.topic,
|
||||
payload_version=intent.payload_version,
|
||||
payload=intent.payload,
|
||||
status="pending",
|
||||
attempt=0,
|
||||
next_retry_at=_iso(now),
|
||||
created_at=_iso(now),
|
||||
)
|
||||
)
|
||||
self._session.flush()
|
||||
|
||||
def claim(
|
||||
self,
|
||||
now: datetime,
|
||||
lease_until: datetime,
|
||||
) -> ClaimedOutboxMessage | None:
|
||||
"""条件更新候选行;并发丢失竞争时返回 None。"""
|
||||
now_text = _iso(now)
|
||||
candidate = self._session.execute(
|
||||
select(OutboxMessage)
|
||||
.where(
|
||||
OutboxMessage.status.in_(("pending", "processing")),
|
||||
OutboxMessage.next_retry_at <= now_text,
|
||||
or_(
|
||||
OutboxMessage.lease_until.is_(None),
|
||||
OutboxMessage.lease_until <= now_text,
|
||||
),
|
||||
)
|
||||
.order_by(OutboxMessage.id)
|
||||
.limit(1)
|
||||
).scalars().first()
|
||||
if candidate is None:
|
||||
return None
|
||||
next_attempt = candidate.attempt + 1
|
||||
claimed = execute_dml(
|
||||
self._session,
|
||||
update(OutboxMessage)
|
||||
.where(
|
||||
OutboxMessage.id == candidate.id,
|
||||
OutboxMessage.attempt == candidate.attempt,
|
||||
or_(
|
||||
OutboxMessage.lease_until.is_(None),
|
||||
OutboxMessage.lease_until <= now_text,
|
||||
),
|
||||
)
|
||||
.values(
|
||||
status="processing",
|
||||
attempt=next_attempt,
|
||||
lease_until=_iso(lease_until),
|
||||
),
|
||||
)
|
||||
self._session.commit()
|
||||
if not claimed:
|
||||
return None
|
||||
return ClaimedOutboxMessage(
|
||||
message_id=candidate.id,
|
||||
event_key=candidate.event_key,
|
||||
topic=candidate.topic,
|
||||
payload=dict(candidate.payload),
|
||||
payload_version=candidate.payload_version,
|
||||
attempt=next_attempt,
|
||||
)
|
||||
|
||||
def complete(self, message_id: int, completed_at: datetime) -> None:
|
||||
"""持久化完成终态并释放 lease。"""
|
||||
self._session.execute(
|
||||
update(OutboxMessage)
|
||||
.where(OutboxMessage.id == message_id)
|
||||
.values(status="completed", completed_at=_iso(completed_at), lease_until=None)
|
||||
)
|
||||
self._session.commit()
|
||||
|
||||
def complete_by_event_key(self, event_key: str, completed_at: datetime) -> None:
|
||||
"""即时 post-commit 全部成功时按幂等键收口对应 intent。"""
|
||||
self._session.execute(
|
||||
update(OutboxMessage)
|
||||
.where(OutboxMessage.event_key == event_key)
|
||||
.values(status="completed", completed_at=_iso(completed_at), lease_until=None)
|
||||
)
|
||||
self._session.commit()
|
||||
|
||||
def retry(
|
||||
self,
|
||||
message_id: int,
|
||||
*,
|
||||
next_retry_at: datetime,
|
||||
last_error: str,
|
||||
dead: bool,
|
||||
) -> None:
|
||||
"""持久化下一次退避或不可自动重试的 dead 终态。"""
|
||||
self._session.execute(
|
||||
update(OutboxMessage)
|
||||
.where(OutboxMessage.id == message_id)
|
||||
.values(
|
||||
status="dead" if dead else "pending",
|
||||
next_retry_at=_iso(next_retry_at),
|
||||
lease_until=None,
|
||||
last_error=last_error,
|
||||
)
|
||||
)
|
||||
self._session.commit()
|
||||
|
||||
|
||||
class SqlAlchemyAsyncOutboxStager:
|
||||
"""只负责把 outbox 意图加入调用方异步事务。"""
|
||||
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
"""保存由异步订阅命令拥有的 Session。"""
|
||||
self._session = session
|
||||
|
||||
async def stage(self, intent: OutboxIntent, now: datetime) -> None:
|
||||
"""暂存并 flush,确保业务行与意图由同一次 commit 决定。"""
|
||||
self._session.add(
|
||||
OutboxMessage(
|
||||
event_key=intent.event_key,
|
||||
topic=intent.topic,
|
||||
payload_version=intent.payload_version,
|
||||
payload=intent.payload,
|
||||
status="pending",
|
||||
attempt=0,
|
||||
next_retry_at=_iso(now),
|
||||
created_at=_iso(now),
|
||||
)
|
||||
)
|
||||
await self._session.flush()
|
||||
|
||||
async def complete_by_event_key(
|
||||
self,
|
||||
event_key: str,
|
||||
completed_at: datetime,
|
||||
) -> None:
|
||||
"""异步 post-commit 全部成功时按幂等键收口 intent。"""
|
||||
await self._session.execute(
|
||||
update(OutboxMessage)
|
||||
.where(OutboxMessage.event_key == event_key)
|
||||
.values(status="completed", completed_at=_iso(completed_at), lease_until=None)
|
||||
)
|
||||
await self._session.commit()
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from collections.abc import Callable
|
||||
from contextlib import AbstractAsyncContextManager
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -11,9 +12,14 @@ from app.application.subscription.write import (
|
||||
AsyncAfterCommitEffect,
|
||||
AsyncCreateSubscriptionCommand,
|
||||
CreateSubscriptionCommand,
|
||||
subscription_added_event_key,
|
||||
)
|
||||
from app.db.oper.subscribe import SubscribeOper
|
||||
from app.db.uow import SqlAlchemyAsyncUnitOfWork, SqlAlchemyUnitOfWork
|
||||
from app.startup.outbox import (
|
||||
SqlAlchemyAsyncOutboxStager,
|
||||
SqlAlchemyOutboxRepository,
|
||||
)
|
||||
|
||||
|
||||
class TransactionalSubscribeWriter:
|
||||
@@ -41,11 +47,23 @@ class TransactionalSubscribeWriter:
|
||||
"""在独占同步会话内执行一次完整订阅新增事务。"""
|
||||
session = self._sync_session()
|
||||
try:
|
||||
outbox = SqlAlchemyOutboxRepository(session)
|
||||
command = CreateSubscriptionCommand(
|
||||
repository=SubscribeOper(session),
|
||||
unit_of_work=SqlAlchemyUnitOfWork(session),
|
||||
outbox=outbox,
|
||||
)
|
||||
return command.execute(identity, payload, username, after_commit)
|
||||
|
||||
def delivered(subscribe_id: int) -> None:
|
||||
"""执行旧 post-commit 编排,全部成功后收口 durable intent。"""
|
||||
if after_commit:
|
||||
after_commit(subscribe_id)
|
||||
outbox.complete_by_event_key(
|
||||
subscription_added_event_key(subscribe_id, payload),
|
||||
datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
return command.execute(identity, payload, username, delivered)
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
@@ -58,13 +76,25 @@ class TransactionalSubscribeWriter:
|
||||
) -> tuple[int, str]:
|
||||
"""在独占异步会话作用域内执行一次完整订阅新增事务。"""
|
||||
async with self._async_session() as session:
|
||||
outbox = SqlAlchemyAsyncOutboxStager(session)
|
||||
command = AsyncCreateSubscriptionCommand(
|
||||
repository=SubscribeOper(session),
|
||||
unit_of_work=SqlAlchemyAsyncUnitOfWork(session),
|
||||
outbox=outbox,
|
||||
)
|
||||
|
||||
async def delivered(subscribe_id: int) -> None:
|
||||
"""异步执行旧编排,全部成功后收口 durable intent。"""
|
||||
if after_commit:
|
||||
await after_commit(subscribe_id)
|
||||
await outbox.complete_by_event_key(
|
||||
subscription_added_event_key(subscribe_id, payload),
|
||||
datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
return await command.execute(
|
||||
identity,
|
||||
payload,
|
||||
username,
|
||||
after_commit,
|
||||
delivered,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user