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:
+88
-2
@@ -4,6 +4,7 @@ import (
|
||||
"errors"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -121,7 +122,7 @@ func TestReplaySegmentFileFull(t *testing.T) {
|
||||
})
|
||||
|
||||
replayer := &mockReplayer{}
|
||||
next, err := ReplaySegmentFile(filePath, 50, replayer)
|
||||
next, err := ReplaySegmentFile(filePath, 50, true, replayer)
|
||||
if err != nil {
|
||||
t.Fatalf("ReplaySegmentFile: %v", err)
|
||||
}
|
||||
@@ -147,7 +148,7 @@ func TestReplaySegmentFileTailCorruption(t *testing.T) {
|
||||
appendFileBytes(t, filePath, []byte{0x01, 0x02, 0x03})
|
||||
|
||||
replayer := &mockReplayer{}
|
||||
next, err := ReplaySegmentFile(filePath, 70, replayer)
|
||||
next, err := ReplaySegmentFile(filePath, 70, true, replayer)
|
||||
if err == nil {
|
||||
t.Fatal("ReplaySegmentFile succeeded, want tail corruption error")
|
||||
}
|
||||
@@ -250,3 +251,88 @@ func appendFileBytes(t *testing.T, filePath string, data []byte) {
|
||||
t.Fatalf("Write corruption bytes: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// -------- C4 regression guards: isLastSegment controls corruption classification --------
|
||||
|
||||
// Regression guards for C4: isLastSegment controls whether CollectingFragments
|
||||
// at end is tail corruption (truncatable) or hard corruption (must hard-fail).
|
||||
|
||||
func TestReplaySegmentFile_LastSegmentCollectingFragmentsIsTailCorruption(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
filePath := filepath.Join(dir, "segment-0.wal")
|
||||
writeTestSegment(t, dir, 0, 0, [][]*WalEntry{
|
||||
{makePutEntry("k", "v")},
|
||||
})
|
||||
// Append First + Middle (no Last) to leave collector in Collecting state.
|
||||
appendFileBytes(t, filePath, EncodePhysicalRecord(RecFirst, []byte("first")))
|
||||
appendFileBytes(t, filePath, EncodePhysicalRecord(RecMiddle, []byte("middle")))
|
||||
|
||||
_, err := ReplaySegmentFile(filePath, 0, true, &mockReplayer{})
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if !IsTailCorruption(err) {
|
||||
t.Errorf("expected TailCorruptionError for last segment, got: %v", err)
|
||||
}
|
||||
var tce *TailCorruptionError
|
||||
if errors.As(err, &tce) {
|
||||
if tce.SegmentPath != filePath {
|
||||
t.Errorf("SegmentPath = %q, want %q", tce.SegmentPath, filePath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplaySegmentFile_NonLastSegmentCollectingFragmentsIsHardError(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
filePath := filepath.Join(dir, "segment-0.wal")
|
||||
writeTestSegment(t, dir, 0, 0, [][]*WalEntry{
|
||||
{makePutEntry("k", "v")},
|
||||
})
|
||||
appendFileBytes(t, filePath, EncodePhysicalRecord(RecFirst, []byte("first")))
|
||||
appendFileBytes(t, filePath, EncodePhysicalRecord(RecMiddle, []byte("middle")))
|
||||
|
||||
_, err := ReplaySegmentFile(filePath, 0, false, &mockReplayer{})
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if IsTailCorruption(err) {
|
||||
t.Errorf("expected HARD error for non-last segment, got TailCorruptionError: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplaySegmentFile_LastSegmentParseErrorIsTailCorruption(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
filePath := filepath.Join(dir, "segment-0.wal")
|
||||
writeTestSegment(t, dir, 0, 0, [][]*WalEntry{
|
||||
{makePutEntry("k", "v")},
|
||||
})
|
||||
// Append corrupted bytes to trigger ParseBlock's CRC failure path.
|
||||
appendFileBytes(t, filePath, []byte{0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF})
|
||||
|
||||
_, err := ReplaySegmentFile(filePath, 0, true, &mockReplayer{})
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if !IsTailCorruption(err) {
|
||||
t.Errorf("expected TailCorruptionError for last segment parse error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplaySegmentFile_NonLastSegmentParseErrorIsHardError(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
filePath := filepath.Join(dir, "segment-0.wal")
|
||||
writeTestSegment(t, dir, 0, 0, [][]*WalEntry{
|
||||
{makePutEntry("k", "v")},
|
||||
})
|
||||
appendFileBytes(t, filePath, []byte{0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF})
|
||||
|
||||
_, err := ReplaySegmentFile(filePath, 0, false, &mockReplayer{})
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
// Per C4 fix: non-last segment parser corruption is hard error (NOT TailCorruption).
|
||||
// Implemented via %v (not %w) so errors.As cannot find underlying TailCorruptionError.
|
||||
if IsTailCorruption(err) {
|
||||
t.Errorf("expected HARD error for non-last segment parse error, got TailCorruptionError: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user