Files
AgentLab/backend/app/core/bus.py
T
ThearchyHelios eddeded2a4 全栈安全加固、性能提速与架构演进:连接池与沙箱止血、双路混合检索、多版本归档及前端分包治理
- 第一批(安全加固与生命周期止血):隔离测试宿主机密钥与 .env 泄漏;修复 SQLite 取消查询挂死、:memory: 连接池报错与 EngineCache 世代竞态;补齐运行与评测删除时的 Checkpointer 线程、Artifact 磁盘文件与无主附件回收;加固三套沙箱路径越界、进程组清理与 Seatbelt 数据目录隔离,封堵 SSRF IPv4-mapped IPv6 元数据绕过与响应体 OOM;修复推理模型多轮 reasoning_content、结构化输出 Token 漏计与缓存折扣计费
- 第二批(数据库/集群/问数提速与首屏分包):补齐核心表索引与 SQLite WAL 调优,列表接口按需 defer 大列,收敛启动扫描并打通跨进程 slot 槽位唤醒;优化跨源全局选表、哈希桶数字绑定、流式 CSV 导入及多方言分析算子;剥离首屏静态引入的 @xyflow/react 与全局样式(首屏主包降至 461.59 kB),补充流式 rAF 攒批、Store 精细订阅与表单无障碍绑定
- 第三批(架构演进与工程自动化闭环):记录库超挂接阈值历史版本自动增量归档至单库多表(解除 SQLite 10 库 ATTACH 硬上限);重构知识库为词面 BM25 与全集合归一化向量矩阵真·双路独立召回并支持游标分批重建;升级多方言感知 SQL 模板词法分析(E'...' / $$...$$ / q'[...]' / # / 嵌套注释)与 Decimal / bool / NaN 参数校验,消除 ledger 层对 api 路由层的反向依赖;拆分 3101 行 CaliberDrafts 页面为 7 个领域子模块,消除检查脚本本机路径硬编码并新增 GitHub Actions CI 门禁与三批回归测试套件
2026-11-13 14:51:36 +08:00

150 lines
5.7 KiB
Python

from __future__ import annotations
import asyncio
import contextlib
from collections import OrderedDict, defaultdict
from collections.abc import Callable
from typing import AsyncIterator
from app.core.events import RunEventModel
# 单个订阅者的积压上限。慢消费者(比如卡住的浏览器标签)不应该拖垮整个 run,
# 所以队列满了就丢最旧的事件并打标记,而不是阻塞发布方。
_QUEUE_MAXSIZE = 2048
_MAX_SEQ_ENTRIES = 4096
class _Subscriber:
def __init__(self) -> None:
self.queue: asyncio.Queue[RunEventModel | None] = asyncio.Queue(maxsize=_QUEUE_MAXSIZE)
self.dropped = 0
class EventBus:
"""按 run_id 分频道的内存 pub/sub。
只负责实时分发;历史事件由 DB 承担,订阅者接入时先读库补齐再接实时流,
这样刷新页面或中途接入都能看到完整时间线。
多进程(app/cluster/relay.py):本进程发布的每一条也交给 tap(转发器据此给别的进程发通知、给在看的进程转流式增量);
别的进程执行的运行,转发器从库里按 seq 读出来再在本进程发布一遍(relayed=True,不再交给 tap,免得来回转)。
"""
def __init__(self) -> None:
self._subs: dict[str, set[_Subscriber]] = defaultdict(set)
self._seq: OrderedDict[str, int] = OrderedDict()
self._lock = asyncio.Lock()
self.tap: Callable[[RunEventModel], None] | None = None
def _trim_seq(self) -> None:
while len(self._seq) > _MAX_SEQ_ENTRIES:
oldest, _ = next(iter(self._seq.items()))
if self._subs.get(oldest):
self._seq.move_to_end(oldest)
break
self._seq.popitem(last=False)
def next_seq(self, run_id: str) -> int:
nxt = self._seq.get(run_id, 0) + 1
self._seq[run_id] = nxt
self._seq.move_to_end(run_id)
self._trim_seq()
return nxt
def set_seq(self, run_id: str, value: int) -> None:
"""恢复一个已存在的 run 时,把序号接上,保证前端排序不乱。"""
self._seq[run_id] = max(self._seq.get(run_id, 0), value)
self._seq.move_to_end(run_id)
self._trim_seq()
async def publish(self, event: RunEventModel, *, relayed: bool = False) -> None:
if not relayed and self.tap is not None:
try:
self.tap(event)
except Exception: # noqa: BLE001 - 转发出错不能拦住本进程的推送
pass
for sub in list(self._subs.get(event.run_id, ())):
try:
sub.queue.put_nowait(event)
except asyncio.QueueFull:
with contextlib.suppress(asyncio.QueueEmpty):
sub.queue.get_nowait()
sub.dropped += 1
with contextlib.suppress(asyncio.QueueFull):
sub.queue.put_nowait(event)
async def close(self, run_id: str) -> None:
"""给所有订阅者发结束哨兵,让 WebSocket 干净地收尾。"""
self._seq.pop(run_id, None)
for sub in list(self._subs.get(run_id, ())):
with contextlib.suppress(asyncio.QueueFull):
sub.queue.put_nowait(None)
def attach(self, run_id: str) -> "Subscription":
"""立刻占一个位,之后再慢慢消费。
必须能在读历史**之前**占位。`subscribe()` 做不到这件事:它是异步
生成器,订阅者要等到第一次迭代才真正注册——而调用方通常先读完历史
才开始迭代。这中间发出的事件既不在历史里、也不在实时流里,就这么
没了。一次跑图从发起到订阅之间正好有几毫秒,丢掉的往往是开头那
两三条 node.started/node.finished,表现为"某个节点永远在转圈、
它的子步骤跑到了顶层"。
"""
sub = _Subscriber()
self._subs[run_id].add(sub)
return Subscription(self, run_id, sub)
def _detach(self, run_id: str, sub: _Subscriber) -> None:
self._subs.get(run_id, set()).discard(sub)
if run_id in self._subs and not self._subs[run_id]:
self._subs.pop(run_id, None)
async def subscribe(self, run_id: str) -> AsyncIterator[RunEventModel]:
"""便捷入口:占位即消费。只适合不需要补历史的场景。"""
with self.attach(run_id) as sub:
async for event in sub.stream():
yield event
def subscriber_count(self, run_id: str) -> int:
return len(self._subs.get(run_id, ()))
class Subscription:
"""一个已经在收事件的订阅位。"""
def __init__(self, bus: EventBus, run_id: str, sub: _Subscriber) -> None:
self._bus = bus
self._run_id = run_id
self._sub = sub
@property
def dropped(self) -> int:
"""因为消费太慢被丢掉的条数。不是 0 就说明这条时间线有洞。"""
return self._sub.dropped
async def stream(self, after: int = 0) -> AsyncIterator[RunEventModel]:
"""吐出实时事件。
after 用来去重:占位到读完历史之间发出的事件,两边都有一份。
按 seq 过滤掉已经补过的,前端就不会看到同一步出现两次。
"""
while True:
item = await self._sub.queue.get()
if item is None:
break
if after and (item.seq or 0) <= after:
continue
yield item
def close(self) -> None:
self._bus._detach(self._run_id, self._sub)
def __enter__(self) -> "Subscription":
return self
def __exit__(self, *exc: object) -> None:
self.close()
bus = EventBus()