SegmentManager.AppendBatch was passing sm.active.CurrentOffset() (byte
offset from file header) as the new segment's startSequence on rotation.
The result: segment-N+1's header.startSequence was a byte count (e.g.
50000), not the actual sequence number. Recovery's continuity check at
recovery.go:181-184 (segment.StartSequence != expectedSequence) failed,
making Phase 1 multi-segment recovery completely broken.
Oracle bg_ef425776 noted: "C8 是隐藏炸弹:单 segment 时一切正常,
第一次轮转后就坏".
Changes:
- wal/segment_manager.go: AppendBatch now takes batchStartSequence uint64
parameter. On rotation, passes it to rotate (which writes it to the new
segment's header.startSequence). The previous byte-offset argument is
replaced by the actual sequence number.
- wal/writer.go: processBatch passes baseSequence (already allocated by
seqManager.AllocateBatch) to AppendBatch.
- wal/segment_manager_test.go: 6 existing AppendBatch call sites updated
to pass batchStartSequence (tracked via local currentSeq variable).
Added 2 new tests:
- TestSegmentManagerRotationWritesCorrectStartSequence: verifies new
segment's header.startSequence matches the first rotated batch's
sequence (and explicitly != old byte offset, catching C8 regression).
- TestSegmentManagerMultiSegmentRecoveryRoundTrip: end-to-end test that
writes across multiple segments, closes, recovers, and verifies all
batches replay. Before C8 fix, recovery failed at continuity check.
Verified: each new test fails on pre-fix code (segment-1 startSequence
is byte offset, recovery fails) and passes after the fix. Full suite
green including go test -race ./... .
Audit context: docs/audit-3.2.md C8 (Oracle-discovered bg_2e86d33b).
15 KiB
C8 修复方案:SegmentManager 轮转时传正确的 startSequence
TL;DR
目标:让 SegmentManager 在 segment 轮转时把真正的 sequence number 写入新 segment header,而不是当前的
CurrentOffset()(字节偏移)。修复后 Phase 1 多 segment 场景的 recovery 能正常通过segment.startSequence == expectedSequence校验。交付:
SegmentManager.AppendBatch加batchStartSequence uint64参数rotate调用从sm.active.CurrentOffset()改为batchStartSequenceWalWriter.processBatch传baseSequence给 AppendBatch- 6 个测试调用点 + 1 个 production 调用点签名更新
- 2 个新测试:单元测试验证 segment header startSequence + e2e 多 segment recovery
- 单次 commit
预估工时:1.5-2 小时 风险:低。改动局限在签名 + 一个调用值
Context
Bug 摘要
wal/segment_manager.go:64-67:
if sm.active.RemainingPayload() < worstCaseSize {
if err := sm.rotate(sm.active.CurrentOffset()); err != nil { // ← bug
return fmt.Errorf("wal: rotate segment: %w", err)
}
}
CurrentOffset() 返回字节偏移(从 WalFileHeaderSize=32 累加),不是 sequence number。rotate(newStartSequence uint64) 把这个值写入新 segment header 的 startSequence 字段。
类型 mismatch 隐藏:rotate 的参数叫 newStartSequence(uint64),CurrentOffset() 返回 uint64,编译器抓不到。
失败场景
segment-0: startSequence=0, 写入 100 batches (seq 0..99)
currentOffset = 32 + encoded_bytes (e.g. 50000)
触发轮转 → segment-1: header.startSequence = 50000 ← bug
写 batch seq 100..199 到 segment-1
重启 recovery:
segment-0: startSeq=0, replay 100 batches, nextSequence=100
segment-1: startSeq=50000, expected=100 → mismatch → ERROR
Oracle 发现(bg_ef425776)
C8 是隐藏炸弹:单 segment 时一切正常,第一次轮转后就坏。
设计依据
docs/design.md §3.2 line 639-663 Segment 连续性校验:
for segment in recoverySegments:
require segment.segmentID == expectedSegmentID
require segment.startSequence == expectedSequence ← C8 在这失败
recover all complete batches in segment
expectedSegmentID += 1
expectedSequence = next sequence after last recovered batch
协同:C4 / C5+H8
- C4 已修:非尾段损坏硬错误
- C5+H8 已修:truncation 持久化 + batch-aware offset
- C8 修复后:多 segment recovery 能跑通 → C4/C5+H8 的多 segment 行为才能在生产中体现
执行计划
Phase A:代码改动(15-30 分钟)
A.1 SegmentManager.AppendBatch 加 batchStartSequence 参数
文件:wal/segment_manager.go
// 改前:
func (sm *SegmentManager) AppendBatch(encodedBatch []byte) error {
worstCaseSize := uint64(len(encodedBatch)) + uint64(PhysicalRecordHeaderSize) + uint64(PhysicalRecordHeaderSize)
if sm.active.RemainingPayload() < worstCaseSize {
if err := sm.rotate(sm.active.CurrentOffset()); err != nil { // ← bug
return fmt.Errorf("wal: rotate segment: %w", err)
}
}
return sm.active.AppendBatch(encodedBatch)
}
// 改后:
// AppendBatch writes encodedBatch to the active segment, rotating first if
// the batch doesn't fit. batchStartSequence is the sequence number of the
// FIRST entry in this batch — used as the new segment's startSequence when
// rotation occurs, so multi-segment recovery's continuity check passes per
// design §3.2 line 639-663.
func (sm *SegmentManager) AppendBatch(encodedBatch []byte, batchStartSequence uint64) error {
worstCaseSize := uint64(len(encodedBatch)) + uint64(PhysicalRecordHeaderSize) + uint64(PhysicalRecordHeaderSize)
if sm.active.RemainingPayload() < worstCaseSize {
// C8 fix: new segment's first batch is THIS batch, so its
// startSequence must equal batchStartSequence (not byte offset).
if err := sm.rotate(batchStartSequence); err != nil {
return fmt.Errorf("wal: rotate segment: %w", err)
}
}
return sm.active.AppendBatch(encodedBatch)
}
必要 docstring:解释
batchStartSequence的用途(防止未来调用方传错值,类似当前 C8 bug)。属于 security-related 注释。
A.2 WalWriter.processBatch 传 baseSequence
文件:wal/writer.go:210
// 改前:
if err := ww.segManager.AppendBatch(encoded); err != nil {
// 改后:
if err := ww.segManager.AppendBatch(encoded, baseSequence); err != nil {
baseSequence 在 processBatch 函数内已经通过 seqManager.AllocateBatch 分配(writer.go:197),直接传给 AppendBatch。
A.3 测试调用点更新
文件:wal/segment_manager_test.go(6 个调用点)
// 改前(每个调用):
if err := sm.AppendBatch(batch); err != nil {
// 改后(每个调用,加 sequence 参数):
if err := sm.AppendBatch(batch, currentSeq); err != nil {
t.Fatalf("AppendBatch: %v", err)
}
currentSeq += uint64(len(batch.entries)) // 模拟 sequence 推进
需要为每个测试维护一个本地 currentSeq 变量,模拟 seqManager 的行为。具体修改见 B.3。
Phase B:测试(60-90 分钟)
B.1 单元测试:segment header startSequence 正确性
Oracle 修订(bg_282d3b17)BLOCKING:原版循环跑 100 个 batch,每次轮转覆盖
rotationSeq。但 segment-1 的 header 是第一次轮转时创建的,多次轮转后断言会用错误的 sequence。修复:第一次轮转后就 break,记录firstRotationSeq。同时加!= oldOffset断言显式抓 C8 regression。
新增到 wal/segment_manager_test.go:
// Regression guard for C8: after rotation, the new segment's header
// startSequence must equal the rotated batch's baseSequence, NOT the
// previous segment's byte offset.
func TestSegmentManagerRotationWritesCorrectStartSequence(t *testing.T) {
dir := t.TempDir()
cfg := tinyWalConfig()
sm, err := NewSegmentManager(dir, 0, 0, cfg)
if err != nil {
t.Fatalf("NewSegmentManager: %v", err)
}
defer sm.Close()
var firstRotationSeq uint64
var oldOffset uint64 // byte offset of segment-0 right before first rotation
var hadRotation bool
for i := 0; ; i++ {
// Snapshot offset + segment ID BEFORE append to capture pre-rotation state.
offsetBefore := sm.active.CurrentOffset()
segIDBefore := sm.ActiveSegmentID()
encoded, err := EncodeWalBatch(uint64(i), []*WalEntry{
makePutEntry(fmt.Sprintf("k%d", i), "v"),
})
if err != nil {
t.Fatalf("EncodeWalBatch %d: %v", i, err)
}
if err := sm.AppendBatch(encoded, uint64(i)); err != nil {
t.Fatalf("AppendBatch %d: %v", i, err)
}
if !hadRotation && sm.ActiveSegmentID() != segIDBefore {
// First rotation just happened.
hadRotation = true
firstRotationSeq = uint64(i)
oldOffset = offsetBefore
break // Stop at first rotation to avoid overwriting.
}
}
if !hadRotation {
t.Skip("no rotation occurred with tinyWalConfig; test setup needs adjustment")
}
// Read segment-1 header and verify startSequence.
seg1Path := filepath.Join(dir, "segment-1.wal")
if _, err := os.Stat(seg1Path); err != nil {
t.Fatalf("segment-1 should exist after rotation: %v", err)
}
seg1Header := readSegmentHeader(t, seg1Path)
if seg1Header.StartSequence != firstRotationSeq {
t.Errorf("segment-1 startSequence = %d, want %d (first rotation sequence)",
seg1Header.StartSequence, firstRotationSeq)
}
// Explicit C8 regression check: buggy code would set startSequence = oldOffset.
if seg1Header.StartSequence == oldOffset {
t.Errorf("segment-1 startSequence = %d matches old byte offset (C8 regression)",
seg1Header.StartSequence)
}
}
func readSegmentHeader(t *testing.T, path string) *WalFileHeader {
t.Helper()
f, err := os.Open(path)
if err != nil {
t.Fatalf("Open %s: %v", path, err)
}
defer f.Close()
hdrBuf := make([]byte, WalFileHeaderSize)
if _, err := io.ReadFull(f, hdrBuf); err != nil {
t.Fatalf("read header: %v", err)
}
hdr, err := DecodeWalHeader(hdrBuf)
if err != nil {
t.Fatalf("DecodeWalHeader: %v", err)
}
return hdr
}
B.2 集成测试:多 segment recovery 端到端
新增到 wal/segment_manager_test.go 或 db_test.go:
// Regression guard for C8: writing across multiple segments, then closing
// and reopening, must successfully recover ALL data. Before C8 fix, the
// second segment's startSequence was a byte offset, causing recovery's
// continuity check to fail.
func TestSegmentManagerMultiSegmentRecoveryRoundTrip(t *testing.T) {
dir := t.TempDir()
cfg := tinyWalConfig()
sm, err := NewSegmentManager(dir, 0, 0, cfg)
if err != nil {
t.Fatalf("NewSegmentManager: %v", err)
}
// Write enough batches to force rotation.
const totalBatches = 50
for i := 0; i < totalBatches; i++ {
encoded, err := EncodeWalBatch(uint64(i), []*WalEntry{
makePutEntry(fmt.Sprintf("k%d", i), fmt.Sprintf("v%d", i)),
})
if err != nil {
t.Fatalf("EncodeWalBatch %d: %v", i, err)
}
if err := sm.AppendBatch(encoded, uint64(i)); err != nil {
t.Fatalf("AppendBatch %d: %v", i, err)
}
}
if err := sm.Close(); err != nil {
t.Fatalf("Close: %v", err)
}
// Verify multiple segments were created.
entries, _ := os.ReadDir(dir)
segCount := 0
for _, e := range entries {
if _, ok := ParseSegmentFilename(e.Name()); ok {
segCount++
}
}
if segCount < 2 {
t.Fatalf("expected at least 2 segments after rotation, got %d", segCount)
}
// Recover and verify all batches replay.
replayer := &mockReplayer{}
result, err := Recover(dir, replayer)
if err != nil {
t.Fatalf("Recover failed (C8 regression): %v", err)
}
if result.NextSequence != totalBatches {
t.Errorf("NextSequence = %d, want %d", result.NextSequence, totalBatches)
}
if len(replayer.puts) != totalBatches {
t.Errorf("replayed puts = %d, want %d", len(replayer.puts), totalBatches)
}
}
B.3 现有测试调用点签名更新
Oracle 修订(bg_282d3b17):原计划写
currentSeq += uint64(len(batch.entries))是错的,因为现有segment_manager_test.go的调用点用的是 dummy[]byte(不是带 entries 的 batch struct)。改成"每次 append 后 currentSeq++"(假设每个 batch 1 entry)。
wal/segment_manager_test.go 的 6 个调用点:
- Line 84, 119, 132, 180, 295, 304
每个调用点的 batchStartSequence 值需要根据测试上下文确定。大多数测试用 startSequence=0,所以维护一个本地 currentSeq := uint64(0),每次 append 后 currentSeq++(假设每个 batch 1 entry)。具体修改在执行阶段读上下文做。
Phase C:验证(15 分钟)
# 1. 编译
go build ./...
# 2. 重点测试
go test ./wal -run 'TestSegmentManagerRotation|TestSegmentManagerMultiSegment|TestSegmentManager' -count=1 -v
# 3. wal 包全量
go test ./wal/... -count=1
# 4. 全仓
go test ./... -count=1
# 5. race
go test -race ./... -count=1
# 6. vet
go vet ./...
Phase D:Commit message draft
fix: write correct startSequence on segment rotation (C8)
SegmentManager.AppendBatch was passing sm.active.CurrentOffset() (byte
offset from file header) as the new segment's startSequence on rotation.
The result: segment-N+1's header.startSequence was a byte count (e.g.
50000), not the actual sequence number. Recovery's continuity check at
recovery.go:181-184 (segment.StartSequence != expectedSequence) failed,
making Phase 1 multi-segment recovery completely broken.
Oracle bg_ef425776 noted: "C8 是隐藏炸弹:单 segment 时一切正常,
第一次轮转后就坏".
Changes:
- wal/segment_manager.go: AppendBatch now takes batchStartSequence uint64
parameter. On rotation, passes it to rotate (which writes it to the new
segment's header.startSequence). The previous byte-offset argument is
replaced by the actual sequence number.
- wal/writer.go: processBatch passes baseSequence (already allocated by
seqManager.AllocateBatch) to AppendBatch.
- wal/segment_manager_test.go: 6 existing AppendBatch call sites updated
to pass batchStartSequence. Added 2 new tests:
- TestSegmentManagerRotationWritesCorrectStartSequence: verifies new
segment's header.startSequence matches the first rotated batch's
sequence (and explicitly != old byte offset, catching C8 regression).
- TestSegmentManagerMultiSegmentRecoveryRoundTrip: end-to-end test that
writes across multiple segments, closes, recovers, and verifies all
batches replay. Before C8 fix, recovery failed at continuity check.
Verified: each new test fails on pre-fix code (segment-1 startSequence
is byte offset, recovery fails) and passes after the fix. Full suite
green including go test -race ./... .
Audit context: docs/audit-3.2.md C8 (Oracle-discovered bg_2e86d33b).
验收清单
- Phase A.1:
AppendBatch(encodedBatch []byte, batchStartSequence uint64)签名存在 - Phase A.1:
rotate调用从CurrentOffset()改为batchStartSequence - Phase A.2:
writer.go:210传baseSequence - Phase A.3:6 个测试调用点全部更新
- Phase B.1:
TestSegmentManagerRotationWritesCorrectStartSequence存在 - Phase B.2:
TestSegmentManagerMultiSegmentRecoveryRoundTrip存在 go test ./wal/... -count=1全绿go test ./... -count=1全绿go test -race ./... -count=1全绿go vet ./...无新增警告- 单次 commit,message 引用 audit C8
不在本次范围内(后续 issue)
| 编号 | 为什么不放进来 |
|---|---|
| C1 | CRC 多项式 IEEE → crc32c,独立 |
| C7 | Put/Close 竞态,独立 |
| H1-H7 | 其他 High,独立 |
修订记录
- v1(原始):C8 修复方案初稿,送 Momus 审
- v1.0(Momus 审核 bg_22b882bf):[OKAY],无 blocking。提醒 B.1 测试需要
fmtimport(执行时补) - v1.1(Oracle 修订 bg_282d3b17):
- BLOCKING:B.1 测试逻辑错 —— 循环跑 100 个 batch,每次轮转覆盖
rotationSeq,但 segment-1 的 header 是第一次轮转时创建的。修复:第一次轮转后 break,记录firstRotationSeq - NEW:B.1 加
!= oldOffset断言,显式抓 C8 regression(buggy 代码下值是字节偏移) - FIX:A.3 的
currentSeq += len(batch.entries)误导(现有测试用 dummy[]byte),改成"每次 append 后 currentSeq++" - FIX:commit message 行号
recovery.go:155过期(C4 fix 后行号变了),改成recovery.go:181-184 - MINOR:
readSegmentHeader用io.ReadFull而不是f.Read(避免 short read) - NOTE:B.2 测试必须留在 wal 包(用
mockReplayer,是包内部)
- BLOCKING:B.1 测试逻辑错 —— 循环跑 100 个 batch,每次轮转覆盖