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).
140 lines
3.7 KiB
Go
140 lines
3.7 KiB
Go
package wal
|
|
|
|
import (
|
|
"bytes"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
)
|
|
|
|
// TailCorruptionError indicates that the WAL tail contains corrupt data
|
|
// (bad CRC, unexpected non-zero padding bytes, etc.). Recovery may safely
|
|
// truncate at the last valid record.
|
|
//
|
|
// SegmentPath is set by ReplaySegmentFile when it propagates the error,
|
|
// so Recover can use it as the authoritative truncation target.
|
|
type TailCorruptionError struct {
|
|
Offset int
|
|
SegmentPath string
|
|
Err error
|
|
}
|
|
|
|
func (e *TailCorruptionError) Error() string {
|
|
if e.SegmentPath != "" {
|
|
return fmt.Sprintf("wal: tail corruption in %s at offset %d: %v", e.SegmentPath, e.Offset, e.Err)
|
|
}
|
|
return fmt.Sprintf("wal: tail corruption at offset %d: %v", e.Offset, e.Err)
|
|
}
|
|
|
|
func (e *TailCorruptionError) Unwrap() error {
|
|
return e.Err
|
|
}
|
|
|
|
// IsTailCorruption reports whether err indicates tail corruption in a WAL
|
|
// segment. Callers may safely truncate the segment at the last valid record.
|
|
func IsTailCorruption(err error) bool {
|
|
var tce *TailCorruptionError
|
|
return errors.As(err, &tce)
|
|
}
|
|
|
|
// ParseBlock parses physical records from a single block of raw bytes.
|
|
// The block is typically WalBlockSize (32 KB) bytes, but the last block of a
|
|
// segment may be shorter. Trailing bytes after the last record must be all
|
|
// zeros (padding); non-zero trailing bytes produce a TailCorruptionError.
|
|
func ParseBlock(data []byte) ([]*PhysicalRecord, error) {
|
|
var records []*PhysicalRecord
|
|
pos := 0
|
|
|
|
for pos < len(data) {
|
|
remaining := len(data) - pos
|
|
|
|
// If fewer than PhysicalRecordHeaderSize bytes remain, they must be
|
|
// zero-padding.
|
|
if remaining < PhysicalRecordHeaderSize {
|
|
tail := data[pos:]
|
|
if !isAllZeros(tail) {
|
|
return records[:len(records):len(records)], &TailCorruptionError{
|
|
Offset: pos,
|
|
Err: fmt.Errorf("non-zero padding bytes in tail (%d bytes)", len(tail)),
|
|
}
|
|
}
|
|
break
|
|
}
|
|
|
|
// Check for zero-filled header (preallocated / unwritten space).
|
|
if isAllZeros(data[pos : pos+PhysicalRecordHeaderSize]) {
|
|
// Verify rest of block is also zeros.
|
|
if !isAllZeros(data[pos:]) {
|
|
return records[:len(records):len(records)], &TailCorruptionError{
|
|
Offset: pos,
|
|
Err: errors.New("zero header but non-zero bytes follow"),
|
|
}
|
|
}
|
|
break
|
|
}
|
|
|
|
rec, consumed, err := DecodePhysicalRecord(data[pos:])
|
|
if err != nil {
|
|
return records[:len(records):len(records)], &TailCorruptionError{
|
|
Offset: pos,
|
|
Err: err,
|
|
}
|
|
}
|
|
records = append(records, rec)
|
|
pos += consumed
|
|
}
|
|
|
|
return records, nil
|
|
}
|
|
|
|
// ParseRecordsFromFile opens a WAL segment file, skips the file header, reads
|
|
// blocks sequentially, and returns all physical records in order. Short final
|
|
// blocks are handled correctly.
|
|
func ParseRecordsFromFile(filePath string) ([]*PhysicalRecord, error) {
|
|
f, err := os.Open(filePath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("wal: parse records: %w", err)
|
|
}
|
|
defer f.Close()
|
|
|
|
// Skip file header.
|
|
if _, err := f.Seek(WalFileHeaderSize, 0); err != nil {
|
|
return nil, fmt.Errorf("wal: seek past header: %w", err)
|
|
}
|
|
|
|
var allRecords []*PhysicalRecord
|
|
buf := make([]byte, WalBlockSize)
|
|
|
|
for {
|
|
n, readErr := f.Read(buf)
|
|
if readErr != nil {
|
|
if errors.Is(readErr, os.ErrClosed) {
|
|
return nil, fmt.Errorf("wal: file closed during read: %w", readErr)
|
|
}
|
|
break
|
|
}
|
|
if n == 0 {
|
|
break
|
|
}
|
|
|
|
blockData := buf[:n]
|
|
recs, err := ParseBlock(blockData)
|
|
if err != nil {
|
|
// Return records collected so far along with the error.
|
|
return allRecords, err
|
|
}
|
|
allRecords = append(allRecords, recs...)
|
|
|
|
// If we got a short block, this was the last one.
|
|
if n < WalBlockSize {
|
|
break
|
|
}
|
|
}
|
|
|
|
return allRecords, nil
|
|
}
|
|
|
|
func isAllZeros(data []byte) bool {
|
|
return bytes.Count(data, []byte{0}) == len(data)
|
|
}
|