From 59de99ca0b0317b120de3fa314cb3990086db98d Mon Sep 17 00:00:00 2001 From: dailz Date: Fri, 12 Jun 2026 10:11:39 +0800 Subject: [PATCH] update WAL recovery edge case design Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- docs/design.md | 103 ++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 102 insertions(+), 1 deletion(-) diff --git a/docs/design.md b/docs/design.md index 6f0d057..79b2c3e 100644 --- a/docs/design.md +++ b/docs/design.md @@ -136,6 +136,22 @@ WAL 写入路径的失败分类如下: `ErrCommitUnknown` 的 Batch 在当前运行期仍不发布、不确认成功;如果已经写入 MemTable,其 entries 保持 pending / unpublished 或转为 aborted,仅作为内部状态存在,对普通读不可见。`publishedSequence` 始终保持连续 high-water mark,普通读仍可使用 `sequence <= publishedSequence` 判断可见性。重启后,如果 recovery 在 WAL 中发现该 Batch 完整、CRC 合法且 sequence 连续,可以按正常 WAL 规则重放;如果只留下尾部 partial write,则按 WAL 尾部截断规则处理。 +WAL 编码与写入缓冲必须采用 **per-batch private encode buffer + direct write** 模型: + +```text +Batch entries → private encoded []byte → split Physical Records → write(fd, records) +``` + +每个 WAL Batch 在私有内存缓冲区中完成完整编码和 Physical Record 布局;该缓冲区在调用 `write()` 之前不得被 +recovery 看到,也不得进入任何跨 Batch 共享的 WAL 写入状态。实现可以用对象池复用底层 `[]byte` 容量,但复用后的 +缓冲区在同一时刻只能归属一个 Batch,并且必须在该 Batch 完成写入或失败处理后才能归还池中。 + +第一版 WAL writer 不使用共享 `bufio.Writer` 作为 WAL append 路径的一部分,也不允许多个 Batch 先写入同一个共享 +用户态缓冲区后再统一 flush。共享 buffered writer 会模糊“zero bytes reached WAL state”的边界:一旦 Batch bytes +进入共享缓冲,后续 flush 失败无法可靠判断具体哪个 Batch 已经对 WAL 状态产生副作用。因此失败分类以是否已经尝试 +把当前 Batch 的 private buffer 写入 WAL 文件为边界:未调用 `write()` 前失败是普通错误;调用 `write()` 后失败, +除非实现能证明当前 Batch 零字节到达 WAL 状态,否则必须返回 `ErrCommitUnknown` 并进入 write-stopped。 + `ErrCommitUnknown` 表示提交结果不确定:调用方不能把它当作“写入一定失败”并盲目重试。恢复完成后,调用方必须通过读取 key 或后续事务层提供的事务 ID / commit record 查询提交状态,再决定是否重试。后续 MVCC / SSI 事务层必须为事务提交提供幂等标识,避免 fsync 不确定结果导致非幂等事务重复提交。 如果进程在 WAL write / append 已尝试之后、调用方收到成功或错误之前崩溃,调用方观察到的是“无返回结果”。该状态在 API 语义上等价于 `ErrCommitUnknown`:不是成功确认,也不是 definitely failed,而是 maybe committed。调用方重启后必须按同一套状态确认约束处理,不得因为没有收到成功返回就假设该写入一定不存在。 @@ -186,6 +202,48 @@ WAL 写入路径的失败分类如下: 非默认落盘策略必须由用户显式开启。内部仍使用同一套 sequence / publish 机制,区别只在于 `publishedSequence` 是在 fsync 后推进,还是在 WAL write 成功后提前推进。`Periodic` / `Never` 下,已经发布并返回成功的写入仍可能在机器掉电后丢失。 +系统必须同时维护 `durableSequence`,表示最后一次已经满足当前恢复发现条件且 fsync 成功的连续 WAL 物理 +mutation high-water mark: + +```text +durableSequence <= publishedSequence +``` + +`Always` 策略下,Batch 只有 fsync 成功后才发布,因此 `durableSequence == publishedSequence`。`Periodic` 策略下, +WAL write 成功后可以先推进 `publishedSequence` 并返回成功,后台 fsync 成功后再推进 `durableSequence`。`Never` +策略下,引擎不主动推进 `durableSequence`,它只能反映最近一次由启动恢复或显式同步操作确认的 durable 边界。 + +`Periodic` 后台 fsync 失败后,引擎必须进入 write-stopped:后续写入被拒绝,错误被记录并暴露给调用方的健康检查 / +状态查询接口。已经返回成功但 `sequence > durableSequence` 的 Batch 不回滚、不从 MemTable 删除,也不向原调用方 +补发错误;它们在当前进程内仍按 `publishedSequence` 可见,但机器掉电后可能丢失。调用方如果选择 `Periodic` / +`Never`,必须通过 `GetDurableSequence()` 或等价状态接口自行判断哪些已确认写入已经跨越 durable 边界。 + +`Periodic` 的 fsync 间隔必须是显式配置项;文档中的默认策略仍是 `Always`,第一版可以不提供 `Periodic` 默认值。 +若后续提供默认 `Periodic` 间隔,必须同时在用户文档中说明最大时间窗口和最大 pending bytes 窗口,使调用方能估算 +`publishedSequence - durableSequence` 覆盖的风险范围。 + +`durableSequence` 不能只根据“最近一次 fsync 成功”模糊推进。WAL writer 必须为每个完整 Batch 记录 +`(segmentID, endOffset, endSequence)`,其中 `endOffset` 是该 Batch 最后一个 Physical Record 结束后的文件偏移, +`endSequence = baseSequence + entryCount - 1`。后台 fsync worker 触发时必须先快照当前 append high-water mark: + +```text +fsyncSnapshot = (segmentID, endOffset, endSequence) +``` + +fsync 成功后,只能把 `durableSequence` 推进到满足以下条件的最大连续 Batch: + +```text +batch.segmentID < fsyncSnapshot.segmentID +∨ (batch.segmentID == fsyncSnapshot.segmentID ∧ batch.endOffset <= fsyncSnapshot.endOffset) + +∧ batch.segment 已进入 durable-ready 状态 +∧ batch.endSequence 连续衔接当前 durableSequence +``` + +如果 fsync 期间又追加了新的 Batch,或者发生 segment rotation,这些晚于快照的 Batch 不得因为本次 fsync 成功而被 +标记为 durable;它们必须等待覆盖自身 `(segmentID, endOffset)` 的下一次 fsync 成功。跨 segment 推进时,目标 segment +还必须满足 WAL 元数据持久化协议:segment header 已 fsync,rename 后目录项已 fsync,并已进入 durable-ready。 + #### WAL 元数据持久化协议 `Always` 策略下,WAL Batch 可以返回成功的前提不仅是 WAL bytes 已 fsync,还包括恢复路径能够在掉电后找到这些 bytes。任何承载已确认写入的 WAL segment 都必须先进入 durable-ready 状态。 @@ -429,6 +487,17 @@ Entry 的 sequence 由 batch 内位置推导: entry[i].sequence = baseSequence + i ``` +所有 sequence 运算必须使用 checked arithmetic。写入侧在分配 Batch sequence 前必须确认: + +```text +baseSequence + entryCount - 1 不溢出 u64 +``` + +如果剩余 sequence 空间不足以容纳整个 Batch,WAL writer 不得写入部分 Entry,也不得让 sequence wrap;引擎必须进入 +terminal `sequence exhausted` 状态,拒绝后续写入并要求用户迁移 / 重建数据库。Recovery 侧解析 WAL Batch 时也必须做 +同样的溢出检查;如果 `baseSequence + entryCount - 1` 或 `expectedSequence += entryCount` 发生溢出,视为 WAL 损坏 +并报错,而不是按取模后的 sequence 继续恢复。 + ###### WAL Batch 资源上限 WAL Batch 解析和写入必须使用同一套可配置资源上限。默认上限如下: @@ -616,6 +685,11 @@ while blockRemaining >= 7: 当 `blockRemaining < 7` 时,剩余 bytes 必须全为 0,然后进入下一个 Block。 +最后一个需要恢复的 segment 的最后一个 Block 可以是 short Block:如果解析器刚好在一个完整 Physical Record 结束后 +遇到 EOF,该 EOF 是正常 WAL 结束条件,不要求文件中不存在的 bytes 补齐到 32KB,也不要求对不存在的 padding 做 0 校验。 +Padding 规则只适用于文件中实际存在的 bytes:只要当前 Block 内已经物理存在的剩余 bytes 非 0,就按 padding 损坏处理; +如果剩余 bytes 尚未写入文件而直接 EOF,则按正常尾部结束处理。 + 错误分类: 这里的 “WAL 尾部” 有严格定义:只指最后一个需要恢复的 WAL segment 的物理 EOF 附近。非最后恢复 segment 中的 header 半写、`length` 越界、CRC 错误、incomplete batch 或非法 padding,即使发生在该 segment 文件尾,也视为 WAL 中间损坏。 @@ -701,7 +775,10 @@ expectedSequence += entryCount lastCompleteBatchEnd = 当前 WAL offset ``` -如果 Batch Header 或 Entry 解析错误发生在 WAL 尾部,可以丢弃该 incomplete batch;如果发生在 WAL 中间,默认报错。 +如果 Batch Header 或 Entry 解析错误发生在 CRC-valid 的完整 WAL Batch 中,即使该 Batch 位于最后一个需要恢复的 +segment 的物理尾部,也必须视为 WAL 损坏并报错;它不再是典型 torn write 证据,不能通过尾部截断静默丢弃。 +尾部可截断 repair 只适用于物理不完整或物理校验失败的情况:半个 Physical Record header、`length` 越过 EOF、 +CRC 校验失败、非法 tail padding、或 `First + Middle*` fragment 链未出现 `Last`。 ###### 尾部截断持久化 @@ -823,6 +900,30 @@ MemTable 可能包含已写入内存但尚未发布的 pending entry,也可能 ### 3.4 读路径 +#### 嵌入式 Get API + +第一版嵌入式 API 的 `Get` 必须显式返回 key 是否存在,不能只返回 `[]byte`: + +```go +// GetResult 表示单 key 读取结果。 +type GetResult struct { + // Value 是 key 当前可见版本的 value bytes。 + // 当 Found 为 true 时,Value 可以是长度为 0 的合法空 value。 + Value []byte + + // Found 表示 key 是否存在。 + // Found=false 表示 key 不存在;Found=true 且 len(Value)==0 表示 key 存在但 value 为空 bytes。 + Found bool +} + +// Get 读取 key 的当前可见值。 +func (db *DB) Get(key []byte) (GetResult, error) +``` + +该签名服务于两个约束:第一,`Put(key, emptyValue)` 是合法写入,读路径必须能把“存在但 value 为空”与“不存在” +区分开;第二,`ErrCommitUnknown` 后的弱状态确认需要读取当前 key 状态,如果 API 丢失 `Found` 信息,调用方无法 +判断空 value 写入是否可能已经提交。 + 读取时按优先级查,从新到旧,找到第一个就返回: ```