perf(logs): 日志事件按窗口合并推送,抽屉改为虚拟滚动
- 后端 LogSink 把逐行事件按 16 KiB / 100 ms 合并成一条 log 事件,文件写入保持即时;后台 tick 与析构 flush 保证低输出和收尾都不丢尾部 - 前端新增 LogViewer:固定行高虚拟滚动、跟随底部/回到最新,增量按 rAF 合并,长日志 DOM 节点数恒定 - 日志抽屉改为行缓冲(lib/logLines)+ 可见窗口计算(lib/logWindow) - 详情区改 flex 布局,日志面板吃掉剩余高度,回到最新按钮不再被裁到面板外 - 测试:Rust 单元 52→57,Vitest 13→28,Playwright 4→5(1500 行虚拟滚动) - 文档:README 补充合并推送与虚拟滚动,新增 ADR-0008
This commit is contained in:
@@ -0,0 +1,46 @@
|
||||
# ADR-0008:日志合并推送与虚拟滚动
|
||||
|
||||
- 状态:已接受
|
||||
- 日期:2026-10-27
|
||||
|
||||
## 背景
|
||||
|
||||
日志链路原本是"一行一个事件、一行一次渲染":
|
||||
|
||||
1. 子进程每输出一行,`LogSink::append` 就向 512 容量的 broadcast 通道发一条 `log` 事件。
|
||||
输出密集时事件按行堆积,SSE 订阅者很容易 `Lagged`,触发 `lagged` → 前端重新拉取,
|
||||
在日志最密集的时刻反而放大数据库与文件读取压力。
|
||||
2. 前端 `RunLogOutput` 把每条事件字符串拼到 `content` state,React 每次都要重渲染整个
|
||||
`<pre>` 文本;开销随行数线性增长,单次运行 5 MiB 上限意味着最坏情况下数万行 ×
|
||||
数万次更新,是典型的 O(n²)。
|
||||
3. 全部日志行都作为 DOM 文本存在,长日志下文本节点、布局与滚动都变得昂贵。
|
||||
|
||||
## 决策
|
||||
|
||||
1. **后端按窗口合并**:`LogSink` 新增 `PendingBatch`,行仍然**立即写文件**(轮询读到的偏移不受影响),
|
||||
但 SSE 事件按"**攒满 16 KiB** 或 **满 100 ms**"合并成一条 `log` 事件,携带连续的
|
||||
`offset..next_offset`。
|
||||
2. **按时兜底**:每个 sink 起一个 50 ms tick 的后台任务,低输出量时也保证 100 ms 内推送;
|
||||
任务只持有 `Weak<LogSinkInner>`,sink 释放后自行退出。
|
||||
3. **析构 flush**:`Drop for LogSinkInner` 同步把残留批次发出去,运行结束时不会丢最后几行。
|
||||
4. **前端按帧合并**:增量先进行缓冲,`requestAnimationFrame` 内最多触发一次重渲染,
|
||||
并且在同一次渲染里把新行并入。
|
||||
5. **虚拟滚动**:`LogViewer` 以固定行高 20 px 换算可见区间,只渲染视口 ± 24 行的窗口;
|
||||
行内不折行(`white-space: pre`),超长行横向滚动,从而避免逐行测量高度。
|
||||
6. **跟随策略**:默认贴底跟随;用户上滚超过 24 px 即停止跟随并显示「回到最新」,
|
||||
点击后回到底部并恢复跟随。
|
||||
|
||||
## 原因
|
||||
|
||||
- 合并推送把事件数量从"每行一条"降到"每 100 ms 最多一条",SSE 背压与前端更新次数同时下降,
|
||||
而实时感没有可感知的损失(100 ms 窗口)。
|
||||
- 文件写入保持即时,轮询接口与下载接口的语义完全不变,合并只影响事件粒度。
|
||||
- 固定行高让滚动位置与行号一一对应,虚拟滚动不需要动态测量,代码量与出错面都小。
|
||||
- 行缓冲原地追加 + 一帧一次渲染,避免了复制整个字符串/数组的二次开销。
|
||||
|
||||
## 边界
|
||||
|
||||
- 合并意味着**事件粒度不再是行**:依赖"一条事件 = 一行"的消费者需要按 `content` 自行切分。
|
||||
- 不折行是显式取舍:超过面板宽度的行需要横向滚动;折行会让行高不定,虚拟滚动需要测量。
|
||||
- 单次运行仍受 5 MiB 总上限、单行 256 KiB 上限约束;虚拟滚动只解决渲染,不会保留更多日志。
|
||||
- `Drop` 里的 flush 是同步发送,若此时没有订阅者,这批事件不会被补发(轮询仍然能读到文件)。
|
||||
Reference in New Issue
Block a user