package wal import ( "bytes" "os" "path/filepath" "reflect" "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, 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 }