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:
dailz
2026-06-12 13:50:03 +08:00
parent 349063968b
commit 08960a9bcf
9 changed files with 1074 additions and 4 deletions
+119
View File
@@ -0,0 +1,119 @@
package wal
import (
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
)
// SegmentInfo holds metadata about a WAL segment file.
type SegmentInfo struct {
FilePath string
SegmentID uint64
StartSequence uint64
}
// ParseSegmentFilename extracts the segment ID from a filename matching the
// pattern "segment-{N}.wal". Returns (N, true) on match, (0, false) otherwise.
func ParseSegmentFilename(name string) (segmentID uint64, ok bool) {
if !strings.HasPrefix(name, "segment-") || !strings.HasSuffix(name, ".wal") {
return 0, false
}
// Strip "segment-" prefix and ".wal" suffix.
middle := name[len("segment-") : len(name)-len(".wal")]
if len(middle) == 0 {
return 0, false
}
id, err := strconv.ParseUint(middle, 10, 64)
if err != nil {
return 0, false
}
return id, true
}
var (
// ErrSegmentGap indicates missing WAL segment(s) in the expected sequence.
ErrSegmentGap = errors.New("wal: gap detected in segment sequence")
)
// ScanSegments discovers WAL segment files in dir, filters those with
// SegmentID >= recoverySegmentID, validates file headers, and returns them
// sorted by SegmentID ascending. Returns an error if a gap is detected in the
// segment ID sequence.
func ScanSegments(dir string, recoverySegmentID uint64) ([]*SegmentInfo, error) {
entries, err := os.ReadDir(dir)
if err != nil {
return nil, fmt.Errorf("wal: scan segments: %w", err)
}
var candidates []*SegmentInfo
for _, ent := range entries {
if ent.IsDir() {
continue
}
segID, ok := ParseSegmentFilename(ent.Name())
if !ok {
continue
}
if segID < recoverySegmentID {
continue
}
candidates = append(candidates, &SegmentInfo{
FilePath: filepath.Join(dir, ent.Name()),
SegmentID: segID,
})
}
if len(candidates) == 0 {
return nil, nil
}
// Sort by SegmentID ascending.
sort.Slice(candidates, func(i, j int) bool {
return candidates[i].SegmentID < candidates[j].SegmentID
})
// Decode each file header to populate StartSequence and validate.
for _, si := range candidates {
hdr, err := readFileHeader(si.FilePath)
if err != nil {
return nil, fmt.Errorf("wal: segment %d: %w", si.SegmentID, err)
}
si.StartSequence = hdr.StartSequence
}
// Verify continuity: segment IDs must form a consecutive sequence
// starting from recoverySegmentID.
for i, si := range candidates {
expected := recoverySegmentID + uint64(i)
if si.SegmentID != expected {
return nil, fmt.Errorf("%w: expected segment %d, found %d",
ErrSegmentGap, expected, si.SegmentID)
}
}
return candidates, nil
}
// readFileHeader opens the file, reads the header portion, and decodes it.
func readFileHeader(path string) (*WalFileHeader, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
hdrBuf := make([]byte, WalFileHeaderSize)
n, err := f.Read(hdrBuf)
if err != nil {
return nil, fmt.Errorf("read header: %w", err)
}
if n < WalFileHeaderSize {
return nil, fmt.Errorf("read header: got %d bytes, need %d", n, WalFileHeaderSize)
}
return DecodeWalHeader(hdrBuf)
}