feat(event): add single-thread event publisher with retry and cleanup
This commit is contained in:
76
channel-service/app/services/event_publisher.py
Normal file
76
channel-service/app/services/event_publisher.py
Normal file
@@ -0,0 +1,76 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
|
||||
from app.clients.mci_server_client import MciServerClient
|
||||
from app.models.event import EventStatus, OutboundEvent
|
||||
from app.repositories.event_store import EventStore
|
||||
|
||||
|
||||
class EventPublisher:
|
||||
def __init__(
|
||||
self,
|
||||
store: EventStore,
|
||||
client: MciServerClient,
|
||||
max_retry: int = 5,
|
||||
base_delay_seconds: int = 5,
|
||||
interval_seconds: float = 5.0,
|
||||
batch_size: int = 100,
|
||||
):
|
||||
self.store = store
|
||||
self.client = client
|
||||
self.max_retry = min(max_retry, 10)
|
||||
self.base_delay_seconds = base_delay_seconds
|
||||
self.interval_seconds = interval_seconds
|
||||
self.batch_size = batch_size
|
||||
self._task: Optional[asyncio.Task] = None
|
||||
self._stop_event = asyncio.Event()
|
||||
|
||||
def start(self) -> None:
|
||||
self._task = asyncio.create_task(self._loop())
|
||||
|
||||
async def stop(self) -> None:
|
||||
self._stop_event.set()
|
||||
if self._task:
|
||||
try:
|
||||
await asyncio.wait_for(self._task, timeout=5.0)
|
||||
except asyncio.TimeoutError:
|
||||
self._task.cancel()
|
||||
|
||||
async def _loop(self) -> None:
|
||||
cleanup_counter = 0
|
||||
cleanup_interval = max(1, int(24 * 3600 / self.interval_seconds))
|
||||
while not self._stop_event.is_set():
|
||||
try:
|
||||
await self._tick()
|
||||
except Exception:
|
||||
pass
|
||||
cleanup_counter += 1
|
||||
if cleanup_counter >= cleanup_interval:
|
||||
try:
|
||||
await self.cleanup_old_events(10)
|
||||
except Exception:
|
||||
pass
|
||||
cleanup_counter = 0
|
||||
try:
|
||||
await asyncio.wait_for(self._stop_event.wait(), timeout=self.interval_seconds)
|
||||
except asyncio.TimeoutError:
|
||||
pass
|
||||
|
||||
async def _tick(self) -> None:
|
||||
events = self.store.fetch_pending(limit=self.batch_size)
|
||||
for event in events:
|
||||
try:
|
||||
await self.client.post_event(event)
|
||||
self.store.mark_delivered(event.event_id)
|
||||
except Exception as e:
|
||||
next_attempt = event.attempt_count + 1
|
||||
delay = self.base_delay_seconds * (2 ** (next_attempt - 1))
|
||||
next_retry_at = datetime.now(timezone.utc).replace(tzinfo=None) + timedelta(seconds=delay)
|
||||
status = EventStatus.DEAD_LETTER if next_attempt >= self.max_retry else EventStatus.RETRYING
|
||||
self.store.mark_retry(event.event_id, status, next_attempt, next_retry_at, str(e)[:500])
|
||||
|
||||
async def cleanup_old_events(self, retention_days: int) -> int:
|
||||
return self.store.cleanup(retention_days)
|
||||
Reference in New Issue
Block a user