mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-10-30 03:48:03 +08:00
261 lines
8.3 KiB
Python
261 lines
8.3 KiB
Python
"""持久副作用 outbox 的应用契约与有限重试 dispatcher。"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from collections.abc import Callable, Mapping
|
||
from dataclasses import dataclass
|
||
from datetime import datetime, timedelta, timezone
|
||
from types import MappingProxyType
|
||
from typing import Any, Protocol, TypeVar
|
||
|
||
from app.schemas.types import EventType
|
||
|
||
|
||
T = TypeVar("T")
|
||
|
||
|
||
SUBSCRIBE_ADDED_TOPIC = "subscribe.added"
|
||
SUBSCRIBE_MODIFIED_TOPIC = "subscribe.modified"
|
||
SUBSCRIBE_DELETED_TOPIC = "subscribe.deleted"
|
||
DOWNLOAD_ADDED_TOPIC = "download.added"
|
||
TRANSFER_COMPLETED_TOPIC = "transfer.completed"
|
||
TRANSFER_FAILED_TOPIC = "transfer.failed"
|
||
OUTBOX_LEASE_SECONDS = 60
|
||
|
||
DURABLE_EVENT_TOPICS: Mapping[EventType, str] = MappingProxyType({
|
||
EventType.SubscribeAdded: SUBSCRIBE_ADDED_TOPIC,
|
||
EventType.SubscribeModified: SUBSCRIBE_MODIFIED_TOPIC,
|
||
EventType.SubscribeDeleted: SUBSCRIBE_DELETED_TOPIC,
|
||
EventType.DownloadAdded: DOWNLOAD_ADDED_TOPIC,
|
||
EventType.TransferComplete: TRANSFER_COMPLETED_TOPIC,
|
||
EventType.TransferFailed: TRANSFER_FAILED_TOPIC,
|
||
})
|
||
|
||
|
||
def durable_event_topic(event_type: EventType) -> str:
|
||
"""返回 durable-required 事件唯一登记的 outbox topic。"""
|
||
try:
|
||
return DURABLE_EVENT_TOPICS[event_type]
|
||
except KeyError as error:
|
||
raise ValueError(f"事件 {event_type.name} 未登记 durable topic") from error
|
||
|
||
|
||
@dataclass(frozen=True, slots=True)
|
||
class OutboxIntent:
|
||
"""与业务事务一起暂存的版本化副作用意图。"""
|
||
|
||
event_key: str
|
||
topic: str
|
||
payload: dict[str, Any]
|
||
payload_version: int = 1
|
||
|
||
|
||
@dataclass(frozen=True, slots=True)
|
||
class ClaimedOutboxMessage:
|
||
"""dispatcher 已获得 lease 的稳定消息投影。"""
|
||
|
||
message_id: int
|
||
event_key: str
|
||
topic: str
|
||
payload: dict[str, Any]
|
||
payload_version: int
|
||
attempt: int
|
||
|
||
|
||
def validate_durable_event_handlers(
|
||
handlers: Mapping[str, Callable[[ClaimedOutboxMessage], None]],
|
||
) -> None:
|
||
"""拒绝缺少任一 durable-required 事件恢复 handler 的 dispatcher。"""
|
||
missing = set(DURABLE_EVENT_TOPICS.values()) - set(handlers)
|
||
if missing:
|
||
raise RuntimeError(
|
||
"Outbox dispatcher 缺少 durable 事件 handler: "
|
||
+ ", ".join(sorted(missing))
|
||
)
|
||
|
||
|
||
class OutboxRepository(Protocol):
|
||
"""outbox 写入、claim 和终态更新所需的最小端口。"""
|
||
|
||
def stage(self, intent: OutboxIntent, now: datetime) -> None:
|
||
"""在调用方当前事务中暂存意图,不自行提交。"""
|
||
|
||
def claim(self, now: datetime, lease_until: datetime) -> ClaimedOutboxMessage | None:
|
||
"""原子认领一条到期消息。"""
|
||
|
||
def complete(self, message_id: int, completed_at: datetime) -> None:
|
||
"""按消息 ID 标记完成。"""
|
||
|
||
def retry(
|
||
self,
|
||
message_id: int,
|
||
*,
|
||
next_retry_at: datetime,
|
||
last_error: str,
|
||
dead: bool,
|
||
) -> None:
|
||
"""记录有限退避或 dead-letter 终态。"""
|
||
|
||
|
||
class AsyncOutboxTransaction(Protocol):
|
||
"""异步业务事务暂存并收口 durable intent 的最小端口。"""
|
||
|
||
async def stage(self, intent: OutboxIntent, now: datetime) -> None:
|
||
"""把 intent 加入调用方当前事务,但不自行提交。"""
|
||
|
||
async def complete_by_event_key(
|
||
self,
|
||
event_key: str,
|
||
completed_at: datetime,
|
||
) -> None:
|
||
"""即时投递成功后按稳定幂等键标记 intent 完成。"""
|
||
|
||
|
||
class SyncUnitOfWork(Protocol):
|
||
"""同步 durable 业务切片的最小事务端口。"""
|
||
|
||
def commit(self) -> None:
|
||
"""提交业务写入与 outbox intent。"""
|
||
|
||
def rollback(self) -> None:
|
||
"""回滚业务写入与 outbox intent。"""
|
||
|
||
|
||
class SyncOutboxTransaction(Protocol):
|
||
"""同步业务事务暂存并收口 durable intent 的最小端口。"""
|
||
|
||
def stage(self, intent: OutboxIntent, now: datetime) -> None:
|
||
"""把 intent 加入调用方事务,但不自行提交。"""
|
||
|
||
def claim_by_event_key(
|
||
self,
|
||
event_key: str,
|
||
now: datetime,
|
||
lease_until: datetime,
|
||
) -> bool:
|
||
"""在同步副作用前原子认领 intent,已被其他投递者持有时返回 False。"""
|
||
|
||
def complete_by_event_key(
|
||
self,
|
||
event_key: str,
|
||
completed_at: datetime,
|
||
) -> None:
|
||
"""即时投递成功后按幂等键标记 intent 完成。"""
|
||
|
||
|
||
class DurableEventCommand:
|
||
"""把一次同步业务写入与可恢复事件 intent 原子提交。"""
|
||
|
||
def __init__(
|
||
self,
|
||
unit_of_work: SyncUnitOfWork,
|
||
outbox: SyncOutboxTransaction,
|
||
) -> None:
|
||
"""注入共享同一 Session 的事务与 outbox 端口。"""
|
||
self._unit_of_work = unit_of_work
|
||
self._outbox = outbox
|
||
|
||
def execute(
|
||
self,
|
||
*,
|
||
intent: OutboxIntent | Callable[[T], OutboxIntent],
|
||
stage_business: Callable[[], T],
|
||
publish: Callable[[], None],
|
||
after_commit: Callable[[], None] | None = None,
|
||
) -> T:
|
||
"""先原子提交业务与 intent,再保持原顺序执行提交后动作和即时广播。"""
|
||
try:
|
||
result = stage_business()
|
||
resolved_intent = intent(result) if callable(intent) else intent
|
||
self._outbox.stage(resolved_intent, datetime.now(timezone.utc))
|
||
self._unit_of_work.commit()
|
||
except Exception:
|
||
self._unit_of_work.rollback()
|
||
raise
|
||
|
||
if after_commit:
|
||
after_commit()
|
||
publish()
|
||
self._outbox.complete_by_event_key(
|
||
resolved_intent.event_key,
|
||
datetime.now(timezone.utc),
|
||
)
|
||
return result
|
||
|
||
|
||
class OutboxDispatcher:
|
||
"""认领并派发 outbox,按 event key 依赖 handler 幂等。"""
|
||
|
||
def __init__(
|
||
self,
|
||
repository: OutboxRepository,
|
||
handlers: dict[str, Callable[[ClaimedOutboxMessage], None]],
|
||
*,
|
||
max_attempts: int = 5,
|
||
lease_seconds: int = OUTBOX_LEASE_SECONDS,
|
||
clock: Callable[[], datetime] | None = None,
|
||
close: Callable[[], None] | None = None,
|
||
failure_observer: Callable[[bool], None] | None = None,
|
||
) -> None:
|
||
"""注入持久端口、topic handler、有界重试策略与失败观测端口。"""
|
||
self._repository = repository
|
||
self._handlers = handlers
|
||
self._max_attempts = max_attempts
|
||
self._lease_seconds = lease_seconds
|
||
self._clock = clock or (lambda: datetime.now(timezone.utc))
|
||
self._close = close or (lambda: None)
|
||
self._failure_observer = failure_observer or (lambda _dead: None)
|
||
|
||
def dispatch_one(self) -> bool:
|
||
"""处理一条到期消息;无消息返回 False,handler 失败留待重试。"""
|
||
now = self._clock()
|
||
message = self._repository.claim(
|
||
now,
|
||
now + timedelta(seconds=self._lease_seconds),
|
||
)
|
||
if message is None:
|
||
return False
|
||
try:
|
||
handler = self._handlers[message.topic]
|
||
handler(message)
|
||
except Exception as error:
|
||
dead = message.attempt >= self._max_attempts
|
||
delay = min(3600, 2 ** max(0, message.attempt - 1))
|
||
self._repository.retry(
|
||
message.message_id,
|
||
next_retry_at=now + timedelta(seconds=delay),
|
||
last_error=str(error)[:4000],
|
||
dead=dead,
|
||
)
|
||
self._failure_observer(dead)
|
||
return True
|
||
self._repository.complete(message.message_id, now)
|
||
return True
|
||
|
||
def close(self) -> None:
|
||
"""释放 dispatcher 工厂创建的短生命周期持久化资源。"""
|
||
self._close()
|
||
|
||
|
||
_configured_dispatcher: Callable[[], OutboxDispatcher] | None = None
|
||
|
||
|
||
def configure_outbox_dispatcher(provider: Callable[[], OutboxDispatcher]) -> None:
|
||
"""由组合根登记短生命周期 dispatcher 工厂。"""
|
||
global _configured_dispatcher
|
||
_configured_dispatcher = provider
|
||
|
||
|
||
def dispatch_pending_outbox(limit: int = 20) -> int:
|
||
"""恢复有限数量到期 intent,供 Scheduler 与启动补偿复用。"""
|
||
if _configured_dispatcher is None:
|
||
raise RuntimeError("Outbox dispatcher 尚未配置")
|
||
dispatcher = _configured_dispatcher()
|
||
try:
|
||
processed = 0
|
||
while processed < limit and dispatcher.dispatch_one():
|
||
processed += 1
|
||
return processed
|
||
finally:
|
||
dispatcher.close()
|