feat(wal): implement recovery orchestrator with tail truncation
- wal/recover.go: top-level Recover() function that orchestrates scan + replay + truncation Reads MANIFEST/CURRENT for recovery segment ID, handles tail corruption by truncating - Comprehensive tests for empty dir, full flow, tail corruption, and MANIFEST updates - All tests pass with -race
This commit is contained in:
@@ -0,0 +1,157 @@
|
||||
package wal
|
||||
|
||||
import (
|
||||
"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 TestRecoverUpdatesManifest(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
// Write test data.
|
||||
writeTestSegment(t, dir, 0, 50, [][]*WalEntry{
|
||||
{makePutEntry("a", "b")},
|
||||
{makePutEntry("c", "d")},
|
||||
})
|
||||
|
||||
replayer := &mockReplayer{}
|
||||
result, err := Recover(dir, replayer)
|
||||
if err != nil {
|
||||
t.Fatalf("Recover: %v", err)
|
||||
}
|
||||
|
||||
// Verify MANIFEST file exists and has correct content.
|
||||
mf, err := manifest.Load(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("manifest.Load after recover: %v", err)
|
||||
}
|
||||
if mf.RecoverySegmentID != result.NextSegmentID {
|
||||
t.Errorf("MANIFEST RecoverySegmentID = %d, want %d",
|
||||
mf.RecoverySegmentID, result.NextSegmentID)
|
||||
}
|
||||
|
||||
// Verify MANIFEST file path.
|
||||
manifestPath := filepath.Join(dir, "MANIFEST")
|
||||
if _, err := os.Stat(manifestPath); os.IsNotExist(err) {
|
||||
t.Error("MANIFEST file should exist after Recover")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user