# WAL 代码审核报告（对照 `docs/design.md` § 3.2）

审核范围：`/wal/...`、`/memtable/...`、`/manifest/...`、`/config/...`、`db.go`。
审核时间：基于 commit `e34de4a`（fix: implement group commit collection window）。

下面按严重程度排列，每条给出 **设计要求 → 代码现状 → 后果 → 修复建议**。

---

## 🔴 Critical（破坏持久化 / 恢复正确性，必须修）

### C1. CRC 多项式错误：用 IEEE 而非 crc32c

- **设计要求**（§3.2 Physical Record、WAL File Header）：crc32c（Castagnoli，0x82F63B78），用于识别 torn write / partial write。
- **代码现状**：
  - `wal/record.go:30,55` → `crc32.ChecksumIEEE`
  - `wal/header.go:44,83` → `crc32.ChecksumIEEE`
- **后果**：CRC 校验值与设计文档不一致；如果未来要做跨实现兼容（其他客户端 / 工具按 crc32c 校验），所有 segment 都会被判损坏。当前自洽但与规范脱钩。
- **修复**：改用 `hash/crc32.Castagnoli`（即 `crc32.MakeTable(crc32.Castagnoli)`），所有 `ChecksumIEEE` 替换为 `Checksum(data, castagnoliTable)`。常数需要加测试固定。

---

### C2. Recovery 用 CURRENT 作为兜底，违反"MANIFEST 是唯一权威"

- **设计要求**（§3.2 CURRENT / MANIFEST 权威性，line 600-66）：
  > MANIFEST 是 recovery 起点和 checkpoint 状态的权威源……CURRENT 只表示写入侧上次尝试记录的 active WAL segment hint……**不能作为 recovery 起点、终点或排除 segment 的依据**。
- **代码现状**：`wal/recover.go:109-124`
  ```go
  if mf.RecoverySegmentID > 0 { return mf.RecoverySegmentID, nil }
  if segID, ok := manifest.ReadCurrent(dir); ok { return segID, nil }  // ← 违规
  return 0, nil
  ```
- **后果**：CURRENT 是 best-effort 写入、可能落后 / 指向已被截断或未 durable-ready 的 segment。用它做 recovery 起点，要么漏恢复（如果它指向比 MANIFEST 更新的 segment，而那个 segment 实际上没 durable-ready），要么恢复出空集（如果它指向已被 MANIFEST 覆盖删除的旧 segment）。这条路径在 MANIFEST.RecoverySegmentID==0 时（首次创建 DB 或刚 flush 后尚未写过 MANIFEST）会被触发。
- **修复**：删掉 CURRENT 兜底，MANIFEST.RecoverySegmentID==0 时直接返回 0；CURRENT 仅在写入侧作为 hint 顺手写。

> **Oracle 修订（bg_ef425776）**：C2 在 Phase 1 比 C3 更严重。Phase 1 没有 flush，MANIFEST 一直保持 0；`segment_manager.go:47,90` 每次创建/轮转 segment 都会写 CURRENT 指向 active segment。**默认状态下首次 Recover 就会触发 fallback**，不需要"CURRENT 缺失/落后"这种特殊场景。修 C3 之前必须先修 C2，否则数据丢失窗口依然存在。

---

### C3. Recovery 主动写 MANIFEST，违反"recovery repair 不更新 MANIFEST"

- **设计要求**（§3.2 line 280）：
  > MANIFEST 只作为 checkpoint / recovery 起点元数据；**recovery repair 不更新 MANIFEST**，也不通过 MANIFEST 记录恢复终点。
- **代码现状**：`wal/recover.go:77,100`
  ```go
  if saveErr := manifest.Save(dir, result.NextSegmentID); saveErr != nil { ... }
  ```
  无论恢复成功还是尾部截断，都会把 MANIFEST.RecoverySegmentID 改写为"最后一个 segment + 1"。
- **后果**：**直接数据丢失**。设想 segment-5 是 active，写了若干 batch 后崩溃，恢复时只重放了部分 batch 并把尾部截断。代码把 MANIFEST 推进到 segment-6。**下次启动时 recovery 直接从 segment-6 开始，跳过了 segment-5 中已经 durable 的 batch**。设计明确要求 MANIFEST 只能由 checkpoint（MemTable flush 完成 + SSTable 元数据落盘）推进。
- **修复**：删掉 recovery 中的 `manifest.Save`。MANIFEST 推进只能发生在 flush 完成后（未来 §3.3 实现）。

> **Oracle 修订（bg_ef425776）**：单修 C3 不足以解决 Phase 1 数据丢失，必须和 C2 一起修。测试方向需要区分干净 WAL 和尾部损坏 WAL 两种幂等性。

---

### C4. 非尾段 fragment 状态被当成尾段损坏

- **设计要求**（§3.2 Fragment 状态机 line 731）：
  > Segment 边界不是合法的 fragment 边界……如果扫描到 WAL 尾部时仍处于 CollectingFragments 状态……丢弃该 incomplete batch。设计同时明确：**CollectingFragments + 不是最后恢复 segment → 视为 WAL 中间损坏，报错**。
- **代码现状**：`wal/recovery.go:133-138` 在 `ReplaySegmentFile` 末尾，只要处于 `FragmentCollecting` 就返回 `TailCorruptionError`，**不区分当前 segment 是否是最后一个**；`RecoverFromSegments` 也直接透传。
- **后果**：如果 segment-5 中间出现 `First + Middle*` 但没有 `Last`（中间损坏），但 segment-6 还存在，代码会把 segment-5 的中间损坏当尾部截断处理，截掉 segment-6 的有效数据。设计要求此时硬报错。
- **修复**：`ReplaySegmentFile` 需要接收 `isLast bool` 参数，或者在 `RecoverFromSegments` 的 segment 循环里检查：非最后段返回 `CollectingFragments` 必须返回普通 corruption 错误，不是 `TailCorruptionError`。

> **Oracle 修订（bg_ef425776）**：C4 还有一个更严重的失败模式。`wal/recover.go:61` 总是对 `segments[len(segments)-1]` 调用 `truncateSegment`，但 `RecoverFromSegments` 的 `TailCorruptionError` 可能来自非尾段。结果：真正损坏的 segment 不动，最后一段的有效数据被错误截掉。

---

### C5. 尾部截断不 fsync、不删空 segment、不 fsync 目录，且失败被吞

- **设计要求**（§3.2 尾部截断持久化 line 786-800）：
  > 1. ftruncate 当前 active segment 到 lastCompleteBatchEnd
  > 2. **fsync 被截断的 segment**
  > 3. 删除 startSequence == expectedSequence 且不含任何 complete batch 的后续空 segment
  > 4. **fsync WAL directory**
  > 全部成功后 recovery 才能进入恢复完成状态。**若任一步失败，recovery 必须报错，DB 不得进入可写状态**。
- **代码现状**：`wal/recover.go:60-69`
  ```go
  validOffset, truncErr := findValidOffset(lastSeg.FilePath)
  if truncErr != nil {
      result.TruncateError = fmt.Errorf("%w (find valid offset: %v)", err, truncErr)  // 吞掉
  } else if truncErr := truncateSegment(lastSeg.FilePath, validOffset); truncErr != nil {
      result.TruncateError = fmt.Errorf("%w (truncate: %v)", err, truncErr)          // 吞掉
  }
  ```
  `truncateSegment` 只是 `os.Truncate`，没有 fsync 文件、没有删后续空 segment、没有 fsync 目录，失败也只是写进 `result.TruncateError` 然后**正常返回成功**。
- **后果**：崩溃恢复后 WAL 尾部可能再次暴露已被"截断"的脏字节，下次启动会重复 repair 或 repair 出不同边界；DB 已经接受新写入，破坏了"恢复后的 WAL 状态即权威"这一不变量。
- **修复**：把 truncation 拆成独立函数，按设计 4 步串行执行，任何一步失败 `return err`，DB.Open 必须失败。

---

### C6. SegmentWriter 创建时目录 fsync 失败被静默忽略

- **设计要求**（§3.2 WAL 元数据持久化协议 line 248-273）：durable-ready 协议第 5 步 `fsync WAL directory` 是 segment 进入 durable-ready 的硬条件。目录 fsync 失败时 segment **不得**承载可确认写入；若已有 Batch 依赖该 segment，进入 write-stopped。
- **代码现状**：`wal/segment_writer.go:86-89`
  ```go
  if dirFD, derr := os.Open(dir); derr == nil {
      dirFD.Sync()   // 错误被忽略
      dirFD.Close()
  }
  ```
- **后果**：rename 已发生但目录元数据未落盘。掉电后恢复可能看不到这个 segment 文件，但 WAL writer 已经向调用方确认了该 Batch 成功（"不丢已确认写入"被破坏）。这是 §3.2 Always 策略下最严重的 durability 漏洞之一。
- **修复**：目录 fsync 错误必须返回，触发 write-stopped；同时显式管理 durable-ready 状态机（新字段 `durableReady bool`），未就绪时禁止 AppendBatch。

---

### C7. Put/Delete vs Close 存在 send-on-closed-channel 竞态

- **代码现状**：`wal/writer.go:314-326`（Put）先检查 `writeStopped`，再 `queue.Submit`；而 `Close` 先 `writeStopped.Store(true)` 再 `queue.Close()`。两者之间没有同步。
- **后果**：并发调用 Put 和 Close 时，Put 通过 writeStopped 检查后、Submit 之前，Close 把 channel 关掉，Put 的 `cq.ch <- req` 触发 panic。这不是 §3.2 设计直接约束，但破坏写入路径稳定性。
- **修复**：Close 用 RWMutex 保护，Submit 用 RLock 检查 closed 标志；或者把 close 时机延后到所有 in-flight submit 完成。

---

### C8. Segment 轮转时把字节偏移当 sequence number 传给新 segment（Oracle bg_2e86d33b 发现）

- **设计要求**（§3.2 WAL File Header、Segment 连续性校验 line 639-663）：每个 segment 的 `startSequence` 必须严格衔接前一 segment 最后一个 batch 后的 sequence；recovery 时 `segment.StartSequence != expectedSequence` 必须报错。
- **代码现状**：`wal/segment_manager.go:64-66`
  ```go
  if sm.active.RemainingPayload() < worstCaseSize {
      if err := sm.rotate(sm.active.CurrentOffset()); err != nil { ... }
  }
  ```
  `rotate(newStartSequence uint64)` 的参数名是 `newStartSequence`，但传入的 `sm.active.CurrentOffset()` 返回的是**字节偏移**（从 `WalFileHeaderSize=32` 累加），不是 sequence number。
- **后果**：发生 segment 轮转后，新 segment 的 header 里 `startSequence = 上一 segment 的字节偏移`。Recovery 时 `wal/recovery.go:155-157`：
  ```go
  if segment.StartSequence != nextSequence { error }
  ```
  立刻失败。**Phase 1 多 segment 场景的 recovery 实际上是坏的**。
- **修复**：`SegmentManager` 需要跟踪当前 `nextSequence`（或从 WalWriter 拿），rotate 时把它传下去，而不是 `CurrentOffset()`。
- **对 C2+C3 patch 的影响**：相关 e2e 测试（`TestOpenThreeTimesKeepsData`）必须**单 segment**，不能强制轮转，否则会撞上 C8 而不是 C2+C3。

---

## 🟠 High（破坏格式约束或重要不变量）

### H1. Physical Record 不校验 `length > 0`

- **设计要求**（§3.2 Block 边界处理、Physical Record 解析规则 line 389, 681）：`length` 必须 `> 0`。
- **代码现状**：`wal/record.go:38-67` `DecodePhysicalRecord` 只校验 `length <= len(data)-headerSize`，没校验下界。`wal/record_parser.go:69` 也没校验。
- **后果**：攻击者 / 损坏数据可注入 `length=0` 的 record，绕过 CRC（payload 为空时 CRC 只覆盖 length+type），恢复出空 batch 进而触发 `entry count is zero` 之类的错误，被误判为"尾部损坏可截断"，实际上属于中间损坏。
- **修复**：`DecodePhysicalRecord` 加 `if length == 0 { return ErrZeroLength }`，并在 parser 把它当中间损坏（非尾部不截断）。

---

### H2. Physical Record 不校验 `type != Invalid (0)`

- **设计要求**（§3.2 fragment 类型表 line 366-372）：type=0 是 invalid，用于损坏检测。
- **代码现状**：`wal/record.go` 解析时不校验 type；`FragmentCollector.Append` 的 default 分支会拒绝，但 `ParseBlock` 收集 records 时不会。
- **后果**：损坏的 type=0 record 会被加入 records 列表，传给 fragment collector 后才报错；这把"物理层损坏"推迟到"batch 层错误"，错误分类可能错（按设计应该硬错，但实际可能被当 CRC 失败归为尾部损坏）。
- **修复**：`DecodePhysicalRecord` 校验 `recType ∈ {1,2,3,4}`，否则返回错误。

---

### H3. `worstCaseSize` 估算不足，segment 可能写超 `MaxSegmentSize`

- **设计要求**（§3.2 Segment Rotation 约束、config 不变量 line 392-451）：判断是否需要 rotate 时必须基于"真实最坏情况"开销：`numRecords * prHeaderSize + worstCaseBlockPadding`，对默认配置最大 batch 是 903 + 7 = 910 bytes 开销。
- **代码现状**：`wal/segment_manager.go:62`
  ```go
  worstCaseSize := uint64(len(encodedBatch)) + uint64(PhysicalRecordHeaderSize) + uint64(PhysicalRecordHeaderSize)
  ```
  只加 14 bytes（2 × 7）。对接近 4MB 的大 batch，少估了约 896 bytes。
- **后果**：当 active segment 剩余 payload 在 `[encodedBatchSize + 14, encodedBatchSize + 910]` 区间时，代码认为放得下，实际写出后超过 `MaxSegmentSize`。ValidateBatchLimits 只校验"空 segment 能放下"，没校验"当前剩余能放下"。下一批次才会触发 rotate，期间 segment 文件实际大小超出配置上限。
- **修复**：把 `ValidateBatchLimits` 中已经算过的 `numRecords`/`overhead`/`padding` 计算抽成公共函数，segment_manager 用同样的公式判断 remaining。或者更稳：rotate 阈值改为 `if active.RemainingPayload() < minFreshSegmentPayload { rotate }`，即只要剩余不足以容纳最大合法 batch 就 rotate。

---

### H4. `MaxImmutableCount` 配置存在但从不强制

- **设计要求**（§3.2 写入流程 step ⑤ line 119）：
  > 若 Immutable MemTable 队列已达上限，该 Batch 必须在 WAL write 之前等待后台 flush 释放容量。
- **代码现状**：`wal/writer.go:244-265` `reserveMemTable` 只在 active 满时 rotate，**完全不检查 immutable 队列长度**。`MemTableList.rotateActive` 无脑 append。
- **后果**：写入速度快于 flush 时，immutable 队列无限增长，OOM；同时也违反了"WAL write 前等待 flush"的设计约束。
- **修复**：`reserveMemTable` 在 rotate 前检查 `len(immutable) >= MaxImmutableCount`，是则阻塞等待条件变量（需要 flush goroutine 来唤醒）。Phase 1 没有 flush，至少应该在超限时返回 `ErrWriteStopped` 或阻塞，而不是无脑 append。

---

### H5. Per-batch `(segmentID, endOffset, endSequence)` 跟踪缺失，durableSequence 推进模型错误

- **设计要求**（§3.2 line 205-245）：每个完整 Batch 必须记录 `(segmentID, endOffset, endSequence)`；后台 fsync worker snapshot `(segmentID, endOffset, endSequence)`；fsync 成功后只能推进到满足"segment 已 durable-ready + offset 覆盖 + endSequence 连续"的最大 batch。跨 segment 推进还需要目标 segment 的 header + rename + 目录都已 fsync。
- **代码现状**：`wal/sequence.go:68-78` `MarkDurable` 只是 CAS 把 durableSequence 推到 seq；`wal/writer.go:228` 每次 `processBatch` fsync 后立刻调用 `MarkDurable(lastSequence)`。
- **后果**：当前 Phase 1 只有 Always 模式 + 单 writer 串行 fsync，**功能上恰好正确**（每次 fsync 覆盖且只覆盖当前 batch，durableSequence == publishedSequence）。但一旦未来引入 Periodic / 异步 fsync / 多 batch 合并 fsync，模型立刻崩溃。设计明确要求"durableSequence 不能只根据'最近一次 fsync 成功'模糊推进"。
- **修复**：哪怕 Phase 1，也要按设计记录每 batch 的 `(segmentID, endOffset, endSequence)`，并实现 fsyncSnapshot 比较逻辑。否则是对未来扩展的债务性违约。

---

### H6. MemTable publish 机制不符合 release/acquire 模型

- **设计要求**（§3.2 step ⑧-⑩ line 121）：
  > MemTable skiplist / arena 节点必须先通过**原子发布机制**写入读路径可见结构（例如 `atomic.Pointer` store-release，或等价的 release publish），并且 Batch 内所有 entry 的节点都完成发布后，才能用 `atomic.Uint64.Store` 推进 `publishedSequence`。普通读者必须先 `Load` 当前 `publishedSequence`，再遍历 MemTable；读到 entry 后仍以 `entry.sequence <= loadedPublishedSequence` 判断可见性。
- **代码现状**：`memtable/memtable.go:87-127` `Publish`：
  1. 在 `skiplist.mu` 下遍历收集 pending 节点；
  2. **释放锁后**，对每个 entry 调用 `skiplist.Put(..., pending=false)` 创建**新节点**替换旧节点；
  3. `mt.published.Store(upToSequence)`。

  读路径 `skiplist.Get` 用 `node.pending` 判断可见性，**完全不读 `publishedSequence`**。
- **后果**：
  1. 读路径看到的可见性边界是"pending 标志位"，不是 `publishedSequence`。Phase 1 单 key autocommit 下语义等价，但设计要求的是后者。
  2. Publish 创建新节点替换旧节点，旧 pending 节点成为 GC 垃圾；多个 batch 同时 publish（理论上）会竞争同一 key 的替换路径。
  3. 弱内存序架构上，新节点写入（通过 `atomic.Pointer.Store`）发生在 `published.Store` 之前，**顺序对的**；但设计要求的 publish-then-load-sequence 模式没有被代码使用，未来引入 MVCC 的 `commitSequence` / `visibleCommitSequence` 时会失配。
  4. MemTable.published 字段写完后**没人读**，是死代码。
- **修复**：要么按设计实现：skiplist 节点带 sequence，Put 时直接 published（不 pending），读路径 Load `publishedSequence` 后过滤；要么保留 pending 模型但在设计文档里明确改写，并删除/利用 MemTable.published。

---

### H7. `arena.Reserve` 只是检查，不是真正预留

- **设计要求**（§3.2 step ⑤ line 119）：按 Batch 内所有 entry 的最大内存占用（key、value 或 ValueLogPointer、**skiplist 节点**、arena 对齐与**层高开销**）计算需要预留的 Arena 字节数。
- **代码现状**：`memtable/arena.go:75-84` `Reserve` 只读 offset 不修改；`memtable/memtable.go:55-70` 每条 entry 估算 `metadataOverhead = 32`，没考虑 `maxLevel * sizeof(pointer) = 20 * 8 = 160` bytes 的层高开销。skiplist 节点实际存在堆上（`newSkipNode` 不用 arena）。
- **后果**：Reserve 是个粗略的 budget gate，实际 heap 用量可能超过 MemTableSize；多线程并发 reserve 还可能 double-count（两个 batch 都看到 remaining 够，都写入，实际超限）。Phase 1 不用 arena 存节点，所以不会触发 ErrArenaFull，但容量预算失效。
- **修复**：要么把 Reserve 改成真 atomic advance offset（commit 预留），要么显式承认 Phase 1 是软限制并在设计里标注；估算公式按最坏层高（maxLevel=20）算。

---

## 🟡 Medium（设计偏离但不立即致错）

### H8. `findValidOffset` 不跟踪 batch 边界，截断点错位（Oracle bg_ef425776 升级 M1）

- **设计要求**（§3.2 尾部截断持久化 line 786）：截断目标必须是"最后一个完整 WAL Batch 的结束位置 `lastCompleteBatchEnd`"。
- **代码现状**：`wal/recover.go:137-203` 的 `findValidOffset` 只用 `DecodePhysicalRecord` 校验物理记录，**不跟踪完整 batch 边界 / fragment 状态机**。对尾部 `First + Middle*` 没 `Last` 的情况：
  - `recovery.go:133` 在最后一个完整 batch 结尾报尾部损坏
  - `findValidOffset` 可能返回不完整 fragment 之后的 EOF，**比真正应截断的位置更靠后**
- **后果**：残留半截 fragment 在文件尾部。下次启动 recovery 还会在同位置触发尾部损坏，**反复 repair，每次结果可能不同**。
- **修复**：让 `ParseRecordsFromFile` 直接返回 `lastValidOffset`（基于完整 batch 边界），作为 truncation 的权威依据；删掉独立的 `findValidOffset` 重复解析。

> 原 M1（"两次解析可能不一致"）已升级为 H8，因为 Oracle 给出具体证据：不跟踪 batch 边界会让截断点错位，导致反复 repair。

### M2. `EncodeWalBatch(0, entries)` 预校验后立刻丢弃，再编一次

- `wal/writer.go:192-204` 先用 baseSequence=0 编码一次只为校验，然后再用真实 baseSequence 编码。功能对（baseSequence 不影响 size），但白编一次 4MB buffer。
- **修复**：抽 `validateEncodedSize(entries)` 函数，只算 size 不分配 buffer；或者直接信任 `ValidateBatchLimits` 已经覆盖的检查（其实已经覆盖了），删掉这次预编码。

### M3. `SyncMode` 是字符串 `"always"`，不是设计里的策略枚举

- 设计明确三种策略 `Always` / `Periodic` / `Never`，Phase 1 只支持 Always。代码用裸字符串，类型不安全，未来加 Periodic 时容易漏改地方。
- **修复**：定义 `type SyncMode int` + 常量，配置序列化层做字符串映射。

### M4. `CommitQueue` 容量 = `MaxBatchEntries`，可能过大

- `wal/writer.go:82` `queueCapacity := max(1, int(cfg.MaxBatchEntries))` 默认 10000，意味着 channel 缓冲 10000 个 `*CommitRequest`。每个 request 至少带 1 个 entry 的 key/value clone。高并发下内存占用被低估。
- **修复**：独立配置项 `CommitQueueCapacity`，默认更小（例如 1024）。

### M5. `SegmentWriter` 没有 `durableReady` 状态字段

- 设计要求 segment 显式进入 durable-ready 状态才能承载可确认写入。代码隐式假设"构造函数返回即可写"，没有状态机字段。
- **修复**：加 `durableReady bool` 字段，`AppendBatch` 前断言；构造函数中所有 fsync 步聚通过后才置 true。

### M6. `GroupCommitDelay` 计时器语义偏离设计

- 设计："500µs 或 32KB，先到者触发"。代码 `runLoop` 每次从 channel 读到第一个 req 后启 timer，500µs 内继续收集。这相当于"从第一个 req 起等待 500µs"，与设计一致。但 timer 在每个 batch 循环重建，如果 channel 持续有 req，低负载时其实仍能 500µs 触发，OK。**不算 bug，但建议注释清楚**。

### M7. `MemTable.aborted` 是 `sync.Map` 且从不清理

- `memtable/memtable.go:36`。每次 `Abort(seq)` 写入，从不删除。长时间运行 + 频繁 abort 会内存泄漏。
- **修复**：Publish 时把已 publish 的 sequence 从 aborted 里删掉；或者用 bitmap。

### M8. `processRemaining` 在 write-stopped 后仍会处理队列

- `wal/writer.go:165-173` 在 close 时 drain 队列。但 `processBatch` 内部会先检查 `writeStopped`，如果已停止会直接 sendError。所以 drain 不会真正写 WAL，OK。**但需要测试覆盖这个路径**。

### M9. 错误消息中"segment 损坏"和"尾部损坏"混用，难审计

- 多处 `fmt.Errorf` 没用 `%w` 包装 `ErrWALCorrupted`，调用者难以 `errors.Is`。
- **修复**：定义 `ErrWALCorrupted` / `ErrWALTailCorrupted` 哨兵错误，所有 corruption 路径用 `%w` 包装。

---

## 🟢 Low / 建议

- **L1**：`wal/header.go` 把 `walFileHeaderSize` 等小写常量和 `wal/header.go` 与 `wal/constants.go` 中重复的大写常量合并，避免两套维护。
- **L2**：`wal/segment_writer.go:42` `os.O_EXCL` 在并发 Open 同一 tmp 名时直接失败，错误信息可加上"可能是上次崩溃残留"。
- **L3**：`memtable/skiplist.go:209` `rand.Float64()` 非并发安全（Go 1.20+ 默认 auto-seed 后是安全的，但显式用 `math/rand/v2` 更清楚）。
- **L4**：`config/config.go:124` `SyncMode != "always"` 错误消息建议列出合法值。
- **L5**：测试覆盖建议补：① crc32c 固定向量；② 非尾段 CollectingFragments 必须 hard error；③ MANIFEST 不被 recovery 修改；④ Put(k, []) 与 Delete(k) 的 Get 结果在恢复后仍能区分；⑤ dir fsync 失败时 segment 不可写。

---

## 总结（必须立刻修的）

| 编号 | 一句话 | 风险等级 |
|------|--------|----------|
| **C1** | CRC 多项式错（IEEE → crc32c） | 格式不符规范 |
| **C2** | Recovery 用 CURRENT 兜底 | 恢复起点错误 |
| **C3** | Recovery 写 MANIFEST | **下次启动跳过有效 segment，丢数据** |
| **C4** | 非尾段 CollectingFragments 当尾段损坏 | 中间损坏被静默截断 |
| **C5** | 截断无 fsync / 不删空 segment / 失败被吞 | **重复 repair，DB 状态不一致** |
| **C6** | SegmentWriter dir fsync 失败被吞 | **掉电后已确认写入消失** |
| **C7** | Put vs Close 的 channel 竞态 | 偶发 panic |
| **C8** | Segment 轮转把字节偏移当 startSequence | **多 segment recovery 直接失败** |

C3 和 C6 是最严重的：C3 直接导致数据丢失，C6 直接破坏 Always 模式的"不丢已确认写入"承诺。C8 是隐藏炸弹：单 segment 时一切正常，第一次轮转后就坏。建议优先修复 C1-C8 这 8 条，再处理 H1-H7。

---

## 修复优先级建议

按数据丢失风险排：**C3 → C6 → C5 → C2 → C4 → C1 → C7**，再处理 H1-H7。

> **Oracle 修订（bg_2e86d33b）**：实际上 C2 和 C3 必须**捆绑**修复（C2 的触发条件在 Phase 1 默认状态下就成立，比 C3 更宽松），单独修 C3 不止血。修订后优先级：**C2 + C3（一起）→ C6 → C5 + H8（一起，同一文件）→ C4 → C8 → C1 → C7**。
