ForcePilot/backend/package/yuxi/channel/outbound/dispatcher.py
Kris bab30f2715
Some checks failed
Deploy VitePress site to Pages / build (push) Has been cancelled
Ruff Format Check / Ruff Format & Lint (push) Has been cancelled
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
feat:0715
2026-07-15 12:30:58 +08:00

562 lines
23 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""渠道出站消息分发器(仅运行在 API 容器)。"""
import asyncio
import json
import os
import random
import socket
import time
from datetime import UTC, datetime, timedelta
from redis.exceptions import ResponseError
from sqlalchemy import select
from yuxi.channel.config import ChannelConfigManager
from yuxi.channel.constants import (
CHANNEL_BACKOFF_BASE_SECONDS,
CHANNEL_BACKOFF_MAX_SECONDS,
CHANNEL_CONSUMER_GROUP,
CHANNEL_DELIVERED_TTL_SECONDS,
CHANNEL_MAX_DELIVERY_ATTEMPTS,
CHANNEL_PENDING_CLAIM_MIN_IDLE_MS,
CHANNEL_PROCESSING_LOCK_RENEW_INTERVAL_SECONDS,
CHANNEL_PROCESSING_LOCK_TTL_SECONDS,
CHANNEL_STREAM_KEY,
DeliveryStatus,
DispatchResult,
channel_delivered_key,
channel_processing_key,
)
from yuxi.channel.exceptions import (
ChannelPermanentError,
ChannelRateLimitedError,
ChannelRetryableError,
)
from yuxi.channel.metrics import (
channel_delivery_duration_seconds,
channel_messages_delivered_total,
channel_messages_failed_total,
)
from yuxi.channel.middlewares.protocols import OutboundContext, OutboundResult
from yuxi.channel.middlewares.registry import OutboundMiddlewareRegistry
from yuxi.channel.outbound.lock import RedisLockRenewer
from yuxi.channel.plugins.registry import ChannelRegistry, get_registry
from yuxi.channel.ports import OutboundPort
from yuxi.repositories.conversation_repository import ConversationRepository
from yuxi.repositories.message_repository import MessageRepository
from yuxi.services.run_queue_service import get_redis_client
from yuxi.storage.postgres.manager import pg_manager
from yuxi.storage.postgres.model_channel import ChannelSession
from yuxi.utils.logging_config import logger
class OutboundDispatcher:
"""消费 Redis Stream 中的渠道出站事件,调用插件完成投递。"""
MAX_ATTEMPTS = CHANNEL_MAX_DELIVERY_ATTEMPTS
PROCESSING_LOCK_TTL = CHANNEL_PROCESSING_LOCK_TTL_SECONDS
LOCK_RENEW_INTERVAL = CHANNEL_PROCESSING_LOCK_RENEW_INTERVAL_SECONDS
def __init__(
self,
registry: ChannelRegistry | None = None,
config_manager: ChannelConfigManager | None = None,
outbound_registry: OutboundMiddlewareRegistry | None = None,
) -> None:
self.registry = registry or get_registry()
self.config = config_manager or ChannelConfigManager()
self._outbound_registry = outbound_registry or OutboundMiddlewareRegistry()
self._consumer_name: str | None = None
self._task: asyncio.Task | None = None
@property
def CONSUMER_NAME(self) -> str:
"""惰性计算消费者名称,确保在运行时获取正确的 PID。"""
if self._consumer_name is None:
self._consumer_name = f"{socket.gethostname()}-{os.getpid()}"
return self._consumer_name
async def start(self) -> None:
redis = await get_redis_client()
try:
await redis.xgroup_create(
CHANNEL_STREAM_KEY,
CHANNEL_CONSUMER_GROUP,
id="0",
mkstream=True,
)
except ResponseError as e:
if "already exists" not in str(e).lower():
raise
await self._outbound_registry.start_all()
self._task = asyncio.create_task(self._consume_loop())
logger.info("OutboundDispatcher started (consumer=%s)", self.CONSUMER_NAME)
async def stop(self) -> None:
if self._task:
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
self._task = None
await self._outbound_registry.stop_all()
logger.info("OutboundDispatcher stopped")
def invalidate(self, channel_type: str, account_id: str) -> None:
"""清除指定渠道账户的出站中间件缓存。"""
self._outbound_registry.invalidate(channel_type, account_id)
async def _consume_loop(self) -> None:
redis = await get_redis_client()
while True:
try:
# 1. 认领死消费者的 pending 消息
await self._claim_stale_pending(redis)
# 2. 读取本消费者尚未 ACK 的 pending 消息
pending = await redis.xreadgroup(
groupname=CHANNEL_CONSUMER_GROUP,
consumername=self.CONSUMER_NAME,
streams={CHANNEL_STREAM_KEY: "0"},
count=10,
block=5000,
)
for _stream, entries in pending:
for entry_id, fields in entries:
await self._handle_event(entry_id, fields)
# 3. 读取新消息
new = await redis.xreadgroup(
groupname=CHANNEL_CONSUMER_GROUP,
consumername=self.CONSUMER_NAME,
streams={CHANNEL_STREAM_KEY: ">"},
count=10,
block=5000,
)
for _stream, entries in new:
for entry_id, fields in entries:
await self._handle_event(entry_id, fields)
except asyncio.CancelledError:
break
except Exception:
logger.exception("OutboundDispatcher consume loop error")
await asyncio.sleep(1)
async def _claim_stale_pending(self, redis) -> None:
"""认领超过空闲阈值、原消费者可能已死亡的 pending 消息。"""
try:
cursor = "0-0"
while True:
result = await redis.xautoclaim(
CHANNEL_STREAM_KEY,
CHANNEL_CONSUMER_GROUP,
self.CONSUMER_NAME,
min_idle_time=CHANNEL_PENDING_CLAIM_MIN_IDLE_MS,
start_id=cursor,
count=10,
justid=False,
)
# redis-py 返回 [next_cursor, [(id, fields), ...], [deleted_ids...]]
next_cursor = result[0] if result else "0-0"
claimed = result[1] if len(result) > 1 else []
for entry_id, fields in claimed:
await self._handle_event(entry_id, fields)
if not next_cursor or next_cursor == "0-0":
break
cursor = next_cursor
except ResponseError as e:
logger.warning("XAUTOCLAIM failed: %s", e)
except Exception:
logger.exception("Failed to claim stale pending messages")
async def _handle_event(self, entry_id: str, fields: dict) -> None:
payload_raw = fields.get("payload", "{}")
try:
event = json.loads(payload_raw)
except json.JSONDecodeError:
logger.warning("Skip malformed channel outbound event: %s", payload_raw)
# 格式错误无法重试,直接 ACK 避免永久堆积
await self._ack(entry_id)
return
message_id = event.get("message_id")
if not message_id:
logger.warning("Skip channel outbound event without message_id")
await self._ack(entry_id)
return
redis = await get_redis_client()
delivered_key = channel_delivered_key(message_id)
processing_key = channel_processing_key(message_id)
# 已投递则跳过
if await redis.exists(delivered_key):
await self._ack(entry_id)
return
# 检查是否未到重试时间
next_retry_at = await self._get_next_retry_at(message_id)
if next_retry_at and datetime.now(UTC) < next_retry_at:
logger.debug(
"Message %s retry deferred until %s",
message_id,
next_retry_at.isoformat(),
)
return
# 获取处理锁owner 信息用于崩溃后识别与排查
lock_value = json.dumps(
{
"owner": self.CONSUMER_NAME,
"started_at": datetime.now(UTC).isoformat(),
"entry_id": entry_id,
},
ensure_ascii=False,
)
lock_acquired = await redis.set(processing_key, lock_value, nx=True, ex=self.PROCESSING_LOCK_TTL)
if not lock_acquired:
lock_acquired = await self._try_reclaim_lock(redis, processing_key, lock_value)
if not lock_acquired:
logger.debug(
"Message %s is being processed by another consumer, skip",
message_id,
)
return
async with RedisLockRenewer(
redis, processing_key, ttl=self.PROCESSING_LOCK_TTL, interval=self.LOCK_RENEW_INTERVAL
):
await self._process_event(entry_id, event, message_id, redis, delivered_key, processing_key)
async def _try_reclaim_lock(self, redis, lock_key: str, lock_value: str) -> bool:
"""若当前锁属于本消费者(崩溃重启场景),则重新占有。"""
try:
current = await redis.get(lock_key)
if not current:
return bool(await redis.set(lock_key, lock_value, nx=True, ex=self.PROCESSING_LOCK_TTL))
data = json.loads(current)
if data.get("owner") == self.CONSUMER_NAME:
await redis.set(lock_key, lock_value, xx=True, ex=self.PROCESSING_LOCK_TTL)
return True
except Exception:
logger.warning("Failed to reclaim processing lock %s", lock_key)
return False
async def _process_event(
self,
entry_id: str,
event: dict,
message_id: int,
redis,
delivered_key: str,
processing_key: str,
) -> None:
dispatch_status = DispatchResult.RETRYABLE
retry_after: int | None = None
try:
async with pg_manager.get_async_session_context() as session:
msg_repo = MessageRepository(session)
message = await msg_repo.get(message_id)
if message is None:
logger.warning("Message %s not found, acking event", message_id)
await self._ack(entry_id)
await redis.delete(processing_key)
return
# 增加尝试次数
attempts = (message.channel_metadata or {}).get("attempts", 0) + 1
message.update_channel_metadata({"attempts": attempts})
await session.commit()
if attempts > self.MAX_ATTEMPTS:
logger.warning(
"Message %s exceeded max delivery attempts (%s), marking failed",
message_id,
self.MAX_ATTEMPTS,
)
await self._finalize_failure(entry_id, message_id, redis, delivered_key, processing_key)
return
dispatch_status, retry_after = await self._do_dispatch(event, message, session)
except ChannelPermanentError:
logger.exception("Permanent error dispatching channel message %s", message_id)
dispatch_status = DispatchResult.PERMANENT_FAILURE
except ChannelRateLimitedError as e:
logger.exception("Rate limited dispatching channel message %s", message_id)
dispatch_status = DispatchResult.RETRYABLE
retry_after = e.retry_after
except ChannelRetryableError:
logger.exception("Retryable error dispatching channel message %s", message_id)
dispatch_status = DispatchResult.RETRYABLE
except Exception:
logger.exception("Failed to dispatch channel message %s", message_id)
dispatch_status = DispatchResult.RETRYABLE
if dispatch_status == DispatchResult.SUCCESS:
await redis.setex(delivered_key, CHANNEL_DELIVERED_TTL_SECONDS, "1")
await self._ack(entry_id)
await redis.delete(processing_key)
elif dispatch_status == DispatchResult.PERMANENT_FAILURE:
await self._finalize_failure(
entry_id,
message_id,
redis,
delivered_key,
processing_key,
)
else:
# 可重试失败:保留 pending更新 next_retry_at释放 processing 锁
await self._defer_retry(message_id, retry_after_seconds=retry_after)
await redis.delete(processing_key)
async def _finalize_failure(
self,
entry_id: str,
message_id: int,
redis,
delivered_key: str,
processing_key: str,
) -> None:
"""永久失败或超限ACK + 设置 delivered + 删锁。
对 partial_failed 设置 delivered 是为了避免已发出 chunk 被重复投递;
对其他永久失败设置 delivered 是为了避免无意义重试。
"""
try:
async with pg_manager.get_async_session_context() as session:
msg_repo = MessageRepository(session)
message = await msg_repo.get(message_id)
if message is not None:
chunks = (message.channel_metadata or {}).get("chunks") or []
has_success = any(isinstance(cs, dict) and cs.get("status") == "success" for cs in chunks)
has_failure = any(isinstance(cs, dict) and cs.get("status") == "failed" for cs in chunks)
if has_success and has_failure:
message.delivery_status = DeliveryStatus.PARTIAL_FAILED
else:
message.delivery_status = DeliveryStatus.FAILED
await session.commit()
except Exception:
logger.exception("Failed to update delivery_status for message %s", message_id)
await redis.setex(delivered_key, CHANNEL_DELIVERED_TTL_SECONDS, "1")
await self._ack(entry_id)
await redis.delete(processing_key)
async def _defer_retry(self, message_id: int, retry_after_seconds: int | None = None) -> None:
"""可重试失败时,在 channel_metadata 写入下次重试时间。"""
try:
async with pg_manager.get_async_session_context() as session:
msg_repo = MessageRepository(session)
message = await msg_repo.get(message_id)
if message is None:
return
attempts = (message.channel_metadata or {}).get("attempts", 1)
if retry_after_seconds is not None:
delay = retry_after_seconds
else:
delay = min(
CHANNEL_BACKOFF_BASE_SECONDS * (2**attempts),
CHANNEL_BACKOFF_MAX_SECONDS,
)
delay = delay + random.randint(0, delay // 2)
next_retry_at = datetime.now(UTC) + timedelta(seconds=delay)
message.update_channel_metadata({"next_retry_at": next_retry_at.isoformat()})
await session.commit()
logger.info(
"Channel message %s deferred to %s (attempt=%s, delay=%ss)",
message_id,
next_retry_at.isoformat(),
attempts,
delay,
)
except Exception:
logger.exception("Failed to record retry deferral for message %s", message_id)
async def _get_next_retry_at(self, message_id: int) -> datetime | None:
try:
async with pg_manager.get_async_session_context() as session:
msg_repo = MessageRepository(session)
message = await msg_repo.get(message_id)
if message is None:
return None
channel_metadata = message.channel_metadata or {}
raw = channel_metadata.get("next_retry_at")
if not raw:
return None
return datetime.fromisoformat(raw)
except Exception:
logger.warning("Failed to parse next_retry_at for message %s", message_id)
return None
async def _ack(self, entry_id: str) -> None:
try:
redis = await get_redis_client()
await redis.xack(CHANNEL_STREAM_KEY, CHANNEL_CONSUMER_GROUP, entry_id)
except Exception:
logger.warning("Failed to ack stream entry %s", entry_id)
async def _do_dispatch(
self,
event: dict,
message,
session,
) -> tuple[str, int | None]:
"""执行实际投递,返回 (success/permanent_failure/retryable, retry_after_seconds)。"""
labels = {
"channel_type": event["channel_type"],
"account_id": event["account_id"],
}
start_time = time.perf_counter()
conv_repo = ConversationRepository(session)
conversation = await conv_repo.get_conversation_by_id(event["conversation_id"])
if not conversation or not message:
logger.warning(
"Conversation or message not found for message %s (conversation_id=%s)",
message.id,
event.get("conversation_id"),
)
return DispatchResult.PERMANENT_FAILURE, None
result = await session.execute(select(ChannelSession).where(ChannelSession.session_key == event["session_key"]))
channel_session = result.scalar_one_or_none()
if channel_session is None:
logger.warning("ChannelSession not found for session_key=%s", event["session_key"])
plugin = self.registry.get_plugin(event["channel_type"])
if plugin is None:
logger.warning("No plugin registered for channel_type=%s", event["channel_type"])
await self._update_message_status(
session,
message,
status=DeliveryStatus.FAILED,
sent_ids=[],
chunk_statuses=[{"index": 0, "status": "failed", "error": "plugin not found"}],
)
return DispatchResult.PERMANENT_FAILURE, None
if not isinstance(plugin, OutboundPort):
logger.warning(
"Plugin for %s does not implement OutboundPort",
event["channel_type"],
)
await self._update_message_status(
session,
message,
status=DeliveryStatus.FAILED,
sent_ids=[],
chunk_statuses=[{"index": 0, "status": "failed", "error": "plugin does not implement OutboundPort"}],
)
return DispatchResult.PERMANENT_FAILURE, None
try:
config = await self.config.get_config(event["channel_type"], event["account_id"])
except Exception as e:
logger.exception(
"Failed to load channel config for %s/%s",
event["channel_type"],
event["account_id"],
)
await self._update_message_status(
session,
message,
status=DeliveryStatus.FAILED,
sent_ids=[],
chunk_statuses=[{"index": 0, "status": "failed", "error": str(e)}],
)
duration = time.perf_counter() - start_time
channel_delivery_duration_seconds.observe(labels, duration)
channel_messages_failed_total.inc(labels)
logger.bind(
event="channel_delivery_failed",
channel_type=event["channel_type"],
account_id=event["account_id"],
session_key=event["session_key"],
message_id=message.id,
duration_ms=int(duration * 1000),
status="failed",
error=str(e),
).error("Channel outbound delivery failed: config load error")
return DispatchResult.PERMANENT_FAILURE, None
capabilities = plugin.get_delivery_capabilities()
ctx = OutboundContext(
event=event,
message=None,
db_message=message,
config=config,
config_mw={},
plugin=plugin,
channel_session=channel_session,
conversation=conversation,
db=session,
capabilities=capabilities,
labels=labels,
start_time=start_time,
)
ctx.update_status = self._finalize_dispatch
chain = self._outbound_registry.resolve_chain(config)
try:
result = await self._execute_chain(ctx, chain)
return result.status, result.retry_after
except Exception:
logger.exception("Outbound middleware chain failed for message %s", message.id)
return DispatchResult.RETRYABLE, None
async def _execute_chain(
self,
ctx: OutboundContext,
chain: list,
) -> OutboundResult:
"""按顺序执行出站中间件链。"""
async def _next(idx: int) -> OutboundResult:
if idx >= len(chain):
return OutboundResult(status=ctx.dispatch_result, retry_after=ctx.retry_after)
mw = chain[idx]
ctx.config_mw = self._outbound_registry.get_middleware_config(ctx.config, mw.name)
return await mw.process(ctx, lambda: _next(idx + 1))
return await _next(0)
async def _finalize_dispatch(self, ctx: OutboundContext, status: str) -> None:
"""由 StatusUpdateMiddleware 回调,用于提交状态、指标与日志。"""
await self._update_message_status(
ctx.db,
ctx.db_message,
status,
ctx.sent_ids,
ctx.chunk_statuses,
)
duration = time.perf_counter() - ctx.start_time
channel_delivery_duration_seconds.observe(ctx.labels, duration)
if status == DeliveryStatus.COMPLETE:
channel_messages_delivered_total.inc(ctx.labels)
else:
channel_messages_failed_total.inc(ctx.labels)
log_event = "channel_message_delivered" if status == DeliveryStatus.COMPLETE else "channel_delivery_failed"
logger.bind(
event=log_event,
channel_type=ctx.event.get("channel_type"),
account_id=ctx.event.get("account_id"),
session_key=ctx.event.get("session_key"),
message_id=ctx.db_message.id,
duration_ms=int(duration * 1000),
status=status,
).info("Channel outbound message %s", status)
async def _update_message_status(
self,
session,
message,
status: DeliveryStatus,
sent_ids: list[str | None],
chunk_statuses: list[dict],
) -> None:
message.delivery_status = status
message.channel_message_id = next((sid for sid in sent_ids if sid), None)
message.update_channel_metadata({"sent_ids": sent_ids, "chunks": chunk_statuses})
await session.commit()