# ADR-0008:日志合并推送与虚拟滚动 - 状态:已接受 - 日期:2026-10-27 ## 背景 日志链路原本是"一行一个事件、一行一次渲染": 1. 子进程每输出一行,`LogSink::append` 就向 512 容量的 broadcast 通道发一条 `log` 事件。 输出密集时事件按行堆积,SSE 订阅者很容易 `Lagged`,触发 `lagged` → 前端重新拉取, 在日志最密集的时刻反而放大数据库与文件读取压力。 2. 前端 `RunLogOutput` 把每条事件字符串拼到 `content` state,React 每次都要重渲染整个 `
` 文本;开销随行数线性增长,单次运行 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`,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 是同步发送,若此时没有订阅者,这批事件不会被补发(轮询仍然能读到文件)。