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) } }