Files
go-kv/wal/recover_test.go
T
dailz 294ab9181f 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
2026-06-12 14:02:19 +08:00

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