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).
This commit is contained in:
+35
-6
@@ -100,13 +100,35 @@ func ReplayBatch(batch *WalBatch, expectedSequence uint64, replayer BatchReplaye
|
||||
}
|
||||
|
||||
// ReplaySegmentFile replays all complete WAL batches from one segment file.
|
||||
func ReplaySegmentFile(filePath string, startSequence uint64, replayer BatchReplayer) (nextSequence uint64, err error) {
|
||||
//
|
||||
// isLastSegment controls how parse errors and CollectingFragments-at-end are
|
||||
// classified per design §3.2 line 704:
|
||||
// - true: tail corruption (TailCorruptionError, truncatable by Recover)
|
||||
// - false: hard corruption (plain error, Recover must hard-fail)
|
||||
func ReplaySegmentFile(filePath string, startSequence uint64, isLastSegment bool, replayer BatchReplayer) (nextSequence uint64, err error) {
|
||||
nextSequence = startSequence
|
||||
records, parseErr := ParseRecordsFromFile(filePath)
|
||||
if parseErr != nil && !IsTailCorruption(parseErr) {
|
||||
return nextSequence, fmt.Errorf("wal: parse segment records: %w", parseErr)
|
||||
}
|
||||
|
||||
// Attach SegmentPath to parseErr for downstream diagnostics + truncation target.
|
||||
if parseErr != nil {
|
||||
var tce *TailCorruptionError
|
||||
if errors.As(parseErr, &tce) {
|
||||
tce.SegmentPath = filePath
|
||||
}
|
||||
}
|
||||
|
||||
// C4 fix: tail corruption in non-last segment is hard corruption per
|
||||
// design §3.2 line 704. Use %v (NOT %w) so IsTailCorruption returns false
|
||||
// — otherwise errors.As would still find the underlying *TailCorruptionError
|
||||
// through the %w chain and Recover would treat it as truncatable.
|
||||
if parseErr != nil && !isLastSegment {
|
||||
return nextSequence, fmt.Errorf("wal: corruption in non-last segment %s (hard corruption): %v",
|
||||
filePath, parseErr)
|
||||
}
|
||||
|
||||
collector := NewFragmentCollector()
|
||||
for _, record := range records {
|
||||
if err := collector.Append(record.Type, record.Payload); err != nil {
|
||||
@@ -131,10 +153,16 @@ func ReplaySegmentFile(filePath string, startSequence uint64, replayer BatchRepl
|
||||
return nextSequence, parseErr
|
||||
}
|
||||
if collector.State() == FragmentCollecting {
|
||||
return nextSequence, &TailCorruptionError{
|
||||
Offset: 0,
|
||||
Err: errors.New("incomplete fragmented batch at segment tail"),
|
||||
if isLastSegment {
|
||||
return nextSequence, &TailCorruptionError{
|
||||
Offset: 0,
|
||||
SegmentPath: filePath,
|
||||
Err: errors.New("incomplete fragmented batch at segment tail"),
|
||||
}
|
||||
}
|
||||
// Non-last segment with incomplete fragments = middle corruption.
|
||||
// Plain error (no TailCorruptionError) so IsTailCorruption is false.
|
||||
return nextSequence, fmt.Errorf("wal: incomplete fragmented batch in non-last segment %s (hard corruption)", filePath)
|
||||
}
|
||||
|
||||
return nextSequence, nil
|
||||
@@ -151,12 +179,13 @@ func RecoverFromSegments(dir string, recoverySegmentID uint64, replayer BatchRep
|
||||
}
|
||||
|
||||
nextSequence = segments[0].StartSequence
|
||||
for _, segment := range segments {
|
||||
for i, segment := range segments {
|
||||
if segment.StartSequence != nextSequence {
|
||||
return nextSequence, fmt.Errorf("wal: segment start sequence %d does not match expected sequence %d", segment.StartSequence, nextSequence)
|
||||
}
|
||||
|
||||
nextSequence, err = ReplaySegmentFile(segment.FilePath, nextSequence, replayer)
|
||||
isLastSegment := i == len(segments)-1
|
||||
nextSequence, err = ReplaySegmentFile(segment.FilePath, nextSequence, isLastSegment, replayer)
|
||||
if err != nil {
|
||||
if IsTailCorruption(err) {
|
||||
return nextSequence, err
|
||||
|
||||
Reference in New Issue
Block a user