from __future__ import annotations import asyncio import logging from yuxi.channel.domain.model.message.dispatch_result import SendResult from yuxi.channel.domain.port import CachePort 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], cache_port: CachePort | None = None, *, metrics: MetricsPort | None = None, poll_interval: float = 5.0, ) -> None: self._outbox = outbox_repo self._adapters = adapters self._cache = cache_port 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 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 _process_pending(self) -> None: entries = await self._outbox.fetch_and_mark_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: retry_result = await self._outbox.mark_retrying(entry.id, last_error=result.error) if retry_result and retry_result.status == "dead": if self._metrics: await self._metrics.record_outbox_retry_total(entry.channel_type, "dead") elif self._metrics: await self._metrics.record_outbox_retry_total(entry.channel_type, "retrying")