zcbot/web/broker.py

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()