379 lines
16 KiB
Python
379 lines
16 KiB
Python
"""RunBroker:pub/sub,把 agent run 产生的 event fan-out 给所有 SSE 订阅者。
|
|
|
|
DESIGN §7 简化(0004 一并)—— 单活 run 形态下 run_id 是冗余的(同 task 同时
|
|
最多 1 个活 run);broker 内部全用 task_id 索引,客户端只需要 task_id 即可
|
|
订阅 / cancel,不再需要先拿 run_id。
|
|
|
|
两个实现,env `ZCBOT_REDIS_URL` 决定(import 时定,进程生命周期不变):
|
|
- **LocalRunBroker**(默认,env 不设):进程内 asyncio queue,零依赖 —— 本地 dev /
|
|
单实例部署用。多进程不共享:蓝绿并存期跨实例订阅/cancel 送达不到(RUN.md B 档边缘)。
|
|
- **RedisRunBroker**(env 设了):event 走 Redis PUB/SUB(`zcbot:ev:<task_id>`),
|
|
done/cancel 走带 TTL 的 key —— 蓝绿双实例跨进程可达,也是稳态双实例分流的前提
|
|
(DESIGN §7.0)。启动 ping 不通 fail-fast(同 sandbox init 纪律);运行中 publish
|
|
失败 log + 丢帧(直播降级,run 不受影响)。
|
|
|
|
公共语义(两实现一致):
|
|
- emit() 从工作线程调(agent.run 在 to_thread 跑);SSE generator await queue.get()。
|
|
- 同一 task 多个订阅者(刷新页面 / 多 tab)— 每个订阅 1 个独立 queue。
|
|
- run 结束 → close(task_id) 给所有订阅者派 done;晚到的订阅者立即收 done 断流
|
|
(local 用 _done set;redis 用 `zcbot:done:<tid>` 60s TTL key —— 主保护是 SSE
|
|
端点先查 DB run_status,key 只兜"查完 DB 到订阅完成之间 run 恰好收尾"的窗口)。
|
|
- 同 task 起新 run → start(task_id) 清 done 标记。
|
|
- cancel:request_cancel 置位,AgentLoop 在 chunk 间 poll is_cancelled() 即退;
|
|
redis 版是 `zcbot:cancel:<tid>` key(GET ~0.1ms/次,localhost 可忽略),跨实例可达。
|
|
- 不持久化 event — messages 已落 PG,刷新走 GET /messages 看历史;订阅建立前
|
|
emit 的帧两个实现都会丢(设计选择)。
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
import os
|
|
import threading
|
|
import time
|
|
from collections import defaultdict
|
|
from typing import Any, Optional
|
|
from uuid import UUID
|
|
|
|
|
|
class LocalRunBroker:
|
|
"""进程内实现(原 RunBroker)。
|
|
|
|
线程模型:
|
|
- bind_loop(loop) 在 FastAPI startup 调一次,记录 asyncio loop 引用。
|
|
- emit() 调用方可能在任意线程;put_nowait 是 thread-unsafe(asyncio.Queue 设计
|
|
前提是单 loop),所以走 call_soon_threadsafe 跨回 loop 线程再 put。
|
|
"""
|
|
|
|
def __init__(self) -> None:
|
|
self._subs: dict[UUID, set[asyncio.Queue]] = defaultdict(set)
|
|
# 已经发完 done 的 task — 后来订阅者直接收到 done,避免无限等
|
|
self._done: set[UUID] = set()
|
|
# cancel signal per-task。AgentLoop 在 BG 线程里 poll is_cancelled() 决定是否退;
|
|
# request_cancel 可在 BG 还没 register 时调用(setdefault),BG 启动后第一次
|
|
# check 即看到。run 完成在 finally 里 clear_cancel 回收。
|
|
self._cancel_flags: dict[UUID, threading.Event] = {}
|
|
self._loop: Optional[asyncio.AbstractEventLoop] = None
|
|
|
|
def bind_loop(self, loop: asyncio.AbstractEventLoop) -> None:
|
|
"""FastAPI startup 调一次。"""
|
|
self._loop = loop
|
|
|
|
async def shutdown(self) -> None:
|
|
"""lifespan finally 调;local 无外部资源,no-op。"""
|
|
|
|
def start(self, task_id: UUID) -> None:
|
|
"""同 task 起新 run 时调:清 _done 标记,让新订阅者能看到流式。
|
|
cancel flag 在 finally 里 clear_cancel 清,这里不动(避免擦掉刚刚 request_cancel 的请求)。
|
|
"""
|
|
self._done.discard(task_id)
|
|
|
|
def subscribe(self, task_id: UUID) -> asyncio.Queue:
|
|
"""订阅 task 当前 run 的 event 流。已 done 的 task 立刻在 queue 放一条 done。
|
|
|
|
调用方:SSE handler(在 asyncio loop 线程内)。
|
|
"""
|
|
q: asyncio.Queue = asyncio.Queue()
|
|
if task_id in self._done:
|
|
q.put_nowait({"type": "done"})
|
|
else:
|
|
self._subs[task_id].add(q)
|
|
return q
|
|
|
|
def unsubscribe(self, task_id: UUID, q: asyncio.Queue) -> None:
|
|
"""SSE generator finally 清理。"""
|
|
self._subs.get(task_id, set()).discard(q)
|
|
if task_id in self._subs and not self._subs[task_id]:
|
|
del self._subs[task_id]
|
|
|
|
def emit(self, task_id: UUID, event: dict[str, Any]) -> None:
|
|
"""从工作线程调:把 event 推给所有订阅者。
|
|
|
|
如果没人订阅(run 在跑但没浏览器连上),event 丢弃 — 这是设计选择
|
|
(event 不持久化,messages 走 PG)。
|
|
"""
|
|
loop = self._loop
|
|
if loop is None:
|
|
return # 还没 bind,丢弃(测试 / 启动竞态)
|
|
for q in list(self._subs.get(task_id, [])):
|
|
loop.call_soon_threadsafe(q.put_nowait, event)
|
|
|
|
def close(self, task_id: UUID) -> None:
|
|
"""run 结束:派 done 给所有订阅者,标记 task 为已完成。
|
|
|
|
从工作线程调(agent.run 完成 / 抛异常 finally 清理)。
|
|
"""
|
|
self.emit(task_id, {"type": "done"})
|
|
self._done.add(task_id)
|
|
# subs 不在这里立即删 — SSE generator 会先收到 done、yield 它、走到
|
|
# finally unsubscribe;此处 emit 后立即删会让那次 emit 之后的清理无的放矢。
|
|
|
|
def n_subscribers(self, task_id: UUID) -> int:
|
|
"""供测试 / 监控用。"""
|
|
return len(self._subs.get(task_id, set()))
|
|
|
|
def total_subscribers(self) -> int:
|
|
"""本进程 SSE 订阅者总数(所有 task 累加),供 §8.4 并发监控用。"""
|
|
return sum(len(s) for s in self._subs.values())
|
|
|
|
def is_done(self, task_id: UUID) -> bool:
|
|
return task_id in self._done
|
|
|
|
# ─────────────── cancel signaling ───────────────
|
|
|
|
def request_cancel(self, task_id: UUID) -> None:
|
|
"""主线程(HTTP handler)发的 cancel 信号 — BG 线程 poll is_cancelled() 看见即退。
|
|
setdefault:即便 BG 还没注册 flag 也能 set,BG 启动后第一次 check 立刻看见。
|
|
"""
|
|
self._cancel_flags.setdefault(task_id, threading.Event()).set()
|
|
|
|
def is_cancelled(self, task_id: UUID) -> bool:
|
|
ev = self._cancel_flags.get(task_id)
|
|
return bool(ev and ev.is_set())
|
|
|
|
def clear_cancel(self, task_id: UUID) -> None:
|
|
"""run 真正退出(BG finally)清掉 flag,避免 dict 无限增长。"""
|
|
self._cancel_flags.pop(task_id, None)
|
|
|
|
|
|
class RedisRunBroker:
|
|
"""Redis pub/sub 实现(蓝绿双实例跨进程,DESIGN §7.0)。
|
|
|
|
- event:`PUBLISH zcbot:ev:<task_id>`(JSON);单条 async pubsub reader 任务
|
|
把消息路由到本进程各订阅 queue(订阅表 _subs 仍在本地 —— fan-out 的"最后
|
|
一公里"还是进程内,Redis 只负责跨进程那一跳)。
|
|
- done:close 先 `SETEX zcbot:done:<tid> 60`再 publish done;subscribe 先挂
|
|
channel 再查 key,晚到订阅者不漏。
|
|
- cancel:`SETEX zcbot:cancel:<tid> 3600` / GET / DEL,跨实例可达。
|
|
- 容错:bind_loop ping 不通直接 raise(fail-fast);运行中 redis 操作失败
|
|
log(10s 节流)+ 降级(emit 丢帧、is_cancelled 返 False),run 不受影响;
|
|
reader 断线 1s 退避重连并重挂全部活跃 channel。
|
|
"""
|
|
|
|
_EV_PREFIX = "zcbot:ev:"
|
|
_DONE_PREFIX = "zcbot:done:"
|
|
_CANCEL_PREFIX = "zcbot:cancel:"
|
|
_CTL_CHANNEL = "zcbot:ev:__ctl__" # 常挂空 channel,保 pubsub 始终有订阅可 get_message
|
|
_DONE_TTL = 60
|
|
_CANCEL_TTL = 3600
|
|
|
|
def __init__(self, url: str, *, sync_client=None, async_client=None) -> None:
|
|
import redis as _redis
|
|
import redis.asyncio as _aredis
|
|
self._url = url
|
|
# sync client:emit/close/cancel 从工作线程调(redis-py 自带连接池,线程安全);
|
|
# start/request_cancel 也会在 loop 线程上调 —— localhost 单次 ~0.1ms,超时收紧
|
|
# 到 2s 防 redis 挂掉时把 event loop 卡死太久。
|
|
self._redis = sync_client if sync_client is not None else _redis.Redis.from_url(
|
|
url, decode_responses=True, socket_timeout=2.0, socket_connect_timeout=2.0,
|
|
)
|
|
self._aredis = async_client if async_client is not None else _aredis.from_url(
|
|
url, decode_responses=True,
|
|
)
|
|
self._pubsub = None
|
|
self._reader_task: Optional[asyncio.Task] = None
|
|
self._subs: dict[UUID, set[asyncio.Queue]] = defaultdict(set)
|
|
self._loop: Optional[asyncio.AbstractEventLoop] = None
|
|
self._last_err_log = 0.0
|
|
|
|
# ─────────────── helpers ───────────────
|
|
|
|
def _chan(self, task_id: UUID) -> str:
|
|
return f"{self._EV_PREFIX}{task_id}"
|
|
|
|
def _log_err(self, what: str, e: Exception) -> None:
|
|
"""redis 故障时 emit/is_cancelled 每 chunk 都会撞,10s 节流防刷屏。"""
|
|
now = time.monotonic()
|
|
if now - self._last_err_log >= 10:
|
|
self._last_err_log = now
|
|
print(f"[broker] redis {what} failed: {type(e).__name__}: {e} (降级继续,10s 内同类不再报)")
|
|
|
|
# ─────────────── lifecycle ───────────────
|
|
|
|
def bind_loop(self, loop: asyncio.AbstractEventLoop) -> None:
|
|
"""FastAPI startup 调一次:ping 验活(不通 fail-fast)+ 起 pubsub reader。"""
|
|
self._loop = loop
|
|
try:
|
|
self._redis.ping()
|
|
except Exception as e:
|
|
raise RuntimeError(
|
|
f"ZCBOT_REDIS_URL 已设但 Redis 连不上({type(e).__name__}: {e})——"
|
|
f"确认 redis-server 在跑 / URL 对,或删掉该 env 回退进程内 broker"
|
|
)
|
|
self._reader_task = loop.create_task(self._reader(), name="redis-broker-reader")
|
|
host = self._url.rsplit("@", 1)[-1] # 不把密码打进日志
|
|
print(f"[broker] redis pub/sub enabled ({host})")
|
|
|
|
async def shutdown(self) -> None:
|
|
"""lifespan finally 调:停 reader、断两个客户端。"""
|
|
if self._reader_task is not None:
|
|
self._reader_task.cancel()
|
|
try:
|
|
await self._reader_task
|
|
except (asyncio.CancelledError, Exception):
|
|
pass
|
|
if self._pubsub is not None:
|
|
try:
|
|
await self._pubsub.aclose()
|
|
except Exception:
|
|
pass
|
|
try:
|
|
await self._aredis.aclose()
|
|
except Exception:
|
|
pass
|
|
try:
|
|
self._redis.close()
|
|
except Exception:
|
|
pass
|
|
|
|
async def _reader(self) -> None:
|
|
"""常驻:单条 pubsub 连接收所有活跃 channel 的消息,路由到本地订阅 queue。
|
|
断线 1s 退避重连,并把 _subs 里所有活跃 channel 重挂上(订阅状态不丢)。"""
|
|
while True:
|
|
try:
|
|
self._pubsub = self._aredis.pubsub()
|
|
channels = [self._CTL_CHANNEL] + [self._chan(t) for t in self._subs]
|
|
await self._pubsub.subscribe(*channels)
|
|
while True:
|
|
msg = await self._pubsub.get_message(
|
|
ignore_subscribe_messages=True, timeout=5.0
|
|
)
|
|
if msg is None or msg.get("type") != "message":
|
|
continue
|
|
ch = msg.get("channel") or ""
|
|
if ch == self._CTL_CHANNEL:
|
|
continue
|
|
try:
|
|
tid = UUID(ch[len(self._EV_PREFIX):])
|
|
event = json.loads(msg["data"])
|
|
except (ValueError, TypeError):
|
|
continue # 不认识的 channel / 坏 payload,丢
|
|
for q in list(self._subs.get(tid, ())):
|
|
q.put_nowait(event)
|
|
except asyncio.CancelledError:
|
|
raise
|
|
except Exception as e:
|
|
self._log_err("reader", e)
|
|
try:
|
|
if self._pubsub is not None:
|
|
await self._pubsub.aclose()
|
|
except Exception:
|
|
pass
|
|
await asyncio.sleep(1)
|
|
|
|
# ─────────────── pub/sub ───────────────
|
|
|
|
def start(self, task_id: UUID) -> None:
|
|
try:
|
|
self._redis.delete(f"{self._DONE_PREFIX}{task_id}")
|
|
except Exception as e:
|
|
self._log_err("start/del-done", e)
|
|
|
|
def subscribe(self, task_id: UUID) -> asyncio.Queue:
|
|
"""调用方:SSE handler(loop 线程)。queue 立即返回;channel 挂载与 done-key
|
|
补查异步完成 —— 挂载完成前 publish 的帧会丢(与 local 版"订阅前的帧丢"同类,
|
|
SSE 端点已先查过 DB run_status 兜大头)。"""
|
|
q: asyncio.Queue = asyncio.Queue()
|
|
first = not self._subs[task_id]
|
|
self._subs[task_id].add(q)
|
|
|
|
async def _setup() -> None:
|
|
try:
|
|
if first and self._pubsub is not None:
|
|
await self._pubsub.subscribe(self._chan(task_id))
|
|
# 先挂 channel 再查 done key:close() 是先 SETEX 再 publish,
|
|
# 两边一交叉,晚到订阅者要么收到 publish、要么查到 key,不漏
|
|
if await self._aredis.exists(f"{self._DONE_PREFIX}{task_id}"):
|
|
q.put_nowait({"type": "done"})
|
|
except Exception as e:
|
|
self._log_err("subscribe-setup", e)
|
|
q.put_nowait({"type": "done"}) # 保守断流,客户端自动重连
|
|
|
|
if self._loop is not None:
|
|
self._loop.create_task(_setup())
|
|
return q
|
|
|
|
def unsubscribe(self, task_id: UUID, q: asyncio.Queue) -> None:
|
|
subs = self._subs.get(task_id)
|
|
if subs is None:
|
|
return
|
|
subs.discard(q)
|
|
if not subs:
|
|
del self._subs[task_id]
|
|
ch = self._chan(task_id)
|
|
|
|
async def _unsub() -> None:
|
|
try:
|
|
if self._pubsub is not None:
|
|
await self._pubsub.unsubscribe(ch)
|
|
except Exception:
|
|
pass # reader 重连时按 _subs 重挂,残留订阅无害
|
|
|
|
if self._loop is not None:
|
|
self._loop.create_task(_unsub())
|
|
|
|
def emit(self, task_id: UUID, event: dict[str, Any]) -> None:
|
|
try:
|
|
self._redis.publish(
|
|
self._chan(task_id), json.dumps(event, ensure_ascii=False, default=str)
|
|
)
|
|
except Exception as e:
|
|
self._log_err("publish", e) # 丢帧降级,run 不受影响
|
|
|
|
def close(self, task_id: UUID) -> None:
|
|
try:
|
|
self._redis.set(f"{self._DONE_PREFIX}{task_id}", "1", ex=self._DONE_TTL)
|
|
except Exception as e:
|
|
self._log_err("close/set-done", e)
|
|
self.emit(task_id, {"type": "done"})
|
|
|
|
def n_subscribers(self, task_id: UUID) -> int:
|
|
return len(self._subs.get(task_id, set()))
|
|
|
|
def total_subscribers(self) -> int:
|
|
"""本进程的订阅数(蓝绿双实例各报各的,admin 面板看单实例)。"""
|
|
return sum(len(s) for s in self._subs.values())
|
|
|
|
def is_done(self, task_id: UUID) -> bool:
|
|
try:
|
|
return bool(self._redis.exists(f"{self._DONE_PREFIX}{task_id}"))
|
|
except Exception as e:
|
|
self._log_err("is_done", e)
|
|
return False
|
|
|
|
# ─────────────── cancel signaling ───────────────
|
|
|
|
def request_cancel(self, task_id: UUID) -> None:
|
|
try:
|
|
self._redis.set(f"{self._CANCEL_PREFIX}{task_id}", "1", ex=self._CANCEL_TTL)
|
|
except Exception as e:
|
|
self._log_err("request_cancel", e)
|
|
|
|
def is_cancelled(self, task_id: UUID) -> bool:
|
|
"""AgentLoop 在 stream chunk 间 poll(工作线程);redis 挂了返 False 降级
|
|
(cancel 暂不可达,等价于 redis 前的跨实例行为,run 不受影响)。"""
|
|
try:
|
|
return self._redis.get(f"{self._CANCEL_PREFIX}{task_id}") is not None
|
|
except Exception as e:
|
|
self._log_err("is_cancelled", e)
|
|
return False
|
|
|
|
def clear_cancel(self, task_id: UUID) -> None:
|
|
try:
|
|
self._redis.delete(f"{self._CANCEL_PREFIX}{task_id}")
|
|
except Exception as e:
|
|
self._log_err("clear_cancel", e)
|
|
|
|
|
|
def _make_broker():
|
|
"""env `ZCBOT_REDIS_URL` 在 → Redis 版(蓝绿跨进程);不在 → 进程内版(dev/单实例)。
|
|
import 时定一次,与 ZCBOT_INSTANCE 同款读法。"""
|
|
url = os.getenv("ZCBOT_REDIS_URL", "").strip()
|
|
if url:
|
|
return RedisRunBroker(url)
|
|
return LocalRunBroker()
|
|
|
|
|
|
# 进程内单例 — FastAPI lifespan 里 bind_loop;agent / sink / SSE handler 共享。
|
|
broker = _make_broker()
|