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