fix: treat non-last segment corruption as hard error (C4)

Per design §3.2 line 704, tail corruption in a non-last WAL segment is
middle corruption, which must hard-fail recovery instead of being
silently truncated. The previous code in wal/recovery.go always returned
TailCorruptionError for CollectingFragments state or parse errors,
regardless of segment position. Recover then always truncated
segments[last], which could corrupt a valid last segment when the actual
corruption was in a middle segment.

Oracle bg_ef425776 flagged an additional failure mode: "wal/recover.go
总是对 segments[len(segments)-1] 调用截断,但 RecoverFromSegments 的
TailCorruptionError 可能来自非尾段".

Changes:
- wal/record_parser.go: add SegmentPath field to TailCorruptionError for
  diagnostics and defensive truncation target identification.
- wal/recovery.go:
  - ReplaySegmentFile now takes isLastSegment bool parameter.
  - When parse error or CollectingFragments occurs in non-last segment,
    return hard error. Uses %v (NOT %w) so IsTailCorruption returns false
    — otherwise errors.As would still find underlying TailCorruptionError
    through the %w chain and Recover would treat it as truncatable.
  - When in last segment, return TailCorruptionError with SegmentPath set.
  - RecoverFromSegments passes isLastSegment based on iteration index.
- wal/recover.go:
  - Use tce.SegmentPath as authoritative truncation target (defensive
    fallback to segments[last] if missing). After C4 fix, TailCorruptionError
    is only returned for last segment, so this is always segments[last]
    in practice.

Tests:
- wal/recovery_test.go: 4 unit tests for ReplaySegmentFile covering
  last/non-last × CollectingFragments/parse-error matrix. Existing direct
  ReplaySegmentFile calls updated to pass isLastSegment=true.
- wal/recover_test.go: 4 integration tests covering middle-segment
  CollectingFragments corruption (must hard-fail), middle-segment CRC
  corruption (must hard-fail), last-segment corruption in single-segment
  WAL (must truncate), last-segment corruption in multi-segment WAL
  (must truncate only last segment).

Verified: each new test fails on pre-fix code (non-last corruption
silently truncated valid last segment) and passes after the fix. Full
suite green including go test -race ./... .

Audit context: docs/audit-3.2.md C4 (Oracle-verified bg_ef425776).
This commit is contained in:
dailz
2026-06-16 09:12:24 +08:00
parent 273229ac9b
commit 3d2d0ea025
6 changed files with 836 additions and 17 deletions
+150 -1
View File
@@ -118,7 +118,7 @@ func TestRecoverWithTailCorruption(t *testing.T) {
// Verify the truncated file still parses cleanly.
replayer2 := &mockReplayer{}
_, parseErr := ReplaySegmentFile(filePath, 100, replayer2)
_, parseErr := ReplaySegmentFile(filePath, 100, true, replayer2)
if parseErr != nil {
t.Fatalf("replay after truncation: %v", parseErr)
}
@@ -451,3 +451,152 @@ func TestRecoverInvalidBatchNotTruncatable(t *testing.T) {
fi.Size(), int64(WalFileHeaderSize)+int64(len(rec)))
}
}
// -------- C4 integration regression guards --------
// Regression guard for C4: middle segment CollectingFragments must hard-fail
// Recover, NOT truncate the (valid) last segment. This is the key bug Oracle
// flagged: "wal/recover.go 总是对 segments[len(segments)-1] 调用截断".
func TestRecoverMiddleSegmentCorruptionHardFails(t *testing.T) {
dir := t.TempDir()
// segment-0: [Batch seq 0-1] (2 entries → next=2)
// segment-1: [Batch seq 2-3] + [First][Middle no Last] ← middle corruption
// (2 complete entries → next=4 if recovery reached end)
// segment-2: [Batch seq 4-5] (valid; never reached)
writeTestSegment(t, dir, 0, 0, [][]*WalEntry{
{makePutEntry("k0", "v0"), makePutEntry("k1", "v1")},
})
seg1Path := writeTestSegment(t, dir, 1, 2, [][]*WalEntry{
{makePutEntry("k2", "v2"), makePutEntry("k3", "v3")},
})
appendFileBytes(t, seg1Path, EncodePhysicalRecord(RecFirst, []byte("first-frag")))
appendFileBytes(t, seg1Path, EncodePhysicalRecord(RecMiddle, []byte("middle-frag")))
writeTestSegment(t, dir, 2, 4, [][]*WalEntry{
{makePutEntry("k4", "v4"), makePutEntry("k5", "v5")},
})
seg2Path := filepath.Join(dir, "segment-2.wal")
fiBefore, _ := os.Stat(seg2Path)
_, err := Recover(dir, &mockReplayer{})
if err == nil {
t.Fatal("expected Recover to fail on middle segment corruption")
}
if IsTailCorruption(err) {
t.Errorf("expected HARD error (not tail corruption) for middle segment; got %v", err)
}
// CRITICAL: segment-2 must NOT be truncated (it's completely valid).
fiAfter, _ := os.Stat(seg2Path)
if fiAfter.Size() != fiBefore.Size() {
t.Errorf("segment-2 was modified: before=%d after=%d (C4-2 regression)",
fiBefore.Size(), fiAfter.Size())
}
}
// Regression guard for C4 (parser corruption half): middle segment CRC
// corruption must hard-fail Recover, NOT truncate the (valid) last segment.
// Tests the path where ParseBlock returns TailCorruptionError and
// ReplaySegmentFile converts it to hard error for non-last segment.
func TestRecoverMiddleSegmentCRCCorruptionHardFails(t *testing.T) {
dir := t.TempDir()
writeTestSegment(t, dir, 0, 0, [][]*WalEntry{
{makePutEntry("k0", "v0"), makePutEntry("k1", "v1")},
})
seg1Path := writeTestSegment(t, dir, 1, 2, [][]*WalEntry{
{makePutEntry("k2", "v2"), makePutEntry("k3", "v3")},
})
// Append CRC-corrupted bytes (will fail DecodePhysicalRecord CRC check).
appendFileBytes(t, seg1Path, []byte{0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF})
writeTestSegment(t, dir, 2, 4, [][]*WalEntry{
{makePutEntry("k4", "v4"), makePutEntry("k5", "v5")},
})
seg2Path := filepath.Join(dir, "segment-2.wal")
fiBefore, _ := os.Stat(seg2Path)
_, err := Recover(dir, &mockReplayer{})
if err == nil {
t.Fatal("expected Recover to fail on middle segment CRC corruption")
}
if IsTailCorruption(err) {
t.Errorf("expected HARD error (not tail corruption); got %v", err)
}
fiAfter, _ := os.Stat(seg2Path)
if fiAfter.Size() != fiBefore.Size() {
t.Errorf("segment-2 modified: before=%d after=%d", fiBefore.Size(), fiAfter.Size())
}
}
// Regression guard for C4: single-segment tail corruption still truncates
// correctly (existing behavior preserved).
func TestRecoverLastSegmentCorruptionTruncatesCorrectly(t *testing.T) {
dir := t.TempDir()
batchA := []*WalEntry{makePutEntry("k0", "v0")}
batchB := []*WalEntry{makePutEntry("k1", "v1")}
filePath := writeTestSegment(t, dir, 0, 0, [][]*WalEntry{batchA, batchB})
encA, _ := EncodeWalBatch(0, batchA)
encB, _ := EncodeWalBatch(1, batchB)
endOfBatch1 := int64(WalFileHeaderSize) +
int64(PhysicalRecordHeaderSize+len(encA)) +
int64(PhysicalRecordHeaderSize+len(encB))
// Append partial fragments to last (only) segment.
appendFileBytes(t, filePath, EncodePhysicalRecord(RecFirst, []byte("first")))
appendFileBytes(t, filePath, EncodePhysicalRecord(RecMiddle, []byte("middle")))
result, err := Recover(dir, &mockReplayer{})
if err != nil {
t.Fatalf("Recover: %v", err)
}
if !result.Truncated {
t.Fatal("Truncated = false, want true for last-segment corruption")
}
fi, _ := os.Stat(filePath)
if fi.Size() != endOfBatch1 {
t.Errorf("file size = %d, want %d", fi.Size(), endOfBatch1)
}
}
// Regression guard for C4: multi-segment with last-segment corruption still
// works correctly (the legitimate tail-truncation case).
func TestRecoverMultiSegmentLastSegmentCorruptionTruncatesLast(t *testing.T) {
dir := t.TempDir()
writeTestSegment(t, dir, 0, 0, [][]*WalEntry{
{makePutEntry("k0", "v0")},
})
seg1Path := writeTestSegment(t, dir, 1, 1, [][]*WalEntry{
{makePutEntry("k1", "v1")},
{makePutEntry("k2", "v2")},
})
encA, _ := EncodeWalBatch(1, []*WalEntry{makePutEntry("k1", "v1")})
encB, _ := EncodeWalBatch(2, []*WalEntry{makePutEntry("k2", "v2")})
endOfBatch2 := int64(WalFileHeaderSize) +
int64(PhysicalRecordHeaderSize+len(encA)) +
int64(PhysicalRecordHeaderSize+len(encB))
// Append partial fragments to segment-1 (the LAST segment).
appendFileBytes(t, seg1Path, EncodePhysicalRecord(RecFirst, []byte("first")))
appendFileBytes(t, seg1Path, EncodePhysicalRecord(RecMiddle, []byte("middle")))
result, err := Recover(dir, &mockReplayer{})
if err != nil {
t.Fatalf("Recover: %v", err)
}
if !result.Truncated {
t.Fatal("Truncated = false, want true")
}
fi, _ := os.Stat(seg1Path)
if fi.Size() != endOfBatch2 {
t.Errorf("segment-1 size = %d, want %d (end of Batch 2)", fi.Size(), endOfBatch2)
}
}