mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-11-09 23:30:13 +08:00
454 lines
15 KiB
Python
454 lines
15 KiB
Python
"""持久副作用 outbox 的应用契约与有限重试 dispatcher。"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from collections.abc import Awaitable, Callable, Mapping
|
||
from dataclasses import dataclass
|
||
from datetime import datetime, timedelta, timezone
|
||
from types import MappingProxyType
|
||
from typing import Any, Generic, Optional, Protocol, TypeVar, Union
|
||
|
||
from app.schemas.types import EventType
|
||
|
||
T = TypeVar("T")
|
||
|
||
|
||
SUBSCRIBE_ADDED_TOPIC = "subscribe.added"
|
||
SUBSCRIBE_MODIFIED_TOPIC = "subscribe.modified"
|
||
SUBSCRIBE_DELETED_TOPIC = "subscribe.deleted"
|
||
SUBSCRIBE_COMPLETED_TOPIC = "subscribe.complete"
|
||
DOWNLOAD_ADDED_TOPIC = "download.added"
|
||
DOWNLOAD_NOTIFICATION_TOPIC = "download.added.notification"
|
||
DOWNLOAD_MODULE_TOPIC = "download.added.module"
|
||
DOWNLOAD_SUBTITLE_TOPIC = "download.added.subtitle"
|
||
TRANSFER_COMPLETED_TOPIC = "transfer.completed"
|
||
TRANSFER_FAILED_TOPIC = "transfer.failed"
|
||
SUBTITLE_TRANSFER_COMPLETED_TOPIC = "transfer.subtitle.completed"
|
||
SUBTITLE_TRANSFER_FAILED_TOPIC = "transfer.subtitle.failed"
|
||
AUDIO_TRANSFER_COMPLETED_TOPIC = "transfer.audio.completed"
|
||
AUDIO_TRANSFER_FAILED_TOPIC = "transfer.audio.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.SubscribeComplete: SUBSCRIBE_COMPLETED_TOPIC,
|
||
EventType.DownloadAdded: DOWNLOAD_ADDED_TOPIC,
|
||
EventType.TransferComplete: TRANSFER_COMPLETED_TOPIC,
|
||
EventType.TransferFailed: TRANSFER_FAILED_TOPIC,
|
||
EventType.SubtitleTransferComplete: SUBTITLE_TRANSFER_COMPLETED_TOPIC,
|
||
EventType.SubtitleTransferFailed: SUBTITLE_TRANSFER_FAILED_TOPIC,
|
||
EventType.AudioTransferComplete: AUDIO_TRANSFER_COMPLETED_TOPIC,
|
||
EventType.AudioTransferFailed: AUDIO_TRANSFER_FAILED_TOPIC,
|
||
})
|
||
REQUIRED_OUTBOX_TOPICS = frozenset(DURABLE_EVENT_TOPICS.values()) | {
|
||
DOWNLOAD_NOTIFICATION_TOPIC,
|
||
DOWNLOAD_MODULE_TOPIC,
|
||
DOWNLOAD_SUBTITLE_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 DurableOutboxEffect:
|
||
"""绑定持久 intent 与提交后可选的即时 I/O 外壳。"""
|
||
|
||
intent: OutboxIntent
|
||
deliver: Optional[Callable[[], object]] = None
|
||
|
||
|
||
@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
|
||
|
||
|
||
class OutboxLeaseLostError(RuntimeError):
|
||
"""当前派发 owner 的 attempt 已失效,禁止假报成功或覆盖新 owner。"""
|
||
|
||
|
||
def validate_durable_event_handlers(
|
||
handlers: Mapping[str, Callable[[ClaimedOutboxMessage], None]],
|
||
) -> None:
|
||
"""拒绝缺少 durable 事件或正式具名效果 handler 的 dispatcher。"""
|
||
missing = REQUIRED_OUTBOX_TOPICS - set(handlers)
|
||
if missing:
|
||
raise RuntimeError(
|
||
"Outbox dispatcher 缺少 durable 事件 handler: "
|
||
+ ", ".join(sorted(missing))
|
||
)
|
||
|
||
|
||
class OutboxStager(Protocol):
|
||
"""只在业务事务中暂存 durable intent 的最小端口。"""
|
||
|
||
def stage(self, intent: OutboxIntent, now: datetime) -> None:
|
||
"""在调用方当前事务中暂存意图,不自行提交。"""
|
||
|
||
|
||
class OutboxDispatchStore(Protocol):
|
||
"""使用独立短事务认领和结算 durable intent 的最小端口。"""
|
||
|
||
def claim(self, now: datetime, lease_until: datetime) -> Optional[ClaimedOutboxMessage]:
|
||
"""原子认领一条到期消息。"""
|
||
|
||
def claim_by_event_key(
|
||
self,
|
||
event_key: str,
|
||
now: datetime,
|
||
lease_until: datetime,
|
||
) -> Optional[ClaimedOutboxMessage]:
|
||
"""按稳定事件键原子认领一条到期消息。"""
|
||
|
||
def complete(
|
||
self,
|
||
message_id: int,
|
||
attempt: int,
|
||
completed_at: datetime,
|
||
) -> bool:
|
||
"""仅由当前 attempt 的 owner 标记完成。"""
|
||
|
||
def retry(
|
||
self,
|
||
message_id: int,
|
||
attempt: int,
|
||
*,
|
||
next_retry_at: datetime,
|
||
last_error: str,
|
||
dead: bool,
|
||
) -> bool:
|
||
"""仅由当前 attempt 的 owner 记录退避或 dead-letter。"""
|
||
|
||
|
||
class AsyncOutboxStager(Protocol):
|
||
"""只在异步业务事务中暂存 durable intent 的最小端口。"""
|
||
|
||
async def stage(self, intent: OutboxIntent, now: datetime) -> None:
|
||
"""把 intent 加入调用方当前事务,但不自行提交。"""
|
||
|
||
|
||
class AsyncOutboxDispatchStore(Protocol):
|
||
"""使用独立异步短事务认领和结算 intent 的最小端口。"""
|
||
|
||
async def claim_by_event_key(
|
||
self,
|
||
event_key: str,
|
||
now: datetime,
|
||
lease_until: datetime,
|
||
) -> Optional[ClaimedOutboxMessage]:
|
||
"""按稳定事件键原子认领一条到期消息。"""
|
||
|
||
async def complete(
|
||
self,
|
||
message_id: int,
|
||
attempt: int,
|
||
completed_at: datetime,
|
||
) -> bool:
|
||
"""仅由当前 attempt 的 owner 标记完成。"""
|
||
|
||
async def retry(
|
||
self,
|
||
message_id: int,
|
||
attempt: int,
|
||
*,
|
||
next_retry_at: datetime,
|
||
last_error: str,
|
||
dead: bool,
|
||
) -> bool:
|
||
"""仅由当前 attempt 的 owner 记录退避或 dead-letter。"""
|
||
|
||
|
||
class SyncUnitOfWork(Protocol):
|
||
"""同步 durable 业务切片的最小事务端口。"""
|
||
|
||
def commit(self) -> None:
|
||
"""提交业务写入与 outbox intent。"""
|
||
|
||
def rollback(self) -> None:
|
||
"""回滚业务写入与 outbox intent。"""
|
||
|
||
|
||
@dataclass(frozen=True, slots=True)
|
||
class PostCommitResult(Generic[T]):
|
||
"""区分已提交业务结果与逐项完成或仍待恢复的后置效果。"""
|
||
|
||
value: T
|
||
business_committed: bool
|
||
completed_effects: tuple[str, ...] = ()
|
||
pending_effects: tuple[str, ...] = ()
|
||
|
||
|
||
class PostCommitEffectError(RuntimeError):
|
||
"""业务已提交但至少一个后置效果失败,并携带可检查的完成结果。"""
|
||
|
||
def __init__(self, result: PostCommitResult[Any], errors: tuple[Exception, ...]):
|
||
"""保存结构化完成状态及逐项原始异常。"""
|
||
self.result = result
|
||
self.errors = errors
|
||
super().__init__("提交后的相关处理未完成,系统将自动重试")
|
||
|
||
|
||
def deliver_outbox_effect(
|
||
store: OutboxDispatchStore,
|
||
event_key: str,
|
||
effect: Callable[[], object],
|
||
*,
|
||
clock: Optional[Callable[[], datetime]] = None,
|
||
) -> bool:
|
||
"""先认领再执行同步效果,并用同一 attempt fencing 结算结果。"""
|
||
now = (clock or (lambda: datetime.now(timezone.utc)))()
|
||
claimed = store.claim_by_event_key(
|
||
event_key,
|
||
now,
|
||
now + timedelta(seconds=OUTBOX_LEASE_SECONDS),
|
||
)
|
||
if claimed is None:
|
||
return False
|
||
try:
|
||
confirmed = effect()
|
||
except Exception as error:
|
||
store.retry(
|
||
claimed.message_id,
|
||
claimed.attempt,
|
||
next_retry_at=now,
|
||
last_error=str(error)[:4000],
|
||
dead=False,
|
||
)
|
||
raise
|
||
if confirmed is False:
|
||
store.retry(
|
||
claimed.message_id,
|
||
claimed.attempt,
|
||
next_retry_at=now,
|
||
last_error="副作用未确认",
|
||
dead=False,
|
||
)
|
||
return False
|
||
if not store.complete(claimed.message_id, claimed.attempt, now):
|
||
raise OutboxLeaseLostError("Outbox 完成凭证已失效")
|
||
return True
|
||
|
||
|
||
async def deliver_async_outbox_effect(
|
||
store: AsyncOutboxDispatchStore,
|
||
event_key: str,
|
||
effect: Callable[[], Awaitable[object]],
|
||
*,
|
||
clock: Optional[Callable[[], datetime]] = None,
|
||
) -> bool:
|
||
"""先认领再执行异步效果,并用同一 attempt fencing 结算结果。"""
|
||
now = (clock or (lambda: datetime.now(timezone.utc)))()
|
||
claimed = await store.claim_by_event_key(
|
||
event_key,
|
||
now,
|
||
now + timedelta(seconds=OUTBOX_LEASE_SECONDS),
|
||
)
|
||
if claimed is None:
|
||
return False
|
||
try:
|
||
confirmed = await effect()
|
||
except Exception as error:
|
||
await store.retry(
|
||
claimed.message_id,
|
||
claimed.attempt,
|
||
next_retry_at=now,
|
||
last_error=str(error)[:4000],
|
||
dead=False,
|
||
)
|
||
raise
|
||
if confirmed is False:
|
||
await store.retry(
|
||
claimed.message_id,
|
||
claimed.attempt,
|
||
next_retry_at=now,
|
||
last_error="副作用未确认",
|
||
dead=False,
|
||
)
|
||
return False
|
||
if not await store.complete(claimed.message_id, claimed.attempt, now):
|
||
raise OutboxLeaseLostError("Outbox 完成凭证已失效")
|
||
return True
|
||
|
||
|
||
class DurableEventCommand:
|
||
"""把一次同步业务写入与可恢复事件 intent 原子提交。"""
|
||
|
||
def __init__(
|
||
self,
|
||
unit_of_work: SyncUnitOfWork,
|
||
stager: OutboxStager,
|
||
store: OutboxDispatchStore,
|
||
) -> None:
|
||
"""注入业务事务内 stager 与独立短事务 dispatch store。"""
|
||
self._unit_of_work = unit_of_work
|
||
self._stager = stager
|
||
self._store = store
|
||
|
||
def execute(
|
||
self,
|
||
*,
|
||
effects: Union[
|
||
tuple[DurableOutboxEffect, ...],
|
||
Callable[[T], tuple[DurableOutboxEffect, ...]],
|
||
],
|
||
stage_business: Callable[[], T],
|
||
) -> PostCommitResult[T]:
|
||
"""原子提交业务与多个命名 intent,再认领可即时执行的效果。"""
|
||
resolved_effects: tuple[DurableOutboxEffect, ...] = ()
|
||
try:
|
||
result = stage_business()
|
||
resolved_effects = effects(result) if callable(effects) else effects
|
||
event_keys = [effect.intent.event_key for effect in resolved_effects]
|
||
if len(event_keys) != len(set(event_keys)):
|
||
raise ValueError("同一事务不能暂存重复的 outbox event key")
|
||
now = datetime.now(timezone.utc)
|
||
for effect in resolved_effects:
|
||
self._stager.stage(effect.intent, now)
|
||
self._unit_of_work.commit()
|
||
except Exception:
|
||
self._unit_of_work.rollback()
|
||
raise
|
||
|
||
completed: list[str] = []
|
||
pending = [effect.intent.event_key for effect in resolved_effects]
|
||
errors: list[Exception] = []
|
||
for effect in resolved_effects:
|
||
if effect.deliver is None:
|
||
continue
|
||
try:
|
||
delivered = deliver_outbox_effect(
|
||
self._store,
|
||
effect.intent.event_key,
|
||
effect.deliver,
|
||
)
|
||
if delivered:
|
||
pending.remove(effect.intent.event_key)
|
||
completed.append(effect.intent.event_key)
|
||
except Exception as error:
|
||
errors.append(error)
|
||
execution = PostCommitResult(
|
||
value=result,
|
||
business_committed=True,
|
||
completed_effects=tuple(completed),
|
||
pending_effects=tuple(pending),
|
||
)
|
||
if errors:
|
||
raise PostCommitEffectError(execution, tuple(errors))
|
||
return execution
|
||
|
||
|
||
class OutboxDispatcher:
|
||
"""认领并派发 outbox,按 event key 依赖 handler 幂等。"""
|
||
|
||
def __init__(
|
||
self,
|
||
repository: OutboxDispatchStore,
|
||
handlers: dict[str, Callable[[ClaimedOutboxMessage], None]],
|
||
*,
|
||
max_attempts: int = 5,
|
||
lease_seconds: int = OUTBOX_LEASE_SECONDS,
|
||
clock: Optional[Callable[[], datetime]] = None,
|
||
close: Optional[Callable[[], None]] = None,
|
||
failure_observer: Optional[Callable[[bool], 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)
|
||
if not self._repository.complete(
|
||
message.message_id,
|
||
message.attempt,
|
||
now,
|
||
):
|
||
raise OutboxLeaseLostError("Outbox 完成凭证已失效")
|
||
except OutboxLeaseLostError:
|
||
raise
|
||
except Exception as error:
|
||
dead = message.attempt >= self._max_attempts
|
||
delay = min(3600, 2 ** max(0, message.attempt - 1))
|
||
settled = self._repository.retry(
|
||
message.message_id,
|
||
message.attempt,
|
||
next_retry_at=now + timedelta(seconds=delay),
|
||
last_error=str(error)[:4000],
|
||
dead=dead,
|
||
)
|
||
if settled:
|
||
self._failure_observer(dead)
|
||
return True
|
||
return True
|
||
|
||
def close(self) -> None:
|
||
"""释放 dispatcher 工厂创建的短生命周期持久化资源。"""
|
||
self._close()
|
||
|
||
|
||
_configured_dispatcher: Optional[Callable[[], OutboxDispatcher]] = None
|
||
|
||
|
||
def configure_outbox_dispatcher(provider: Callable[[], OutboxDispatcher]) -> None:
|
||
"""由组合根登记短生命周期 dispatcher 工厂。"""
|
||
global _configured_dispatcher
|
||
_configured_dispatcher = provider
|
||
|
||
|
||
def reset_outbox_dispatcher() -> None:
|
||
"""清除当前 lifespan 的 Outbox dispatcher 工厂。"""
|
||
global _configured_dispatcher
|
||
_configured_dispatcher = None
|
||
|
||
|
||
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()
|