mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-04 23:17:20 +08:00
fix(outbox): serialize subscription completion delivery
This commit is contained in:
@@ -20,6 +20,7 @@ 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,
|
||||
@@ -126,6 +127,14 @@ class SyncOutboxTransaction(Protocol):
|
||||
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,
|
||||
@@ -183,7 +192,7 @@ class OutboxDispatcher:
|
||||
handlers: dict[str, Callable[[ClaimedOutboxMessage], None]],
|
||||
*,
|
||||
max_attempts: int = 5,
|
||||
lease_seconds: int = 60,
|
||||
lease_seconds: int = OUTBOX_LEASE_SECONDS,
|
||||
clock: Callable[[], datetime] | None = None,
|
||||
close: Callable[[], None] | None = None,
|
||||
failure_observer: Callable[[bool], None] | None = None,
|
||||
|
||||
@@ -4,10 +4,15 @@ from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Mapping
|
||||
from contextlib import AbstractContextManager
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Protocol
|
||||
|
||||
from app.application.outbox import OutboxIntent, SyncOutboxTransaction, SyncUnitOfWork
|
||||
from app.application.outbox import (
|
||||
OUTBOX_LEASE_SECONDS,
|
||||
OutboxIntent,
|
||||
SyncOutboxTransaction,
|
||||
SyncUnitOfWork,
|
||||
)
|
||||
|
||||
|
||||
class SubscriptionCompletionRepository(Protocol):
|
||||
@@ -101,19 +106,35 @@ class CompleteSubscriptionCommand:
|
||||
self._unit_of_work.rollback()
|
||||
raise
|
||||
|
||||
notify()
|
||||
if self._outbox and notification:
|
||||
self._outbox.complete_by_event_key(
|
||||
notification_key,
|
||||
datetime.now(timezone.utc),
|
||||
)
|
||||
self._publish(event_payload)
|
||||
if notification:
|
||||
if self._claim_sync_delivery(notification_key):
|
||||
notify()
|
||||
self._complete_sync_delivery(notification_key)
|
||||
else:
|
||||
notify()
|
||||
if self._claim_sync_delivery(event_key):
|
||||
self._publish(event_payload)
|
||||
self._complete_sync_delivery(event_key)
|
||||
if self._claim_sync_delivery(report_key):
|
||||
if report(report_payload["subscribe_info"]) is False:
|
||||
raise RuntimeError("订阅完成统计上报未确认")
|
||||
self._complete_sync_delivery(report_key)
|
||||
|
||||
def _claim_sync_delivery(self, event_key: str) -> bool:
|
||||
"""在同步副作用前取得 lease,已由恢复投递接管时跳过直投。"""
|
||||
if self._outbox is None:
|
||||
return True
|
||||
now = datetime.now(timezone.utc)
|
||||
return self._outbox.claim_by_event_key(
|
||||
event_key,
|
||||
now,
|
||||
now + timedelta(seconds=OUTBOX_LEASE_SECONDS),
|
||||
)
|
||||
|
||||
def _complete_sync_delivery(self, event_key: str) -> None:
|
||||
"""收口当前同步投递持有的 durable intent。"""
|
||||
if self._outbox:
|
||||
self._outbox.complete_by_event_key(event_key, datetime.now(timezone.utc))
|
||||
if report(report_payload["subscribe_info"]) is False:
|
||||
raise RuntimeError("订阅完成统计上报未确认")
|
||||
if self._outbox:
|
||||
self._outbox.complete_by_event_key(report_key, datetime.now(timezone.utc))
|
||||
|
||||
|
||||
def completion_event_key(subscribe_id: int, subscribe_info: Mapping[str, Any]) -> str:
|
||||
|
||||
@@ -91,6 +91,52 @@ class SqlAlchemyOutboxRepository:
|
||||
attempt=next_attempt,
|
||||
)
|
||||
|
||||
def claim_by_event_key(
|
||||
self,
|
||||
event_key: str,
|
||||
now: datetime,
|
||||
lease_until: datetime,
|
||||
) -> bool:
|
||||
"""按事件键原子认领同步投递,避免与 dispatcher 并发重复发送。"""
|
||||
now_text = _iso(now)
|
||||
candidate = self._session.execute(
|
||||
select(OutboxMessage)
|
||||
.where(
|
||||
OutboxMessage.event_key == event_key,
|
||||
OutboxMessage.status.in_(("pending", "processing")),
|
||||
OutboxMessage.next_retry_at <= now_text,
|
||||
or_(
|
||||
OutboxMessage.lease_until.is_(None),
|
||||
OutboxMessage.lease_until <= now_text,
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
).scalars().first()
|
||||
if candidate is None:
|
||||
return False
|
||||
claimed = execute_dml(
|
||||
self._session,
|
||||
update(OutboxMessage)
|
||||
.where(
|
||||
OutboxMessage.id == candidate.id,
|
||||
OutboxMessage.attempt == candidate.attempt,
|
||||
OutboxMessage.event_key == event_key,
|
||||
OutboxMessage.status.in_(("pending", "processing")),
|
||||
OutboxMessage.next_retry_at <= now_text,
|
||||
or_(
|
||||
OutboxMessage.lease_until.is_(None),
|
||||
OutboxMessage.lease_until <= now_text,
|
||||
),
|
||||
)
|
||||
.values(
|
||||
status="processing",
|
||||
attempt=OutboxMessage.attempt + 1,
|
||||
lease_until=_iso(lease_until),
|
||||
),
|
||||
)
|
||||
self._session.commit()
|
||||
return bool(claimed)
|
||||
|
||||
def complete(self, message_id: int, completed_at: datetime) -> None:
|
||||
"""持久化完成终态并释放 lease。"""
|
||||
self._session.execute(
|
||||
|
||||
Reference in New Issue
Block a user