Files
task_pilot/docs/decisions/0008-log-streaming-and-virtualization.md
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

3.0 KiB
Raw Permalink Blame History

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 是同步发送,若此时没有订阅者,这批事件不会被补发(轮询仍然能读到文件)。