""" 进程内 inbox / outbox 队列(骨架过渡实现)。 本文件职责:回调快写入队、按条认领、message_id 去重;后续可替换为 PostgreSQL 表。 禁止:Redis 全局 Worker 单例锁;禁止在回调线程里等 LLM。 为何先用内存:先打通「收消息→回复」联调;落 PG 迁移不挡本迭代验收。 """ from __future__ import annotations import logging import threading import time import uuid from collections import OrderedDict from dataclasses import dataclass, field from enum import Enum from typing import Any, Optional from agent.channel.wecom.models import InboundMessage logger = logging.getLogger(__name__) class TaskState(str, Enum): PENDING = "pending" CLAIMED = "claimed" DONE = "done" FAILED = "failed" @dataclass class InboxItem: """inbox 一条入站任务。""" id: str message: InboundMessage state: TaskState = TaskState.PENDING claimed_at: float = 0.0 attempts: int = 0 last_error: str = "" @dataclass class OutboxItem: """ outbox 一条出站任务。 touser:必须是入站 sender_id,禁止另换 ID。 发送成功 ≠ 业务成功;此处仅通道送达。 """ id: str touser: str content: str state: TaskState = TaskState.PENDING claimed_at: float = 0.0 attempts: int = 0 last_error: str = "" created_at: float = field(default_factory=time.time) dedupe_key: str = "" class MemoryMessageStore: """ 线程安全的内存 inbox/outbox。 认领:同一条任务同一时刻只被一个消费者持有;超时可回收(默认 120s)。 去重:相同 message_id / dedupe_key 不重复入队。 """ def __init__(self, *, claim_timeout_sec: float = 120.0, dedupe_maxlen: int = 5000) -> None: self._lock = threading.RLock() self._inbox: OrderedDict[str, InboxItem] = OrderedDict() self._outbox: OrderedDict[str, OutboxItem] = OrderedDict() self._seen_msg: OrderedDict[str, float] = OrderedDict() self._seen_out: OrderedDict[str, float] = OrderedDict() self._claim_timeout = claim_timeout_sec self._dedupe_maxlen = dedupe_maxlen self._inbox_event = threading.Event() self._outbox_event = threading.Event() def enqueue_inbound(self, message: InboundMessage) -> tuple[bool, str]: """ 写入 inbox。 返回:(是否新入队, 说明)。无 sender_id 拒绝;message_id 重复则跳过。 副作用:唤醒 inbox 等待线程。 """ if not message.has_sender(): return False, "missing_sender_id" with self._lock: if message.message_id in self._seen_msg: return False, "duplicate_message_id" self._remember(self._seen_msg, message.message_id) item = InboxItem(id=str(uuid.uuid4()), message=message) self._inbox[item.id] = item self._inbox_event.set() logger.info( "inbox 入队 id=%s msg_id=%s sender=%s type=%s", item.id, message.message_id, message.sender_id, message.msg_type, ) return True, item.id def claim_inbound(self) -> Optional[InboxItem]: """认领一条 pending/超时 inbox;无任务返回 None。""" now = time.time() with self._lock: for item in self._inbox.values(): if item.state == TaskState.PENDING or ( item.state == TaskState.CLAIMED and item.claimed_at and now - item.claimed_at > self._claim_timeout ): item.state = TaskState.CLAIMED item.claimed_at = now item.attempts += 1 return item self._inbox_event.clear() return None def complete_inbound(self, item_id: str, *, error: str = "") -> None: """标记 inbox 完成或失败。""" with self._lock: item = self._inbox.get(item_id) if not item: return if error: item.state = TaskState.FAILED item.last_error = error else: item.state = TaskState.DONE def enqueue_outbound( self, *, touser: str, content: str, dedupe_key: str = "", ) -> tuple[bool, str]: """写入 outbox;dedupe_key 重复则跳过。""" if not touser or not content: return False, "invalid_outbox" with self._lock: if dedupe_key and dedupe_key in self._seen_out: return False, "duplicate_outbox" if dedupe_key: self._remember(self._seen_out, dedupe_key) item = OutboxItem( id=str(uuid.uuid4()), touser=touser, content=content, dedupe_key=dedupe_key, ) self._outbox[item.id] = item self._outbox_event.set() logger.info("outbox 入队 id=%s touser=%s", item.id, touser) return True, item.id def claim_outbound(self) -> Optional[OutboxItem]: """认领一条 pending/超时 outbox。""" now = time.time() with self._lock: for item in self._outbox.values(): if item.state == TaskState.PENDING or ( item.state == TaskState.CLAIMED and item.claimed_at and now - item.claimed_at > self._claim_timeout ): item.state = TaskState.CLAIMED item.claimed_at = now item.attempts += 1 return item self._outbox_event.clear() return None def complete_outbound(self, item_id: str, *, error: str = "", permanent: bool = False) -> None: """ 标记 outbox 完成或失败。 permanent=True:不再重试(如 IP 未加白 60020)。 """ with self._lock: item = self._outbox.get(item_id) if not item: return if error: if permanent or item.attempts >= 5: item.state = TaskState.FAILED else: item.state = TaskState.PENDING item.claimed_at = 0.0 self._outbox_event.set() item.last_error = error else: item.state = TaskState.DONE def wait_inbox(self, timeout: float = 1.0) -> None: self._inbox_event.wait(timeout=timeout) def wait_outbox(self, timeout: float = 1.0) -> None: self._outbox_event.wait(timeout=timeout) def stats(self) -> dict[str, Any]: with self._lock: return { "inbox": len(self._inbox), "outbox": len(self._outbox), "seen_msg": len(self._seen_msg), } def _remember(self, store: OrderedDict[str, float], key: str) -> None: store[key] = time.time() while len(store) > self._dedupe_maxlen: store.popitem(last=False) # 进程内单例:HTTP 回调与消费线程共享(同进程)。多进程部署时必须换 PG。 _STORE: Optional[MemoryMessageStore] = None _STORE_LOCK = threading.Lock() def get_message_store() -> MemoryMessageStore: """获取进程内消息账本单例。""" global _STORE with _STORE_LOCK: if _STORE is None: _STORE = MemoryMessageStore() return _STORE