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,269 @@
|
||||
package wal
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseBlockSingleRecord(t *testing.T) {
|
||||
payload := []byte("hello world")
|
||||
encoded := EncodePhysicalRecord(RecFull, payload)
|
||||
|
||||
block := make([]byte, WalBlockSize)
|
||||
copy(block, encoded)
|
||||
|
||||
recs, err := ParseBlock(block)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseBlock: %v", err)
|
||||
}
|
||||
if len(recs) != 1 {
|
||||
t.Fatalf("expected 1 record, got %d", len(recs))
|
||||
}
|
||||
if recs[0].Type != RecFull {
|
||||
t.Errorf("Type = %d, want RecFull(%d)", recs[0].Type, RecFull)
|
||||
}
|
||||
if string(recs[0].Payload) != string(payload) {
|
||||
t.Errorf("Payload = %q, want %q", recs[0].Payload, payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseBlockMultipleRecords(t *testing.T) {
|
||||
payloads := [][]byte{
|
||||
[]byte("first"),
|
||||
[]byte("second"),
|
||||
[]byte("third"),
|
||||
}
|
||||
|
||||
block := make([]byte, WalBlockSize)
|
||||
offset := 0
|
||||
for i, p := range payloads {
|
||||
rec := EncodePhysicalRecord(RecFull, p)
|
||||
copy(block[offset:], rec)
|
||||
offset += len(rec)
|
||||
if offset > WalBlockSize {
|
||||
t.Fatalf("record %d overflows block", i)
|
||||
}
|
||||
}
|
||||
|
||||
recs, err := ParseBlock(block)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseBlock: %v", err)
|
||||
}
|
||||
if len(recs) != 3 {
|
||||
t.Fatalf("expected 3 records, got %d", len(recs))
|
||||
}
|
||||
for i, want := range payloads {
|
||||
if string(recs[i].Payload) != string(want) {
|
||||
t.Errorf("rec[%d].Payload = %q, want %q", i, recs[i].Payload, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseBlockWithPadding(t *testing.T) {
|
||||
payload := []byte("data")
|
||||
encoded := EncodePhysicalRecord(RecFull, payload)
|
||||
|
||||
// Place record at offset 0, then fill rest with zeros.
|
||||
block := make([]byte, WalBlockSize)
|
||||
copy(block, encoded)
|
||||
|
||||
recs, err := ParseBlock(block)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseBlock: %v", err)
|
||||
}
|
||||
if len(recs) != 1 {
|
||||
t.Fatalf("expected 1 record, got %d", len(recs))
|
||||
}
|
||||
if string(recs[0].Payload) != string(payload) {
|
||||
t.Errorf("Payload = %q, want %q", recs[0].Payload, payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseBlockCorruptPadding(t *testing.T) {
|
||||
payload := []byte("data")
|
||||
encoded := EncodePhysicalRecord(RecFull, payload)
|
||||
|
||||
block := make([]byte, WalBlockSize)
|
||||
copy(block, encoded)
|
||||
// Write non-zero byte in the trailing padding area.
|
||||
block[len(encoded)+3] = 0xFF
|
||||
|
||||
recs, err := ParseBlock(block)
|
||||
if err == nil {
|
||||
t.Fatal("expected tail corruption error")
|
||||
}
|
||||
if !IsTailCorruption(err) {
|
||||
t.Errorf("expected TailCorruptionError, got: %v", err)
|
||||
}
|
||||
// Should still return records parsed before the corruption.
|
||||
if len(recs) != 1 {
|
||||
t.Errorf("expected 1 record before corruption, got %d", len(recs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseBlockBadCRC(t *testing.T) {
|
||||
payload := []byte("data")
|
||||
encoded := EncodePhysicalRecord(RecFull, payload)
|
||||
|
||||
block := make([]byte, WalBlockSize)
|
||||
copy(block, encoded)
|
||||
// Corrupt a payload byte.
|
||||
block[PhysicalRecordHeaderSize+1] ^= 0xFF
|
||||
|
||||
_, err := ParseBlock(block)
|
||||
if err == nil {
|
||||
t.Fatal("expected CRC mismatch error")
|
||||
}
|
||||
if !IsTailCorruption(err) {
|
||||
t.Errorf("expected TailCorruptionError, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseBlockShortBlock(t *testing.T) {
|
||||
payload := []byte("short block data")
|
||||
encoded := EncodePhysicalRecord(RecFull, payload)
|
||||
|
||||
// Simulate a short last block (less than WalBlockSize).
|
||||
block := make([]byte, len(encoded)+10) // extra trailing zeros
|
||||
copy(block, encoded)
|
||||
|
||||
recs, err := ParseBlock(block)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseBlock short block: %v", err)
|
||||
}
|
||||
if len(recs) != 1 {
|
||||
t.Fatalf("expected 1 record, got %d", len(recs))
|
||||
}
|
||||
if string(recs[0].Payload) != string(payload) {
|
||||
t.Errorf("Payload = %q, want %q", recs[0].Payload, payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseBlockEmpty(t *testing.T) {
|
||||
block := make([]byte, 64) // all zeros
|
||||
|
||||
recs, err := ParseBlock(block)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseBlock empty: %v", err)
|
||||
}
|
||||
if len(recs) != 0 {
|
||||
t.Fatalf("expected 0 records, got %d", len(recs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseBlockZeroHeaderWithNonZeroTail(t *testing.T) {
|
||||
block := make([]byte, WalBlockSize)
|
||||
// First 7 bytes are zero (valid zero header), but byte at offset 8 is non-zero.
|
||||
block[8] = 0x42
|
||||
|
||||
_, err := ParseBlock(block)
|
||||
if err == nil {
|
||||
t.Fatal("expected tail corruption error for zero header + non-zero tail")
|
||||
}
|
||||
if !IsTailCorruption(err) {
|
||||
t.Errorf("expected TailCorruptionError, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsTailCorruptionFalse(t *testing.T) {
|
||||
if IsTailCorruption(nil) {
|
||||
t.Error("IsTailCorruption(nil) = true, want false")
|
||||
}
|
||||
if IsTailCorruption(os.ErrNotExist) {
|
||||
t.Error("IsTailCorruption(ErrNotExist) = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRecordsFromFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "segment-0.wal")
|
||||
|
||||
// Create a segment file with header + one full block containing 2 records.
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
hdr := &WalFileHeader{
|
||||
SegmentID: 0,
|
||||
StartSequence: 0,
|
||||
BlockSize: WalBlockSize,
|
||||
}
|
||||
encoded := EncodeWalHeader(hdr)
|
||||
if _, err := f.Write(encoded[:]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
block := make([]byte, WalBlockSize)
|
||||
payload1 := []byte("record-one")
|
||||
payload2 := []byte("record-two")
|
||||
offset := 0
|
||||
rec1 := EncodePhysicalRecord(RecFull, payload1)
|
||||
copy(block[offset:], rec1)
|
||||
offset += len(rec1)
|
||||
rec2 := EncodePhysicalRecord(RecFull, payload2)
|
||||
copy(block[offset:], rec2)
|
||||
|
||||
if _, err := f.Write(block); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
recs, err := ParseRecordsFromFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseRecordsFromFile: %v", err)
|
||||
}
|
||||
if len(recs) != 2 {
|
||||
t.Fatalf("expected 2 records, got %d", len(recs))
|
||||
}
|
||||
if string(recs[0].Payload) != string(payload1) {
|
||||
t.Errorf("rec[0].Payload = %q, want %q", recs[0].Payload, payload1)
|
||||
}
|
||||
if string(recs[1].Payload) != string(payload2) {
|
||||
t.Errorf("rec[1].Payload = %q, want %q", recs[1].Payload, payload2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRecordsFromFileShortLastBlock(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "segment-0.wal")
|
||||
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
hdr := &WalFileHeader{
|
||||
SegmentID: 0,
|
||||
StartSequence: 0,
|
||||
BlockSize: WalBlockSize,
|
||||
}
|
||||
encoded := EncodeWalHeader(hdr)
|
||||
if _, err := f.Write(encoded[:]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Write a partial block (just one record, no full 32KB).
|
||||
payload := []byte("short")
|
||||
rec := EncodePhysicalRecord(RecFull, payload)
|
||||
if _, err := f.Write(rec); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
recs, err := ParseRecordsFromFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseRecordsFromFile: %v", err)
|
||||
}
|
||||
if len(recs) != 1 {
|
||||
t.Fatalf("expected 1 record, got %d", len(recs))
|
||||
}
|
||||
if string(recs[0].Payload) != string(payload) {
|
||||
t.Errorf("Payload = %q, want %q", recs[0].Payload, payload)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user