Files
task_pilot/docs/decisions/0008-log-streaming-and-virtualization.md
T
CNWei 07ca14ea34 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
2026-10-27 22:38:53 +08:00

47 lines
3.0 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 是同步发送,若此时没有订阅者,这批事件不会被补发(轮询仍然能读到文件)。