Phase 1 default state had two data-loss paths in WAL recovery.
C2: resolveRecoverySegmentID fell back to CURRENT when MANIFEST=0.
Since segment_manager writes CURRENT on every segment create/rotate,
the first recovery in Phase 1 (MANIFEST always 0 without flush) would
start from the active segment, skipping earlier unflushed segments.
C3: Recover called manifest.Save after every recovery, advancing
recoverySegmentID past segments that were still the only durable copy
of their data (no SSTable flush yet). Next restart would filter those
segments out and permanently lose the data.
Per design §3.2 line 280, recovery must not update MANIFEST; per line
604-06, CURRENT must not be used as recovery start. Both fixes are
required together — fixing C3 alone leaves C2's data-loss window open.
Changes:
- wal/recover.go: remove manifest.Save calls on both success and
tail-repair paths; remove CURRENT fallback in resolveRecoverySegmentID.
RecoveryResult.NextSegmentID is now in-memory only (consumed by DB.Open
to seed the new WalWriter, but never persisted to MANIFEST).
- wal/recover_test.go: rewrite TestRecoverUpdatesManifest as
TestRecoverDoesNotUpdateManifest; add TestRecoverPreservesExistingManifest,
TestRecoverIdempotentClean, TestRecoverIdempotentAfterTruncation,
TestRecoverIgnoresCurrentFallback.
- db_test.go: add TestOpenThreeTimesKeepsData (three opens to catch C3's
second-restart data loss; single-segment to avoid unrelated C8 bug
where segment_manager passes byte offset as startSequence).
Verified: each new test fails on pre-fix code and passes after the fix.
Full suite green including -race.
Audit context: docs/audit-3.2.md (with Oracle revisions from bg_ef425776
and bg_2e86d33b; C8 added). Plan: .omo/plans/fix-c2-c3-wal-recovery.md
(Momus + Oracle reviewed v1.2).
196 lines
5.8 KiB
Go
196 lines
5.8 KiB
Go
package wal
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
|
|
"github.com/dailz/go-kv/manifest"
|
|
)
|
|
|
|
// RecoveryResult holds the outcome of a WAL recovery pass.
|
|
type RecoveryResult struct {
|
|
NextSequence uint64
|
|
NextSegmentID uint64
|
|
ReplayedEntries int
|
|
Truncated bool
|
|
TruncateError error // non-nil if tail corruption was found
|
|
}
|
|
|
|
// Recover performs a full WAL recovery: reads the recovery checkpoint from
|
|
// MANIFEST (or CURRENT), scans segments, replays entries, and handles tail
|
|
// truncation. On success the MANIFEST is updated with the new recovery state.
|
|
func Recover(dir string, replayer BatchReplayer) (*RecoveryResult, error) {
|
|
if replayer == nil {
|
|
return nil, fmt.Errorf("wal: recover: replayer is nil")
|
|
}
|
|
|
|
// Step 1: Determine recovery segment ID from MANIFEST.
|
|
recoverySegmentID, err := resolveRecoverySegmentID(dir)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("wal: recover: resolve segment id: %w", err)
|
|
}
|
|
|
|
// Step 2: Scan and replay segments.
|
|
nextSequence, err := RecoverFromSegments(dir, recoverySegmentID, replayer)
|
|
if err != nil {
|
|
if !IsTailCorruption(err) {
|
|
return nil, fmt.Errorf("wal: recover: %w", err)
|
|
}
|
|
|
|
// Step 3: Tail corruption — truncate the last segment and accept
|
|
// partial data loss for Phase 1.
|
|
result := &RecoveryResult{
|
|
NextSequence: nextSequence,
|
|
Truncated: true,
|
|
TruncateError: err,
|
|
}
|
|
|
|
// Determine nextSegmentID from scanned segments.
|
|
segments, scanErr := ScanSegments(dir, recoverySegmentID)
|
|
if scanErr != nil {
|
|
return nil, fmt.Errorf("wal: recover: scan after tail corruption: %w", scanErr)
|
|
}
|
|
if len(segments) > 0 {
|
|
result.NextSegmentID = segments[len(segments)-1].SegmentID + 1
|
|
} else {
|
|
result.NextSegmentID = recoverySegmentID
|
|
}
|
|
|
|
// Truncate the last segment file to remove corrupted tail.
|
|
if len(segments) > 0 {
|
|
lastSeg := segments[len(segments)-1]
|
|
validOffset, truncErr := findValidOffset(lastSeg.FilePath)
|
|
if truncErr != nil {
|
|
// Best-effort: record the truncation error but don't fail recovery.
|
|
result.TruncateError = fmt.Errorf("%w (find valid offset: %v)", err, truncErr)
|
|
} else if truncErr := truncateSegment(lastSeg.FilePath, validOffset); truncErr != nil {
|
|
result.TruncateError = fmt.Errorf("%w (truncate: %v)", err, truncErr)
|
|
}
|
|
}
|
|
|
|
// Count replayed entries by re-scanning the replayer state.
|
|
// For Phase 1 we accept that ReplayedEntries may be approximate;
|
|
// the replayer interface doesn't expose a count.
|
|
result.ReplayedEntries = 0 // caller can inspect replayer directly
|
|
|
|
// Per design §3.2 line 280, recovery repair must NOT update MANIFEST.
|
|
// The truncated WAL state is persisted via ftruncate (see C5 for the
|
|
// remaining fsync gaps). MANIFEST can only advance via checkpoint
|
|
// (MemTable flush) in future phases.
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// Step 4: Successful recovery — compute result.
|
|
segments, scanErr := ScanSegments(dir, recoverySegmentID)
|
|
if scanErr != nil {
|
|
return nil, fmt.Errorf("wal: recover: scan after replay: %w", scanErr)
|
|
}
|
|
|
|
result := &RecoveryResult{
|
|
NextSequence: nextSequence,
|
|
NextSegmentID: recoverySegmentID,
|
|
Truncated: false,
|
|
}
|
|
if len(segments) > 0 {
|
|
result.NextSegmentID = segments[len(segments)-1].SegmentID + 1
|
|
}
|
|
|
|
// Per design §3.2 line 280, recovery must NOT update MANIFEST.
|
|
// RecoveryResult.NextSegmentID is in-memory only, consumed by DB.Open to
|
|
// seed the new WalWriter. MANIFEST stays at its pre-recovery value.
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// resolveRecoverySegmentID returns the recovery start segment ID from MANIFEST.
|
|
// MANIFEST is the only authoritative source of recovery start per design §3.2
|
|
// line 600-06. CURRENT is a write-side hint and must NOT be used here.
|
|
func resolveRecoverySegmentID(dir string) (uint64, error) {
|
|
mf, err := manifest.Load(dir)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("load manifest: %w", err)
|
|
}
|
|
return mf.RecoverySegmentID, nil
|
|
}
|
|
|
|
// truncateSegment truncates the file at filePath to validOffset bytes,
|
|
// removing any corrupted data after that point.
|
|
func truncateSegment(filePath string, validOffset int64) error {
|
|
if validOffset < 0 {
|
|
return fmt.Errorf("wal: truncate: invalid offset %d", validOffset)
|
|
}
|
|
return os.Truncate(filePath, validOffset)
|
|
}
|
|
|
|
// findValidOffset parses a segment file and returns the byte offset of the
|
|
// last valid record boundary. The offset includes the file header size.
|
|
func findValidOffset(filePath string) (int64, error) {
|
|
// Re-parse the file to find where valid records end.
|
|
// We need to track the byte offset as we parse.
|
|
f, err := os.Open(filePath)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("open for offset scan: %w", err)
|
|
}
|
|
defer f.Close()
|
|
|
|
if _, err := f.Seek(WalFileHeaderSize, 0); err != nil {
|
|
return 0, fmt.Errorf("seek past header: %w", err)
|
|
}
|
|
|
|
validOffset := int64(WalFileHeaderSize)
|
|
buf := make([]byte, WalBlockSize)
|
|
|
|
for {
|
|
n, readErr := f.Read(buf)
|
|
if readErr != nil {
|
|
break
|
|
}
|
|
if n == 0 {
|
|
break
|
|
}
|
|
|
|
blockData := buf[:n]
|
|
blockStartOffset := validOffset
|
|
pos := 0
|
|
|
|
for pos < len(blockData) {
|
|
remaining := len(blockData) - pos
|
|
|
|
if remaining < PhysicalRecordHeaderSize {
|
|
// Check if remaining bytes are zero-padding.
|
|
if isAllZeros(blockData[pos:]) {
|
|
// Valid padding — update offset to end of last valid record.
|
|
validOffset = blockStartOffset + int64(pos)
|
|
}
|
|
// Either way, we're done with this block.
|
|
break
|
|
}
|
|
|
|
if isAllZeros(blockData[pos : pos+PhysicalRecordHeaderSize]) {
|
|
if isAllZeros(blockData[pos:]) {
|
|
validOffset = blockStartOffset + int64(pos)
|
|
}
|
|
break
|
|
}
|
|
|
|
_, consumed, err := DecodePhysicalRecord(blockData[pos:])
|
|
if err != nil {
|
|
// Corruption starts here — offset is up to last valid record.
|
|
validOffset = blockStartOffset + int64(pos)
|
|
return validOffset, nil
|
|
}
|
|
|
|
// Valid record found.
|
|
validOffset = blockStartOffset + int64(pos+consumed)
|
|
pos += consumed
|
|
}
|
|
|
|
if n < WalBlockSize {
|
|
break
|
|
}
|
|
}
|
|
|
|
return validOffset, nil
|
|
}
|