package wal import ( "errors" "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 is informational only: non-nil means "tail corruption // was found and repair was attempted". It does NOT report persistence // failures — those cause Recover to return an error instead. TruncateError error } // Recover performs a full WAL recovery: reads the recovery checkpoint from // MANIFEST, scans segments, replays entries, and persists tail truncation // per design §3.2 line 787-800. 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 per design // §3.2 line 787-800. result := &RecoveryResult{ NextSequence: nextSequence, Truncated: true, TruncateError: err, // informational: tail corruption was detected } 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 } if len(segments) > 0 { // After C4 fix, TailCorruptionError is only returned for the last // segment. Use tce.SegmentPath as authoritative truncation target // (defensive: fall back to segments[last] if missing). corruptedPath := segments[len(segments)-1].FilePath var tce *TailCorruptionError if errors.As(err, &tce) && tce.SegmentPath != "" { corruptedPath = tce.SegmentPath } lastCompleteBatchEnd, findErr := findLastCompleteBatchEnd(corruptedPath) if findErr != nil { return nil, fmt.Errorf("wal: recover: find truncation offset: %w", findErr) } // Phase 1: truncated segment is always segments[last], no // trailing empty segments to clean up. C4 fix means non-last // segment corruption hard-fails above, so we never reach here // with a non-last corrupted segment. var emptyTrailing []string if err := truncateAndPersist(corruptedPath, lastCompleteBatchEnd, dir, emptyTrailing); err != nil { // Per design §3.2 line 799: DB must NOT enter writable state. return nil, fmt.Errorf("wal: recover: persist tail truncation: %w", err) } } result.ReplayedEntries = 0 // Phase 1: replayer interface doesn't expose count // Per design §3.2 line 280, recovery repair must NOT update MANIFEST. // The truncated WAL state is persisted via ftruncate + fsync segment // + fsync dir (see truncateAndPersist). 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 } // segmentFsyncFn is the package-level indirection for fsyncing a truncated // segment file. Tests that override this must not use t.Parallel(). // Same pattern as dirFsyncFn (see wal/dir_fsync.go). var segmentFsyncFn = segmentFsync func segmentFsync(filePath string) error { f, err := os.OpenFile(filePath, os.O_WRONLY, 0o644) if err != nil { return fmt.Errorf("open for fsync: %w", err) } defer f.Close() if err := f.Sync(); err != nil { return fmt.Errorf("fsync: %w", err) } return nil } // truncateAndPersist executes the 4-step tail-truncation protocol per // design §3.2 line 787-794. Any step failure is fatal: per line 799, // DB must NOT enter writable state if truncation cannot be persisted. func truncateAndPersist( filePath string, lastCompleteBatchEnd int64, dir string, emptyTrailingSegments []string, ) error { // Step 1: ftruncate if lastCompleteBatchEnd < 0 { return fmt.Errorf("wal: invalid truncate offset %d", lastCompleteBatchEnd) } if err := os.Truncate(filePath, lastCompleteBatchEnd); err != nil { return fmt.Errorf("ftruncate %s to %d: %w", filePath, lastCompleteBatchEnd, err) } // Step 2: fsync the truncated segment if err := segmentFsyncFn(filePath); err != nil { return fmt.Errorf("fsync truncated segment: %w", err) } // Step 3: delete empty trailing segments for _, segPath := range emptyTrailingSegments { if err := os.Remove(segPath); err != nil { return fmt.Errorf("remove empty segment %s: %w", segPath, err) } } // Step 4: fsync WAL directory (reuses C6's dirFsyncFn) if err := dirFsyncFn(dir); err != nil { return fmt.Errorf("fsync WAL dir after truncation: %w", err) } return nil } // findLastCompleteBatchEnd walks the segment file, runs physical records // through the FragmentCollector state machine, and returns the byte offset // of the END of the last complete WAL Batch. // // This is the correct truncation target per design §3.2 line 786. A previous // version (findValidOffset) only checked physical record CRCs, missing the // case where a First + Middle* fragment chain has no Last (H8 bug): physical // CRCs pass but no complete batch exists at that offset. // // Block-boundary handling: WAL format allows a full block to end with zero // padding when the next record doesn't fit (see BlockWriter.paddingNeeded). // This function CONTINUES to the next block on padding in a full block, and // only RETURNS on padding in a short (final) block or actual corruption. func findLastCompleteBatchEnd(filePath string) (int64, error) { f, err := os.Open(filePath) if err != nil { return 0, fmt.Errorf("open %s: %w", filePath, err) } defer f.Close() if _, err := f.Seek(WalFileHeaderSize, 0); err != nil { return 0, fmt.Errorf("seek past header: %w", err) } collector := NewFragmentCollector() lastCompleteEnd := int64(WalFileHeaderSize) blockStartOffset := int64(WalFileHeaderSize) buf := make([]byte, WalBlockSize) for { n, readErr := f.Read(buf) if n > 0 { blockData := buf[:n] isFullBlock := n == WalBlockSize && readErr == nil pos := 0 for pos < len(blockData) { remaining := len(blockData) - pos if remaining < PhysicalRecordHeaderSize { if isFullBlock { break // padding in full block, continue to next block } return lastCompleteEnd, nil // tail padding in short block } if isAllZeros(blockData[pos : pos+PhysicalRecordHeaderSize]) { if isFullBlock { break // zero-led padding in full block, continue } return lastCompleteEnd, nil // tail padding } rec, consumed, err := DecodePhysicalRecord(blockData[pos:]) if err != nil { return lastCompleteEnd, nil // physical corruption } if err := collector.Append(rec.Type, rec.Payload); err != nil { return lastCompleteEnd, nil // fragment state machine rejected } pos += consumed recordEndAbsolute := blockStartOffset + int64(pos) if collector.IsComplete() { lastCompleteEnd = recordEndAbsolute collector.Reset() } } blockStartOffset += int64(n) } if readErr != nil || n < WalBlockSize { break } } return lastCompleteEnd, nil }