deepseek-harness/.agents/notes/implemented/architecture/2026-08-08-bounded-session-persistence-write-batching.zh.md
Turtle bec6805d6a refactor(session-persistence)!: handle-based seam with a lifecycle-owned write path
The persistence seam is now create/open/stat/list returning per-session
SessionHandles (read/append/flush/close); every log read and write flows
through the owning handle. The seam package exports only the service and
handle contracts, consumer-visible errors, and pure durable-data
validation helpers; each backend owns its complete storage runtime, and
the shared contract suites pin equivalent observable behavior. The
backend routes published sessions' live events by id into the active
write handle; agent-loop only acquires, seeds, and closes the handle.
Resume appends interruptedTurnClosers through its write handle;
session-query owns the revision-keyed cold cache. Legacy-only surfaces
are removed in the same swap: locate/readRaw/supportsRawArtifacts, the
legacy event-shape read migration, zstd torn-frame salvage,
DSH_SESSION_JSONL, and hook transcript_path population; a torn final
zstd frame is discarded whole; the session-list cold blank probe returns
on stat metadata (eventCount derived from the last physical row,
sizeBytes). The WebUI ZIP export serializes the logical log from a read
handle, so both backends export identically.

Refs #3245
2026-09-01 23:19:02 +08:00

59 lines
8 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.

# Agent Note: 为会话持久化写入批处理设定上界
Status: implemented
[English](2026-08-08-bounded-session-persistence-write-batching.md) | 中文
## 问题
流式响应可能会在短时间内发出大量 `assistant/chunk` 事件。此前,只要空闲队列收到一个事件,持久化协调器就会立即调度一次 provider 追加。该追加仍在进行时到达的事件会共用一个后续批次,但如果 provider 速度很快,仍可能产生大量小规模的持久化追加。每次 JSONL 追加都会创建并同步一个 Zstandard 帧或原始格式后缀。
丢弃分片事件或用组装后的消息替代它们可以减少逻辑存储量,但也会改变事件日志、回放、序列号、时间戳,以及助手消息引用的分片 seq。写放大问题不要求采取这项语义变化更大的方案。
### 量化基线
仓库 fixture(测试前置数据)让逻辑数据量有了具体依据。对当前 [`goal-multi-turn-actions`](../../../../snapshots/web/goal-multi-turn-actions/session.jsonl) 中的打包行进行解码,可得到 2,098 个事件,其中 2,017 个是分片(96.1%)。这些分片解包后的 JSONL 行共 332,647 字节,占全部事件 379,225 字节的 87.7%;分片打包则把仓库中的已提交文件缩小到 89,176 字节和 182 个存储行,其中包括 23 个打包分片行。[`permission-policy-context`](../../../../snapshots/web/permission-policy-context/session.jsonl) 可得到 813 个事件,其中 746 个是分片(91.8%);这些分片解包后的 JSONL 行共 118,935 字节,占全部事件 184,821 字节的 64.4%。其打包文件为 84,917 字节,共 123 个存储行,其中包括 14 个打包行。这些是纳入版本控制的确定性 fixture,不代表生产工作负载分布;但它们说明了删除分片为何会降低逻辑数据量,也说明现有打包行布局已经消除了大量 JSON 包装开销。
JSONL 每个持久化追加批次会写入一个 Zstandard 帧并执行一次 fsync。运行时文件不记录原有追加边界,因此不能把 fixture 的存储行数当作 fsync 次数。
调度上界是确定的。当写入端会立即完成每次操作时,原来的即时控制器可能对每个在前一次追加完成后到达的事件分别发起一次追加。一个控制器测试以 10 ms 的间隔接纳 20 个事件:200 ms 固定窗口会把全部 20 个事件交给一次追加。对于这种到达节奏,追加次数从 20 次降至 1 次,但这不是普遍比例。稀疏事件、强制 flush、较慢的前序写入和不同到达速率都会产生不同的批次大小。
## 决策
固定窗口是 JSONL provider 的常量 `LIVE_WRITE_BATCH_MAX_DELAY_MS`(200 ms),它是内部调度策略而非配置:后端自己的会话监听器按 id 把实时事件路由进活跃写句柄的缓冲,因此批处理绝不跨越包边界([句柄 Note](2026-08-27-handle-based-session-persistence.zh.md))。
每个活跃写句柄直接拥有自己的缓冲。被路由的事件落入句柄的待处理数组,空闲缓冲收到的第一个事件会启动一个固定计时器。后续事件加入该批次但不会重置截止时间:这属于有界合并,而不是防抖。截止时间到达后,一次 single-flight 排空会把待处理前缀经由句柄的修改链持久化,该链本就将其与显式 append 串行化。排空进行期间接纳的事件会按顺序合并进下一个链上的批次。
该窗口只限制控制器为批处理而主动等待的时间。事件循环调度、初始化、此前的串行化操作和后端 I/O 都可能延后持久化完成时间,因此该选项并不对 fsync 完成时间或崩溃数据丢失提供硬性 SLA。
`session/flush` 会取消剩余等待,并充当共享的完全停稳屏障。它会在完成前等待活跃写入尝试,并排空屏障运行期间接纳的每个事件。会话退役(`session/disposed`)、句柄的 close 与后端 teardown 的关闭清扫共用该屏障,因此生命周期 teardown 绝不会等待批处理计时器。检查点策略仍会在模型请求与顶层工具副作用之前设置强制屏障。
每个事件仍会按原有顺序和形态持久化。控制器会在接纳时复制每个事件;任何 `assistant/chunk`、`seq`、`time`、surface 元数据或存储记录都不会被删除或重写。因此,JSONL 可以在一个追加帧中编码更多事件,而无需改变其磁盘格式。
后台排空失败后,其完整批次会按顺序保留在所有较新的待处理事件之前,该失败被报告一次,自动计时器随之暂停。下一次显式排空——`session/flush` 屏障、服务级 `flush()` 或 close——会立即重试,如果故障再次发生,则会向调用方暴露该故障。这可以避免计时器驱动的失败循环,同时保留现有可恢复的 flush 边界。
本决策仅取代[将实时持久化归并到单个刷新控制器](../simplification/2026-07-23-collapse-persistence-flush-state.zh.md)中的即时调度节奏。对于每个活跃会话使用一个缓冲所有者、保留失败批次、退役和完全停稳的 dispose,原 Agent Note 仍是权威记录。最初承载该行为的协调器与独立的 write-behind 控制器均已删除;缓冲、计时器和排空落在 provider 的句柄上,它们写入所经过的存储边界由[基于句柄的 seam](2026-08-27-handle-based-session-persistence.zh.md) 定义。
## 备选方案
**不持久化流式分片事件。** 这里不采纳:这会改变事件溯源的权威地位及恢复语义,而不只是改变物理写入节奏。在无信息损失的替代方案独立定义回放、fork、引用源事件的关联、序列和崩溃行为之前,现有的[拒绝仅保留组装消息的决策](../../rejected/simplification/2026-06-20-assembled-assistant-messages-only.zh.md)仍是防护规则。[打包行决策](2026-07-26-packed-chunk-rows-by-default.zh.md)仍是配套的 JSONL 存储体积优化。
**仅在语义检查点写入。** 不采纳:此方案会最大化批处理,却让普通的崩溃丢失窗口取决于另行挂载的策略。有界后台写入会在检查点之间持久化进度,而强制 flush 继续提供更强的顺序约定。
**按最新事件重置防抖窗口。** 不采纳:持续不断的流式响应可能无限期推迟首次写入。由第一个待处理事件启动的固定窗口,为主动合并等待提供了真正的上界。
**共享的 provider 无关控制器组件。** 曾在一次迭代中交付,随后不采纳:句柄的修改链本就串行化写入,独立控制器重复了这套排序机制。每个 provider 在自己的句柄上实现该缓冲,共享的实时写入约定测试套件为任何 provider 钉住等价的可观察行为。
## 验证
共享的实时写入约定测试套件(`runLiveWritePathContract`)使用假时钟证明固定且不会重置的 200 ms 窗口、`session/flush` 屏障及其失败的响亮暴露、有序保留失败批次并恰好恢复一次、带逐会话失败聚合的服务级 `flush()` 清扫,以及 disposed/close/teardown 的排空。JSONL 测试套件继续覆盖存储格式、恢复和共享持久化约定。
## 后果
高频事件突发通常会减少持久化追加操作,同时保持逻辑事件数量完全不变。减少幅度取决于事件到达速率和后端延迟:位于同一 200 ms 窗口内的突发事件会成为一个批次,而强制 flush 与稀疏事件仍可能产生小批次。
本决策不会限制因 provider 缓慢而积压的待处理事件数量或字节数,也不会减少解码后的逻辑日志。若要建立经过验证的内存上界或逻辑保留策略,就必须为其另行定义失败与回放约定,而不是再引入一条隐式计时器规则。
接纳后的事件在固定窗口内可能只存在于内存中,此后在等待调度或后端工作完成期间也可能如此。显式持久性边界保持不变,并会绕过等待。
句柄统一负责计时器、活跃排空、待处理前缀、重试暂停和屏障;后端的监听器负责路由和生命周期驱动的排空。`SESSION_FORMAT_VERSION` 保持不变。