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).
603 lines
19 KiB
Go
603 lines
19 KiB
Go
package wal
|
|
|
|
import (
|
|
"bytes"
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/dailz/go-kv/manifest"
|
|
)
|
|
|
|
func TestRecoverFromEmptyDir(t *testing.T) {
|
|
dir := t.TempDir()
|
|
|
|
replayer := &mockReplayer{}
|
|
result, err := Recover(dir, replayer)
|
|
if err != nil {
|
|
t.Fatalf("Recover empty dir: %v", err)
|
|
}
|
|
if result.NextSequence != 0 {
|
|
t.Errorf("NextSequence = %d, want 0", result.NextSequence)
|
|
}
|
|
if result.Truncated {
|
|
t.Error("Truncated = true, want false")
|
|
}
|
|
if result.TruncateError != nil {
|
|
t.Errorf("TruncateError = %v, want nil", result.TruncateError)
|
|
}
|
|
}
|
|
|
|
func TestRecoverFullFlow(t *testing.T) {
|
|
dir := t.TempDir()
|
|
|
|
// Write test data: 2 batches across 1 segment.
|
|
writeTestSegment(t, dir, 0, 0, [][]*WalEntry{
|
|
{makePutEntry("key1", "val1"), makeDeleteEntry("key2")},
|
|
{makePutEntry("key3", "val3")},
|
|
})
|
|
|
|
replayer := &mockReplayer{}
|
|
result, err := Recover(dir, replayer)
|
|
if err != nil {
|
|
t.Fatalf("Recover: %v", err)
|
|
}
|
|
if result.NextSequence != 3 {
|
|
t.Errorf("NextSequence = %d, want 3", result.NextSequence)
|
|
}
|
|
if result.NextSegmentID != 1 {
|
|
t.Errorf("NextSegmentID = %d, want 1", result.NextSegmentID)
|
|
}
|
|
if result.Truncated {
|
|
t.Error("Truncated = true, want false")
|
|
}
|
|
|
|
wantPuts := []replayPut{
|
|
{key: "key1", value: "val1", seq: 0},
|
|
{key: "key3", value: "val3", seq: 2},
|
|
}
|
|
if !reflect.DeepEqual(replayer.puts, wantPuts) {
|
|
t.Errorf("puts = %#v, want %#v", replayer.puts, wantPuts)
|
|
}
|
|
wantDeletes := []replayDelete{
|
|
{key: "key2", seq: 1},
|
|
}
|
|
if !reflect.DeepEqual(replayer.deletes, wantDeletes) {
|
|
t.Errorf("deletes = %#v, want %#v", replayer.deletes, wantDeletes)
|
|
}
|
|
}
|
|
|
|
func TestRecoverWithTailCorruption(t *testing.T) {
|
|
dir := t.TempDir()
|
|
|
|
// Write valid batches, then corrupt the tail.
|
|
filePath := writeTestSegment(t, dir, 0, 100, [][]*WalEntry{
|
|
{makePutEntry("good1", "before-corruption")},
|
|
{makePutEntry("good2", "also-before")},
|
|
})
|
|
appendFileBytes(t, filePath, []byte{0xDE, 0xAD, 0xBE, 0xEF})
|
|
|
|
replayer := &mockReplayer{}
|
|
result, err := Recover(dir, replayer)
|
|
if err != nil {
|
|
t.Fatalf("Recover with tail corruption: %v", err)
|
|
}
|
|
if !result.Truncated {
|
|
t.Fatal("Truncated = false, want true")
|
|
}
|
|
if result.TruncateError == nil {
|
|
t.Fatal("TruncateError = nil, want non-nil")
|
|
}
|
|
if result.NextSequence != 102 {
|
|
t.Errorf("NextSequence = %d, want 102", result.NextSequence)
|
|
}
|
|
if result.NextSegmentID != 1 {
|
|
t.Errorf("NextSegmentID = %d, want 1", result.NextSegmentID)
|
|
}
|
|
|
|
// Verify only the valid entries were replayed.
|
|
wantPuts := []replayPut{
|
|
{key: "good1", value: "before-corruption", seq: 100},
|
|
{key: "good2", value: "also-before", seq: 101},
|
|
}
|
|
if !reflect.DeepEqual(replayer.puts, wantPuts) {
|
|
t.Errorf("puts = %#v, want %#v", replayer.puts, wantPuts)
|
|
}
|
|
|
|
// Verify the file was truncated — it should be smaller than before.
|
|
fi, fiErr := os.Stat(filePath)
|
|
if fiErr != nil {
|
|
t.Fatalf("Stat truncated file: %v", fiErr)
|
|
}
|
|
if fi.Size() == 0 {
|
|
t.Error("truncated file is empty")
|
|
}
|
|
|
|
// Verify the truncated file still parses cleanly.
|
|
replayer2 := &mockReplayer{}
|
|
_, parseErr := ReplaySegmentFile(filePath, 100, true, replayer2)
|
|
if parseErr != nil {
|
|
t.Fatalf("replay after truncation: %v", parseErr)
|
|
}
|
|
if !reflect.DeepEqual(replayer2.puts, wantPuts) {
|
|
t.Errorf("replay after truncation puts = %#v, want %#v", replayer2.puts, wantPuts)
|
|
}
|
|
}
|
|
|
|
func TestRecoverDoesNotUpdateManifest(t *testing.T) {
|
|
dir := t.TempDir()
|
|
|
|
writeTestSegment(t, dir, 0, 50, [][]*WalEntry{
|
|
{makePutEntry("a", "b")},
|
|
{makePutEntry("c", "d")},
|
|
})
|
|
|
|
beforeMF, err := manifest.Load(dir)
|
|
if err != nil {
|
|
t.Fatalf("manifest.Load before recover: %v", err)
|
|
}
|
|
beforeExists := fileExists(t, filepath.Join(dir, "MANIFEST"))
|
|
|
|
replayer := &mockReplayer{}
|
|
if _, err := Recover(dir, replayer); err != nil {
|
|
t.Fatalf("Recover: %v", err)
|
|
}
|
|
|
|
afterMF, err := manifest.Load(dir)
|
|
if err != nil {
|
|
t.Fatalf("manifest.Load after recover: %v", err)
|
|
}
|
|
if afterMF.RecoverySegmentID != beforeMF.RecoverySegmentID {
|
|
t.Errorf("MANIFEST RecoverySegmentID changed: %d -> %d",
|
|
beforeMF.RecoverySegmentID, afterMF.RecoverySegmentID)
|
|
}
|
|
afterExists := fileExists(t, filepath.Join(dir, "MANIFEST"))
|
|
if beforeExists != afterExists {
|
|
t.Errorf("MANIFEST file existence changed: before=%v after=%v",
|
|
beforeExists, afterExists)
|
|
}
|
|
}
|
|
|
|
// Regression guard for C3: covers the case where MANIFEST already exists.
|
|
// The fresh-DB test above cannot catch accidental overwrites of an existing
|
|
// MANIFEST.
|
|
func TestRecoverPreservesExistingManifest(t *testing.T) {
|
|
dir := t.TempDir()
|
|
|
|
writeTestSegment(t, dir, 0, 50, [][]*WalEntry{
|
|
{makePutEntry("a", "b")},
|
|
})
|
|
|
|
if err := manifest.Save(dir, 0); err != nil {
|
|
t.Fatalf("manifest.Save setup: %v", err)
|
|
}
|
|
beforeBytes, err := os.ReadFile(filepath.Join(dir, "MANIFEST"))
|
|
if err != nil {
|
|
t.Fatalf("ReadFile MANIFEST: %v", err)
|
|
}
|
|
|
|
replayer := &mockReplayer{}
|
|
if _, err := Recover(dir, replayer); err != nil {
|
|
t.Fatalf("Recover: %v", err)
|
|
}
|
|
|
|
afterBytes, err := os.ReadFile(filepath.Join(dir, "MANIFEST"))
|
|
if err != nil {
|
|
t.Fatalf("ReadFile MANIFEST after recover: %v", err)
|
|
}
|
|
if !bytes.Equal(beforeBytes, afterBytes) {
|
|
t.Errorf("MANIFEST bytes changed:\n before=%q\n after=%q",
|
|
string(beforeBytes), string(afterBytes))
|
|
}
|
|
}
|
|
|
|
// Regression guard for C3: design §3.2 line 280 requires recovery to be
|
|
// idempotent on a clean WAL (no MANIFEST side effects).
|
|
func TestRecoverIdempotentClean(t *testing.T) {
|
|
dir := t.TempDir()
|
|
|
|
writeTestSegment(t, dir, 0, 0, [][]*WalEntry{
|
|
{makePutEntry("k1", "v1")},
|
|
{makePutEntry("k2", "v2")},
|
|
})
|
|
|
|
replayer1 := &mockReplayer{}
|
|
result1, err := Recover(dir, replayer1)
|
|
if err != nil {
|
|
t.Fatalf("Recover (1st): %v", err)
|
|
}
|
|
|
|
replayer2 := &mockReplayer{}
|
|
result2, err := Recover(dir, replayer2)
|
|
if err != nil {
|
|
t.Fatalf("Recover (2nd): %v", err)
|
|
}
|
|
|
|
if result1.NextSequence != result2.NextSequence {
|
|
t.Errorf("NextSequence differs: %d vs %d", result1.NextSequence, result2.NextSequence)
|
|
}
|
|
if result1.NextSegmentID != result2.NextSegmentID {
|
|
t.Errorf("NextSegmentID differs: %d vs %d", result1.NextSegmentID, result2.NextSegmentID)
|
|
}
|
|
if result1.Truncated || result2.Truncated {
|
|
t.Errorf("Truncated should be false for clean WAL: r1=%v r2=%v",
|
|
result1.Truncated, result2.Truncated)
|
|
}
|
|
if !reflect.DeepEqual(replayer1.puts, replayer2.puts) {
|
|
t.Errorf("replayed puts differ:\n r1=%#v\n r2=%#v", replayer1.puts, replayer2.puts)
|
|
}
|
|
}
|
|
|
|
// Regression guard for C3: after the first recovery truncates a corrupted
|
|
// tail, the second recovery must observe stable state (Truncated=false, same
|
|
// NextSequence, same replayed entries).
|
|
func TestRecoverIdempotentAfterTruncation(t *testing.T) {
|
|
dir := t.TempDir()
|
|
|
|
filePath := writeTestSegment(t, dir, 0, 100, [][]*WalEntry{
|
|
{makePutEntry("good1", "before-corruption")},
|
|
{makePutEntry("good2", "also-before")},
|
|
})
|
|
appendFileBytes(t, filePath, []byte{0xDE, 0xAD, 0xBE, 0xEF})
|
|
|
|
replayer1 := &mockReplayer{}
|
|
result1, err := Recover(dir, replayer1)
|
|
if err != nil {
|
|
t.Fatalf("Recover (1st): %v", err)
|
|
}
|
|
if !result1.Truncated {
|
|
t.Fatal("1st Recover: Truncated = false, want true")
|
|
}
|
|
|
|
replayer2 := &mockReplayer{}
|
|
result2, err := Recover(dir, replayer2)
|
|
if err != nil {
|
|
t.Fatalf("Recover (2nd): %v", err)
|
|
}
|
|
if result2.Truncated {
|
|
t.Error("2nd Recover: Truncated = true, want false (tail already repaired)")
|
|
}
|
|
if result1.NextSequence != result2.NextSequence {
|
|
t.Errorf("NextSequence differs: %d vs %d", result1.NextSequence, result2.NextSequence)
|
|
}
|
|
if !reflect.DeepEqual(replayer1.puts, replayer2.puts) {
|
|
t.Errorf("replayed puts differ:\n r1=%#v\n r2=%#v", replayer1.puts, replayer2.puts)
|
|
}
|
|
}
|
|
|
|
// Regression guard for C2: design §3.2 line 604-06 forbids using CURRENT as
|
|
// recovery start. writeTestSegment uses NewSegmentWriter directly, which does
|
|
// NOT write CURRENT (only SegmentManager does), so we write CURRENT explicitly
|
|
// to simulate the Phase 1 default state.
|
|
func TestRecoverIgnoresCurrentFallback(t *testing.T) {
|
|
dir := t.TempDir()
|
|
|
|
writeTestSegment(t, dir, 0, 0, [][]*WalEntry{
|
|
{makePutEntry("seg0-k1", "v1")},
|
|
})
|
|
writeTestSegment(t, dir, 1, 1, [][]*WalEntry{
|
|
{makePutEntry("seg1-k1", "v1")},
|
|
})
|
|
writeTestSegment(t, dir, 2, 2, [][]*WalEntry{
|
|
{makePutEntry("seg2-k1", "v1")},
|
|
})
|
|
|
|
if err := manifest.WriteCurrent(dir, 2); err != nil {
|
|
t.Fatalf("WriteCurrent: %v", err)
|
|
}
|
|
|
|
currentSegID, ok := manifest.ReadCurrent(dir)
|
|
if !ok || currentSegID != 2 {
|
|
t.Fatalf("CURRENT setup wrong: segID=%d ok=%v", currentSegID, ok)
|
|
}
|
|
mf, err := manifest.Load(dir)
|
|
if err != nil {
|
|
t.Fatalf("Load: %v", err)
|
|
}
|
|
if mf.RecoverySegmentID != 0 {
|
|
t.Fatalf("MANIFEST.RecoverySegmentID = %d, want 0", mf.RecoverySegmentID)
|
|
}
|
|
|
|
replayer := &mockReplayer{}
|
|
result, err := Recover(dir, replayer)
|
|
if err != nil {
|
|
t.Fatalf("Recover: %v", err)
|
|
}
|
|
|
|
wantPuts := []replayPut{
|
|
{key: "seg0-k1", value: "v1", seq: 0},
|
|
{key: "seg1-k1", value: "v1", seq: 1},
|
|
{key: "seg2-k1", value: "v1", seq: 2},
|
|
}
|
|
if !reflect.DeepEqual(replayer.puts, wantPuts) {
|
|
t.Errorf("puts = %#v, want %#v", replayer.puts, wantPuts)
|
|
}
|
|
if result.NextSequence != 3 {
|
|
t.Errorf("NextSequence = %d, want 3", result.NextSequence)
|
|
}
|
|
if result.NextSegmentID != 3 {
|
|
t.Errorf("NextSegmentID = %d, want 3", result.NextSegmentID)
|
|
}
|
|
}
|
|
|
|
func fileExists(t *testing.T, path string) bool {
|
|
t.Helper()
|
|
_, err := os.Stat(path)
|
|
if err == nil {
|
|
return true
|
|
}
|
|
if os.IsNotExist(err) {
|
|
return false
|
|
}
|
|
t.Fatalf("stat %s: %v", path, err)
|
|
return false
|
|
}
|
|
|
|
// Regression guard for C5+H8: end-to-end recovery with partial fragment
|
|
// tail must persist truncation at the last COMPLETE batch boundary
|
|
// (H8), and the truncation must be persisted with all 4 steps (C5).
|
|
// After repair, second recovery must not see corruption.
|
|
func TestRecoverPartialFragmentTailIdempotent(t *testing.T) {
|
|
dir := t.TempDir()
|
|
batchA := []*WalEntry{makePutEntry("key-A", "val-A")}
|
|
batchB := []*WalEntry{makePutEntry("key-B", "val-B")}
|
|
|
|
filePath := writeTestSegment(t, dir, 0, 0, [][]*WalEntry{batchA, batchB})
|
|
|
|
// Compute exact byte offset where Batch B's Full record ends.
|
|
// Layout: [header][Batch A Full record][Batch B Full record][padding to 32KB]
|
|
encA, _ := EncodeWalBatch(0, batchA)
|
|
encB, _ := EncodeWalBatch(1, batchB)
|
|
endOfBatchB := int64(WalFileHeaderSize) +
|
|
int64(PhysicalRecordHeaderSize+len(encA)) +
|
|
int64(PhysicalRecordHeaderSize+len(encB))
|
|
|
|
fiBefore, _ := os.Stat(filePath)
|
|
|
|
// Append First + Middle* (no Last) to simulate partial fragment tail.
|
|
appendFileBytes(t, filePath, EncodePhysicalRecord(RecFirst, []byte("first-fragment-payload")))
|
|
appendFileBytes(t, filePath, EncodePhysicalRecord(RecMiddle, []byte("middle-fragment-payload")))
|
|
|
|
replayer1 := &mockReplayer{}
|
|
result1, err := Recover(dir, replayer1)
|
|
if err != nil {
|
|
t.Fatalf("1st Recover: %v", err)
|
|
}
|
|
if !result1.Truncated {
|
|
t.Fatal("1st Recover: Truncated = false, want true")
|
|
}
|
|
|
|
fiAfter, _ := os.Stat(filePath)
|
|
if fiAfter.Size() != endOfBatchB {
|
|
t.Errorf("file size after truncation = %d, want %d (end of Batch B, H8)",
|
|
fiAfter.Size(), endOfBatchB)
|
|
}
|
|
if fiAfter.Size() >= fiBefore.Size() {
|
|
t.Errorf("file should shrink after truncation: before=%d after=%d",
|
|
fiBefore.Size(), fiAfter.Size())
|
|
}
|
|
|
|
replayer2 := &mockReplayer{}
|
|
result2, err := Recover(dir, replayer2)
|
|
if err != nil {
|
|
t.Fatalf("2nd Recover: %v", err)
|
|
}
|
|
if result2.Truncated {
|
|
t.Error("2nd Recover: Truncated = true, want false (truncation should be persisted)")
|
|
}
|
|
if result1.NextSequence != result2.NextSequence {
|
|
t.Errorf("NextSequence differs: %d vs %d", result1.NextSequence, result2.NextSequence)
|
|
}
|
|
}
|
|
|
|
// Regression guard for C5: any truncation persist step failure must
|
|
// fail Recover, causing DB.Open to fail. No swallowing allowed.
|
|
func TestRecoverTruncationFailureFailsRecovery(t *testing.T) {
|
|
dir := t.TempDir()
|
|
filePath := writeTestSegment(t, dir, 0, 0, [][]*WalEntry{
|
|
{makePutEntry("key-A", "val-A")},
|
|
})
|
|
// Append corruption to trigger tail corruption path.
|
|
appendFileBytes(t, filePath, []byte{0xDE, 0xAD, 0xBE, 0xEF})
|
|
|
|
// Inject dir fsync failure (Step 4 of truncateAndPersist).
|
|
orig := dirFsyncFn
|
|
dirFsyncFn = func(string) error { return errors.New("simulated dir fsync failure") }
|
|
t.Cleanup(func() { dirFsyncFn = orig })
|
|
|
|
_, err := Recover(dir, &mockReplayer{})
|
|
if err == nil {
|
|
t.Fatal("expected Recover to fail when truncation persist fails")
|
|
}
|
|
if !strings.Contains(err.Error(), "persist tail truncation") {
|
|
t.Errorf("error should mention 'persist tail truncation', got: %v", err)
|
|
}
|
|
}
|
|
|
|
// Regression guard for design line 778-781: CRC-valid but batch-content-
|
|
// invalid must hard-fail through DecodeWalBatch, NOT enter truncation path.
|
|
func TestRecoverInvalidBatchNotTruncatable(t *testing.T) {
|
|
dir := t.TempDir()
|
|
// Build segment with: physical records CRC-valid, but assembled batch
|
|
// has invalid header (entryCount=0).
|
|
filePath := filepath.Join(dir, "segment-0.wal")
|
|
writeRawSegmentHeader(t, filePath)
|
|
|
|
// Construct an "invalid batch": WalBatchHeaderSize=18 bytes, with
|
|
// entryCount=0 (invalid per ReplayBatch check at recovery.go).
|
|
invalidBatch := make([]byte, WalBatchHeaderSize)
|
|
// flags(2) + baseSequence(8) + entryCount(4)=0 + entriesSize(4)=0
|
|
// All zeros, except entryCount=0 is invalid by itself.
|
|
// Encode as Full physical record (CRC-valid).
|
|
rec := EncodePhysicalRecord(RecFull, invalidBatch)
|
|
appendFileBytes(t, filePath, rec)
|
|
|
|
_, err := Recover(dir, &mockReplayer{})
|
|
if err == nil {
|
|
t.Fatal("expected Recover to fail on invalid batch content")
|
|
}
|
|
// Should NOT mention truncation — must be a different error path.
|
|
if strings.Contains(err.Error(), "truncat") {
|
|
t.Errorf("error should not be about truncation; got: %v", err)
|
|
}
|
|
|
|
// File must NOT have been truncated (size unchanged).
|
|
fi, _ := os.Stat(filePath)
|
|
if fi.Size() != int64(WalFileHeaderSize)+int64(len(rec)) {
|
|
t.Errorf("file was truncated; size = %d, want %d",
|
|
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)
|
|
}
|
|
}
|