feat(wal): implement segment rotation, recovery scanner, and record parser
- wal/segment_manager.go: segment lifecycle with rotation at batch boundaries - wal/scanner.go: segment discovery, ordering, and continuity validation - wal/record_parser.go: block-level physical record parsing with tail corruption detection - Comprehensive tests for all modules, all pass with -race
This commit is contained in:
@@ -0,0 +1,169 @@
|
||||
package wal
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// writeSegmentFile creates a minimal WAL segment file in dir with the given
|
||||
// segment ID and start sequence.
|
||||
func writeSegmentFile(t *testing.T, dir string, segmentID, startSequence uint64) {
|
||||
t.Helper()
|
||||
path := filepath.Join(dir, fmt.Sprintf("segment-%d.wal", segmentID))
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
t.Fatalf("create segment file: %v", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
hdr := &WalFileHeader{
|
||||
SegmentID: segmentID,
|
||||
StartSequence: startSequence,
|
||||
BlockSize: WalBlockSize,
|
||||
}
|
||||
encoded := EncodeWalHeader(hdr)
|
||||
if _, err := f.Write(encoded[:]); err != nil {
|
||||
t.Fatalf("write header: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSegmentFilename(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
wantID uint64
|
||||
wantOK bool
|
||||
}{
|
||||
{"segment-0.wal", 0, true},
|
||||
{"segment-1.wal", 1, true},
|
||||
{"segment-42.wal", 42, true},
|
||||
{"segment-18446744073709551615.wal", 18446744073709551615, true},
|
||||
{"segment-.wal", 0, false}, // no digits
|
||||
{"segment-abc.wal", 0, false}, // non-numeric
|
||||
{"segment-1.wal.bak", 0, false}, // extra suffix
|
||||
{"foo-segment-1.wal", 0, false}, // extra prefix
|
||||
{" Segment-1.wal", 0, false}, // space prefix
|
||||
{"segment--1.wal", 0, false}, // negative sign
|
||||
{"", 0, false},
|
||||
{"readme.md", 0, false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
gotID, gotOK := ParseSegmentFilename(tt.name)
|
||||
if gotOK != tt.wantOK {
|
||||
t.Errorf("ParseSegmentFilename(%q) ok = %v, want %v", tt.name, gotOK, tt.wantOK)
|
||||
}
|
||||
if gotOK && gotID != tt.wantID {
|
||||
t.Errorf("ParseSegmentFilename(%q) id = %d, want %d", tt.name, gotID, tt.wantID)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanSegmentsOrdered(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeSegmentFile(t, dir, 0, 100)
|
||||
writeSegmentFile(t, dir, 1, 200)
|
||||
writeSegmentFile(t, dir, 2, 300)
|
||||
|
||||
segs, err := ScanSegments(dir, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("ScanSegments: %v", err)
|
||||
}
|
||||
if len(segs) != 3 {
|
||||
t.Fatalf("expected 3 segments, got %d", len(segs))
|
||||
}
|
||||
|
||||
for i, want := range []struct {
|
||||
id uint64
|
||||
seq uint64
|
||||
}{
|
||||
{0, 100}, {1, 200}, {2, 300},
|
||||
} {
|
||||
if segs[i].SegmentID != want.id {
|
||||
t.Errorf("seg[%d].SegmentID = %d, want %d", i, segs[i].SegmentID, want.id)
|
||||
}
|
||||
if segs[i].StartSequence != want.seq {
|
||||
t.Errorf("seg[%d].StartSequence = %d, want %d", i, segs[i].StartSequence, want.seq)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanSegmentsFiltersBelowRecoveryID(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
for i := uint64(0); i < 5; i++ {
|
||||
writeSegmentFile(t, dir, i, i*100)
|
||||
}
|
||||
|
||||
segs, err := ScanSegments(dir, 2)
|
||||
if err != nil {
|
||||
t.Fatalf("ScanSegments: %v", err)
|
||||
}
|
||||
if len(segs) != 3 {
|
||||
t.Fatalf("expected 3 segments, got %d", len(segs))
|
||||
}
|
||||
|
||||
wantIDs := []uint64{2, 3, 4}
|
||||
for i, s := range segs {
|
||||
if s.SegmentID != wantIDs[i] {
|
||||
t.Errorf("seg[%d].SegmentID = %d, want %d", i, s.SegmentID, wantIDs[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanSegmentsGapDetection(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeSegmentFile(t, dir, 0, 0)
|
||||
writeSegmentFile(t, dir, 1, 100)
|
||||
// segment 2 is missing
|
||||
writeSegmentFile(t, dir, 3, 300)
|
||||
|
||||
_, err := ScanSegments(dir, 0)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for gap in segment sequence")
|
||||
}
|
||||
if !errors.Is(err, ErrSegmentGap) {
|
||||
t.Errorf("expected ErrSegmentGap, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanSegmentsEmpty(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
segs, err := ScanSegments(dir, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("ScanSegments empty dir: %v", err)
|
||||
}
|
||||
if len(segs) != 0 {
|
||||
t.Fatalf("expected 0 segments, got %d", len(segs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanSegmentsIgnoresNonWalFiles(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeSegmentFile(t, dir, 0, 0)
|
||||
// Create non-WAL files that should be ignored.
|
||||
if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("hi"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "segment-abc.wal"), []byte("nope"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
segs, err := ScanSegments(dir, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("ScanSegments: %v", err)
|
||||
}
|
||||
if len(segs) != 1 {
|
||||
t.Fatalf("expected 1 segment, got %d", len(segs))
|
||||
}
|
||||
if segs[0].SegmentID != 0 {
|
||||
t.Errorf("SegmentID = %d, want 0", segs[0].SegmentID)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user