ForcePilot/backend/package/yuxi/channel/worker/outbox_retry.py
Kris 9e503becd3
Some checks failed
Deploy VitePress site to Pages / build (push) Has been cancelled
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
feat(plugin): 实现完整的插件注册管理系统
新增了插件相关的完整领域模型、应用服务、基础设施实现,包括:
1. 插件状态、注册模式、来源等基础枚举和数据结构
2. 插件清单解析、发现、加载工具类
3. 插件注册表领域服务和内存存储实现
4. 插件相关的命令、查询、事件定义
5. 插件REST API接口和DTO映射
6. 集成了原有通道适配器到插件系统
7. 新增内置插件注册和自动发现能力
2026-05-31 16:44:13 +08:00

131 lines
4.6 KiB
Python

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.model.outbox.outbox_status import OutboxStatus
from yuxi.channel.domain.model.plugin_registry.plugin_registry import PluginRegistry
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,
registry: PluginRegistry | None = None,
) -> None:
self._outbox = outbox_repo
self._adapters = adapters
self._registry = registry
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_and_lock()
for entry in entries:
adapter = (
self._registry.get_adapter(entry.channel_type)
if self._registry
else 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:
updated = await self._outbox.mark_retrying(entry.id, last_error=result.error)
if updated and updated.status == OutboxStatus.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")