- 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
158 lines
4.1 KiB
Go
158 lines
4.1 KiB
Go
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")
|
|
}
|
|
}
|