Files
deepseek-harness/docs/rfc/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.zh.md
T

45 lines
6.0 KiB
Markdown
Raw Normal View History

# RFC:共享持久化写入协调器
Status: implemented
[English](2026-06-18-shared-persistence-write-coordinator.md) | 中文
## 问题
`dsh-session-persistence-jsonl` 与 `dsh-session-persistence-sqlite` 有意在不同存储介质上证明同一份 `SessionPersistence` 契约,但二者的写入路径编排是重复的:per-session 状态、`session/created` 接管、后端特定的前缀读取、write-behind 缓冲区、串行化 flush 链、HMR(热模块替换)种子注入,以及 dispose(资源释放)排空。纯粹的种子前缀冲突与可串行化守卫已经迁入 seam 包;剩余的编排仍然是正确性密集的,并且相同的修复被应用了两次。代码级 diff 表明两个后端在**所有**这些逻辑上是逐字节一致或同算法的:四个 map(`states`/`buffers`/`chains`/`inits`)、`installWritePath`、`initFor`、`onCreated` 的四种分支、`flush`、`drain`、`serialize`、`adopt`、`adoptLivePrefix`、`assertVersion`,以及 `create`/`append`/`load` 骨架。唯一不同的只有存储原语(写字节 vs. INSERT 行)。
## 决策
将一个后端无关的 `PersistenceCoordinator` 提取到 `dsh-session-persistence` 中。协调器统一拥有编排逻辑;每个第一方后端组合一个实例(`new PersistenceCoordinator(ctx, this)`),实现一个小型 `PersistenceBackend` 钩子接口,并将其四个公开服务方法(`create`/`append`/`load`/`list`)委托给协调器。
组合,而非继承。协调器是后端持有的具体类,不是后端继承的基类。本 RFC 的风险点——「协调器不得迫使非常规后端与继承层级搏斗」——由此规避:后端只暴露钩子;它无法触及协调器的私有编排状态,且公开的 `SessionPersistence` 服务形状不变,因此第三方后端仍然可以完全不使用协调器、直接实现抽象服务。
### 钩子接口(`PersistenceBackend<TornMarker>`)
六个方法(五个必需 + 一个可选生命周期钩子)——协调器与存储之间唯一的 seam:
- `name`:后端标签,用于 dispose 失败时的 `AggregateError`。
- `loadStored(id)`:按 id 读取已存储的前缀,扫描**任何**存储范围(JSONL 的每个 cwd bucket;SQLite 的 id 全局唯一)。用于恢复/加载,以及通过 `!== undefined` 实现创建冲突探测。
- `loadLive(id, cwd)`:读取**限定于 `cwd`** 的已存储前缀。**刻意区别于 `loadStored`**:HMR live-adoption 只能接管与活跃会话**相同 cwd** 下的持久化日志;同 id 但不同 cwd 的日志是冲突而非恢复。合并这两个方法会重新引入跨 cwd 接管 bug。SQLite 忽略 `cwd`。
- `appendBatch(meta, events, isMaterialized)`:持久地追加一个连续批次,在尚未物化时**原子地**惰性物化会话(物化写入与第一个事件批次必须一起提交——崩溃发生在二者之间时不得留下一个已物化但为空的会话;这就是为什么没有单独的 `materialize` 钩子)。
- `commitRepair(meta, tornMarker, closers)`:使崩溃修复持久化:截断撕裂尾部(当且仅当 `tornMarker !== undefined`)并追加 `closers`。**不要求原子性**——JSONL 合理地分两步 fsync(先截断再追加),SQLite 在一个事务中完成 DELETE+INSERT。用于 `load`(截断 + 合成 closers)和 live-adoption(仅截断,`closers = []`)。
- `list()`:列出所有已存储的元数据。
- `close?()`:可选的生命周期清理(SQLite 关闭 db 句柄;JSONL 省略),在 dispose effect 中于静默排空**之后**被 await,确保 close 失败不会掩盖排空错误。
### 不透明的撕裂标记
保持 seam 干净的唯一设计选择:崩溃修复中的「撕裂尾部在哪里」token 对协调器是**不透明的**。协调器计算合成 closers(它拥有来自 `dsh-session` 的 `interruptedTurnClosers`),但它只测试 `tornMarker !== undefined` 并将值原样传回 `commitRepair`——从不检视其内容。每个后端选择自己的标记类型:JSONL 使用要截断到的字节偏移量,SQLite 使用要从其开始删除的 seq(两者碰巧都是 `number`)。JSONL 后端将其 `committedBytes < buffer.byteLength` 比较**折叠在钩子内部**,因此返回的标记已经是 `number | undefined`;如果不做这个折叠,协调器就必须了解字节长度。
## 测试
共享的 `runPersistenceContract`(公开 API 契约)继续为每个后端运行。新增的 `runCoordinatorContract`(`tests/coordinator-contract.ts`)覆盖写入路径编排——接管、HMR、冲突、dispose 排空、崩溃尾部修复——通过 `CoordinatorFixture`(内存参考实现 + jsonl + sqlite)为每个后端运行一次。各后端自身的测试缩减为仅覆盖存储机制(JSONL:路径安全、fsync 回滚、bucket 列举;SQLite:schema 版本、`scanRows`、事务回滚)。每个真实后端有一个 through-coordinator 的 torn-tail→load→`commitRepair` 测试(通过 `corruptTail` fixture 钩子),确保协调器的撕裂标记修复分支在 100% per-file 门禁下被覆盖——契约崩溃测试只产生合成 closers 而不产生撕裂标记,因此无法触达该分支。
## 曾考虑的替代方案
- **后端继承的基类**:否决,改用组合。后端只暴露钩子,无法触及协调器的私有编排状态,且第三方后端仍然可以完全不使用协调器、直接实现抽象服务。
- **更宽的钩子面**:每个候选钩子都被折叠掉了:没有单独的 `materialize` 钩子(物化写入必须在 `appendBatch` 内与第一个事件批次原子提交);没有单独的创建冲突探测(它就是 `loadStored(id) !== undefined`);`list()` 也不经过协调器透传(列举不需要任何编排)。
## 后果
协调器增加了一层间接和一个不透明的撕裂标记,但将此前每个后端重复的正确性密集编排集中到一处。其钩子面保持窄小:冲突检查复用 `loadStored`,物化保持在 `appendBatch` 内原子完成,列举绕过协调器。新后端只需实现存储原语,无需复制事件-缓冲区-flush 生命周期。