- 排行榜页顶部新增「实时并发监控」面板(admin-only):当前并发大模型调用数 +
调用列表(用户/智能体/类型/已运行时长),每行「停止」+「全部停止」经
RunManager.cancel 真正中断 LLM 流式调用,减轻大模型压力
- 并发量折线图(峰值/均值,按分钟/小时):新增轻量后台采样器写入新表
concurrency_samples(create_all 自动建表),可按分/时聚合
- 大模型出字速度折线图(tokens/秒,按分钟/小时):复用 llm_call_metrics 聚合,
便于定位是模型/供应商慢
- 后台采样可开关、默认关闭(opt-in)、运行时切换免重启、对线上零影响
(单条极小 INSERT、保留期清理、查询有上限、全程 best-effort)
- 409 中文化:上一轮未结束又发新问题时返回中文提示,前端 onError 友好映射
后端:runtime/runs/manager.py(list_active)、persistence/concurrency(新模块)、
config/system_settings(ConcurrencyMonitorSettings)、gateway/routers/admin_active_runs、
app.py(采样后台任务)、services.py(409)。前端:core/admin/{active-runs,hooks}、
components/workspace/admin/concurrency-monitor、AdminUserLeaderboardPage、threads/hooks。
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
143 lines
5.3 KiB
Python
143 lines
5.3 KiB
Python
"""Async store for concurrency samples + minute/hour aggregation."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from dataclasses import dataclass
|
|
from datetime import UTC, datetime, timedelta
|
|
|
|
from sqlalchemy import delete, select
|
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
|
|
|
from deerflow.persistence.concurrency.model import ConcurrencySampleRow
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# How far back the aggregation will fetch raw rows before bucketing. A safety
|
|
# cap so a degenerate range never tries to load the whole table into memory.
|
|
_MAX_ROWS = 60_000
|
|
|
|
|
|
@dataclass
|
|
class ConcurrencyBucket:
|
|
"""One minute/hour bucket of the concurrency series."""
|
|
|
|
bucket: str # ISO start of the bucket
|
|
peak: int # max concurrent runs observed in the bucket
|
|
avg: float # mean concurrent runs in the bucket
|
|
samples: int # how many raw samples fell into the bucket
|
|
|
|
|
|
def _bucket_start(dt: datetime, granularity: str) -> datetime:
|
|
"""Truncate a timestamp to the start of its minute/hour bucket."""
|
|
if granularity == "hour":
|
|
return dt.replace(minute=0, second=0, microsecond=0)
|
|
return dt.replace(second=0, microsecond=0)
|
|
|
|
|
|
class ConcurrencySampleStore:
|
|
"""Persists periodic concurrency snapshots and aggregates them."""
|
|
|
|
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
|
|
self._sf = session_factory
|
|
|
|
async def record(self, active_count: int, *, sampled_at: datetime | None = None) -> None:
|
|
"""Insert one sample. Best-effort — never raises into the caller."""
|
|
try:
|
|
async with self._sf() as session:
|
|
session.add(
|
|
ConcurrencySampleRow(
|
|
active_count=max(0, int(active_count)),
|
|
sampled_at=sampled_at or datetime.now(UTC),
|
|
)
|
|
)
|
|
await session.commit()
|
|
except Exception: # pragma: no cover - sampling must never break the app
|
|
logger.warning("Failed to record concurrency sample", exc_info=True)
|
|
|
|
async def aggregate(
|
|
self,
|
|
*,
|
|
since: datetime,
|
|
until: datetime,
|
|
granularity: str = "minute",
|
|
) -> list[ConcurrencyBucket]:
|
|
"""Return per-minute / per-hour buckets of peak + average concurrency.
|
|
|
|
Bucketing is done in Python (not SQL ``date_trunc``/``strftime``) so the
|
|
logic is identical across the sqlite/mysql/postgres backends.
|
|
"""
|
|
gran = "hour" if granularity == "hour" else "minute"
|
|
try:
|
|
async with self._sf() as session:
|
|
rows = (
|
|
await session.execute(
|
|
select(ConcurrencySampleRow.sampled_at, ConcurrencySampleRow.active_count)
|
|
.where(
|
|
ConcurrencySampleRow.sampled_at >= since,
|
|
ConcurrencySampleRow.sampled_at <= until,
|
|
)
|
|
.order_by(ConcurrencySampleRow.sampled_at)
|
|
.limit(_MAX_ROWS)
|
|
)
|
|
).all()
|
|
except Exception:
|
|
logger.warning("Failed to aggregate concurrency samples", exc_info=True)
|
|
return []
|
|
|
|
buckets: dict[datetime, dict[str, float]] = {}
|
|
for sampled_at, active_count in rows:
|
|
if sampled_at is None:
|
|
continue
|
|
if sampled_at.tzinfo is None:
|
|
sampled_at = sampled_at.replace(tzinfo=UTC)
|
|
key = _bucket_start(sampled_at, gran)
|
|
value = int(active_count or 0)
|
|
entry = buckets.get(key)
|
|
if entry is None:
|
|
buckets[key] = {"peak": value, "sum": value, "count": 1}
|
|
else:
|
|
entry["peak"] = max(entry["peak"], value)
|
|
entry["sum"] += value
|
|
entry["count"] += 1
|
|
|
|
result: list[ConcurrencyBucket] = []
|
|
for key in sorted(buckets):
|
|
entry = buckets[key]
|
|
count = max(1, int(entry["count"]))
|
|
result.append(
|
|
ConcurrencyBucket(
|
|
bucket=key.isoformat(),
|
|
peak=int(entry["peak"]),
|
|
avg=round(entry["sum"] / count, 2),
|
|
samples=count,
|
|
)
|
|
)
|
|
return result
|
|
|
|
async def purge_older_than(self, cutoff: datetime) -> int:
|
|
"""Delete samples older than ``cutoff``. Returns rows removed."""
|
|
try:
|
|
async with self._sf() as session:
|
|
result = await session.execute(
|
|
delete(ConcurrencySampleRow).where(ConcurrencySampleRow.sampled_at < cutoff)
|
|
)
|
|
await session.commit()
|
|
return int(result.rowcount or 0)
|
|
except Exception: # pragma: no cover
|
|
logger.warning("Failed to purge concurrency samples", exc_info=True)
|
|
return 0
|
|
|
|
|
|
def make_concurrency_sample_store(
|
|
session_factory: async_sessionmaker[AsyncSession] | None,
|
|
) -> ConcurrencySampleStore | None:
|
|
"""Factory mirroring the other ``make_*_store`` helpers.
|
|
|
|
Returns ``None`` on the memory backend (no SQL session factory), in which
|
|
case the monitor simply reports a live snapshot with no historical chart.
|
|
"""
|
|
if session_factory is None:
|
|
return None
|
|
return ConcurrencySampleStore(session_factory)
|