from __future__ import annotations import asyncio import logging import redis.asyncio as aioredis from yuxi.channel.domain.model.message.dispatch_result import SendResult from yuxi.channel.domain.port.channel_adapter_port import ChannelAdapterPort from yuxi.channel.domain.port.metrics_port import MetricsPort from yuxi.channel.domain.repository.outbox_repository import OutboxRepositoryPort logger = logging.getLogger(__name__) _OUTBOX_NOTIFY_CHANNEL = "channel:outbox:notify" class OutboxRetryWorker: def __init__( self, outbox_repo: OutboxRepositoryPort, adapters: dict[str, ChannelAdapterPort], redis: aioredis.Redis | None = None, *, metrics: MetricsPort | None = None, poll_interval: float = 5.0, ) -> None: self._outbox = outbox_repo self._adapters = adapters self._redis = redis self._metrics = metrics self._poll_interval = poll_interval self._running = False self._task: asyncio.Task | None = None self._notify_event: asyncio.Event = asyncio.Event() async def start(self) -> None: self._running = True self._task = asyncio.create_task(self._loop()) logger.info("outbox retry worker started") async def stop(self) -> None: self._running = False self._notify_event.set() if self._task: self._task.cancel() await asyncio.gather(self._task, return_exceptions=True) self._task = None logger.info("outbox retry worker stopped") async def _loop(self) -> None: pubsub_task: asyncio.Task | None = None if self._redis: pubsub_task = asyncio.create_task(self._listen_notifications()) try: while self._running: try: await self._process_pending() except asyncio.CancelledError: break except Exception as exc: logger.error("outbox retry error: %s", exc) try: await asyncio.wait_for(self._notify_event.wait(), timeout=self._poll_interval) self._notify_event.clear() except TimeoutError: pass finally: if pubsub_task: pubsub_task.cancel() try: await asyncio.gather(pubsub_task, return_exceptions=True) except Exception: pass async def _listen_notifications(self) -> None: if not self._redis: return pubsub = self._redis.pubsub() try: await pubsub.subscribe(_OUTBOX_NOTIFY_CHANNEL) async for message in pubsub.listen(): if message["type"] == "message": self._notify_event.set() except asyncio.CancelledError: pass except Exception: logger.warning("outbox pubsub listener failed, falling back to polling") finally: try: await pubsub.unsubscribe(_OUTBOX_NOTIFY_CHANNEL) await pubsub.aclose() except Exception: pass async def _process_pending(self) -> None: entries = await self._outbox.fetch_pending() for entry in entries: adapter = self._adapters.get(entry.channel_type) if not adapter: continue result: SendResult = await adapter.send_message( entry.session_id, entry.content, channel_type=entry.channel_type, metadata={"trace_id": entry.trace_id or ""}, ) if result.success: await self._outbox.mark_sent(entry.id) if self._metrics: await self._metrics.record_outbox_retry_total(entry.channel_type, "success") else: if entry.retry_count + 1 >= entry.max_retries: await self._outbox.mark_dead(entry.id, last_error=result.error or "max_retries_exceeded") if self._metrics: await self._metrics.record_outbox_retry_total(entry.channel_type, "dead") else: await self._outbox.mark_retrying(entry.id, last_error=result.error) if self._metrics: await self._metrics.record_outbox_retry_total(entry.channel_type, "retrying")