Files
go-kv/wal/recover.go
T
dailz 3d2d0ea025 fix: treat non-last segment corruption as hard error (C4)
Per design §3.2 line 704, tail corruption in a non-last WAL segment is
middle corruption, which must hard-fail recovery instead of being
silently truncated. The previous code in wal/recovery.go always returned
TailCorruptionError for CollectingFragments state or parse errors,
regardless of segment position. Recover then always truncated
segments[last], which could corrupt a valid last segment when the actual
corruption was in a middle segment.

Oracle bg_ef425776 flagged an additional failure mode: "wal/recover.go
总是对 segments[len(segments)-1] 调用截断,但 RecoverFromSegments 的
TailCorruptionError 可能来自非尾段".

Changes:
- wal/record_parser.go: add SegmentPath field to TailCorruptionError for
  diagnostics and defensive truncation target identification.
- wal/recovery.go:
  - ReplaySegmentFile now takes isLastSegment bool parameter.
  - When parse error or CollectingFragments occurs in non-last segment,
    return hard error. Uses %v (NOT %w) so IsTailCorruption returns false
    — otherwise errors.As would still find underlying TailCorruptionError
    through the %w chain and Recover would treat it as truncatable.
  - When in last segment, return TailCorruptionError with SegmentPath set.
  - RecoverFromSegments passes isLastSegment based on iteration index.
- wal/recover.go:
  - Use tce.SegmentPath as authoritative truncation target (defensive
    fallback to segments[last] if missing). After C4 fix, TailCorruptionError
    is only returned for last segment, so this is always segments[last]
    in practice.

Tests:
- wal/recovery_test.go: 4 unit tests for ReplaySegmentFile covering
  last/non-last × CollectingFragments/parse-error matrix. Existing direct
  ReplaySegmentFile calls updated to pass isLastSegment=true.
- wal/recover_test.go: 4 integration tests covering middle-segment
  CollectingFragments corruption (must hard-fail), middle-segment CRC
  corruption (must hard-fail), last-segment corruption in single-segment
  WAL (must truncate), last-segment corruption in multi-segment WAL
  (must truncate only last segment).

Verified: each new test fails on pre-fix code (non-last corruption
silently truncated valid last segment) and passes after the fix. Full
suite green including go test -race ./... .

Audit context: docs/audit-3.2.md C4 (Oracle-verified bg_ef425776).
2026-06-16 09:12:24 +08:00

262 lines
8.4 KiB
Go

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
}