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:
@@ -10,12 +10,19 @@ import (
|
||||
// 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
|
||||
Err error
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
+15
-6
@@ -1,6 +1,7 @@
|
||||
package wal
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
@@ -59,19 +60,27 @@ func Recover(dir string, replayer BatchReplayer) (*RecoveryResult, error) {
|
||||
}
|
||||
|
||||
if len(segments) > 0 {
|
||||
lastSeg := segments[len(segments)-1]
|
||||
lastCompleteBatchEnd, findErr := findLastCompleteBatchEnd(lastSeg.FilePath)
|
||||
// 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 will need to
|
||||
// identify trailing empties based on the actually-corrupted
|
||||
// segment's index, which is not the same as segments[last].
|
||||
// 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(lastSeg.FilePath, lastCompleteBatchEnd, dir, emptyTrailing); err != nil {
|
||||
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)
|
||||
}
|
||||
|
||||
+150
-1
@@ -118,7 +118,7 @@ func TestRecoverWithTailCorruption(t *testing.T) {
|
||||
|
||||
// Verify the truncated file still parses cleanly.
|
||||
replayer2 := &mockReplayer{}
|
||||
_, parseErr := ReplaySegmentFile(filePath, 100, replayer2)
|
||||
_, parseErr := ReplaySegmentFile(filePath, 100, true, replayer2)
|
||||
if parseErr != nil {
|
||||
t.Fatalf("replay after truncation: %v", parseErr)
|
||||
}
|
||||
@@ -451,3 +451,152 @@ func TestRecoverInvalidBatchNotTruncatable(t *testing.T) {
|
||||
fi.Size(), int64(WalFileHeaderSize)+int64(len(rec)))
|
||||
}
|
||||
}
|
||||
|
||||
// -------- C4 integration regression guards --------
|
||||
|
||||
// Regression guard for C4: middle segment CollectingFragments must hard-fail
|
||||
// Recover, NOT truncate the (valid) last segment. This is the key bug Oracle
|
||||
// flagged: "wal/recover.go 总是对 segments[len(segments)-1] 调用截断".
|
||||
func TestRecoverMiddleSegmentCorruptionHardFails(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
// segment-0: [Batch seq 0-1] (2 entries → next=2)
|
||||
// segment-1: [Batch seq 2-3] + [First][Middle no Last] ← middle corruption
|
||||
// (2 complete entries → next=4 if recovery reached end)
|
||||
// segment-2: [Batch seq 4-5] (valid; never reached)
|
||||
writeTestSegment(t, dir, 0, 0, [][]*WalEntry{
|
||||
{makePutEntry("k0", "v0"), makePutEntry("k1", "v1")},
|
||||
})
|
||||
|
||||
seg1Path := writeTestSegment(t, dir, 1, 2, [][]*WalEntry{
|
||||
{makePutEntry("k2", "v2"), makePutEntry("k3", "v3")},
|
||||
})
|
||||
appendFileBytes(t, seg1Path, EncodePhysicalRecord(RecFirst, []byte("first-frag")))
|
||||
appendFileBytes(t, seg1Path, EncodePhysicalRecord(RecMiddle, []byte("middle-frag")))
|
||||
|
||||
writeTestSegment(t, dir, 2, 4, [][]*WalEntry{
|
||||
{makePutEntry("k4", "v4"), makePutEntry("k5", "v5")},
|
||||
})
|
||||
|
||||
seg2Path := filepath.Join(dir, "segment-2.wal")
|
||||
fiBefore, _ := os.Stat(seg2Path)
|
||||
|
||||
_, err := Recover(dir, &mockReplayer{})
|
||||
if err == nil {
|
||||
t.Fatal("expected Recover to fail on middle segment corruption")
|
||||
}
|
||||
if IsTailCorruption(err) {
|
||||
t.Errorf("expected HARD error (not tail corruption) for middle segment; got %v", err)
|
||||
}
|
||||
|
||||
// CRITICAL: segment-2 must NOT be truncated (it's completely valid).
|
||||
fiAfter, _ := os.Stat(seg2Path)
|
||||
if fiAfter.Size() != fiBefore.Size() {
|
||||
t.Errorf("segment-2 was modified: before=%d after=%d (C4-2 regression)",
|
||||
fiBefore.Size(), fiAfter.Size())
|
||||
}
|
||||
}
|
||||
|
||||
// Regression guard for C4 (parser corruption half): middle segment CRC
|
||||
// corruption must hard-fail Recover, NOT truncate the (valid) last segment.
|
||||
// Tests the path where ParseBlock returns TailCorruptionError and
|
||||
// ReplaySegmentFile converts it to hard error for non-last segment.
|
||||
func TestRecoverMiddleSegmentCRCCorruptionHardFails(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeTestSegment(t, dir, 0, 0, [][]*WalEntry{
|
||||
{makePutEntry("k0", "v0"), makePutEntry("k1", "v1")},
|
||||
})
|
||||
|
||||
seg1Path := writeTestSegment(t, dir, 1, 2, [][]*WalEntry{
|
||||
{makePutEntry("k2", "v2"), makePutEntry("k3", "v3")},
|
||||
})
|
||||
// Append CRC-corrupted bytes (will fail DecodePhysicalRecord CRC check).
|
||||
appendFileBytes(t, seg1Path, []byte{0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF})
|
||||
|
||||
writeTestSegment(t, dir, 2, 4, [][]*WalEntry{
|
||||
{makePutEntry("k4", "v4"), makePutEntry("k5", "v5")},
|
||||
})
|
||||
|
||||
seg2Path := filepath.Join(dir, "segment-2.wal")
|
||||
fiBefore, _ := os.Stat(seg2Path)
|
||||
|
||||
_, err := Recover(dir, &mockReplayer{})
|
||||
if err == nil {
|
||||
t.Fatal("expected Recover to fail on middle segment CRC corruption")
|
||||
}
|
||||
if IsTailCorruption(err) {
|
||||
t.Errorf("expected HARD error (not tail corruption); got %v", err)
|
||||
}
|
||||
|
||||
fiAfter, _ := os.Stat(seg2Path)
|
||||
if fiAfter.Size() != fiBefore.Size() {
|
||||
t.Errorf("segment-2 modified: before=%d after=%d", fiBefore.Size(), fiAfter.Size())
|
||||
}
|
||||
}
|
||||
|
||||
// Regression guard for C4: single-segment tail corruption still truncates
|
||||
// correctly (existing behavior preserved).
|
||||
func TestRecoverLastSegmentCorruptionTruncatesCorrectly(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
batchA := []*WalEntry{makePutEntry("k0", "v0")}
|
||||
batchB := []*WalEntry{makePutEntry("k1", "v1")}
|
||||
filePath := writeTestSegment(t, dir, 0, 0, [][]*WalEntry{batchA, batchB})
|
||||
|
||||
encA, _ := EncodeWalBatch(0, batchA)
|
||||
encB, _ := EncodeWalBatch(1, batchB)
|
||||
endOfBatch1 := int64(WalFileHeaderSize) +
|
||||
int64(PhysicalRecordHeaderSize+len(encA)) +
|
||||
int64(PhysicalRecordHeaderSize+len(encB))
|
||||
|
||||
// Append partial fragments to last (only) segment.
|
||||
appendFileBytes(t, filePath, EncodePhysicalRecord(RecFirst, []byte("first")))
|
||||
appendFileBytes(t, filePath, EncodePhysicalRecord(RecMiddle, []byte("middle")))
|
||||
|
||||
result, err := Recover(dir, &mockReplayer{})
|
||||
if err != nil {
|
||||
t.Fatalf("Recover: %v", err)
|
||||
}
|
||||
if !result.Truncated {
|
||||
t.Fatal("Truncated = false, want true for last-segment corruption")
|
||||
}
|
||||
|
||||
fi, _ := os.Stat(filePath)
|
||||
if fi.Size() != endOfBatch1 {
|
||||
t.Errorf("file size = %d, want %d", fi.Size(), endOfBatch1)
|
||||
}
|
||||
}
|
||||
|
||||
// Regression guard for C4: multi-segment with last-segment corruption still
|
||||
// works correctly (the legitimate tail-truncation case).
|
||||
func TestRecoverMultiSegmentLastSegmentCorruptionTruncatesLast(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeTestSegment(t, dir, 0, 0, [][]*WalEntry{
|
||||
{makePutEntry("k0", "v0")},
|
||||
})
|
||||
seg1Path := writeTestSegment(t, dir, 1, 1, [][]*WalEntry{
|
||||
{makePutEntry("k1", "v1")},
|
||||
{makePutEntry("k2", "v2")},
|
||||
})
|
||||
|
||||
encA, _ := EncodeWalBatch(1, []*WalEntry{makePutEntry("k1", "v1")})
|
||||
encB, _ := EncodeWalBatch(2, []*WalEntry{makePutEntry("k2", "v2")})
|
||||
endOfBatch2 := int64(WalFileHeaderSize) +
|
||||
int64(PhysicalRecordHeaderSize+len(encA)) +
|
||||
int64(PhysicalRecordHeaderSize+len(encB))
|
||||
|
||||
// Append partial fragments to segment-1 (the LAST segment).
|
||||
appendFileBytes(t, seg1Path, EncodePhysicalRecord(RecFirst, []byte("first")))
|
||||
appendFileBytes(t, seg1Path, EncodePhysicalRecord(RecMiddle, []byte("middle")))
|
||||
|
||||
result, err := Recover(dir, &mockReplayer{})
|
||||
if err != nil {
|
||||
t.Fatalf("Recover: %v", err)
|
||||
}
|
||||
if !result.Truncated {
|
||||
t.Fatal("Truncated = false, want true")
|
||||
}
|
||||
|
||||
fi, _ := os.Stat(seg1Path)
|
||||
if fi.Size() != endOfBatch2 {
|
||||
t.Errorf("segment-1 size = %d, want %d (end of Batch 2)", fi.Size(), endOfBatch2)
|
||||
}
|
||||
}
|
||||
|
||||
+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
|
||||
|
||||
+88
-2
@@ -4,6 +4,7 @@ import (
|
||||
"errors"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -121,7 +122,7 @@ func TestReplaySegmentFileFull(t *testing.T) {
|
||||
})
|
||||
|
||||
replayer := &mockReplayer{}
|
||||
next, err := ReplaySegmentFile(filePath, 50, replayer)
|
||||
next, err := ReplaySegmentFile(filePath, 50, true, replayer)
|
||||
if err != nil {
|
||||
t.Fatalf("ReplaySegmentFile: %v", err)
|
||||
}
|
||||
@@ -147,7 +148,7 @@ func TestReplaySegmentFileTailCorruption(t *testing.T) {
|
||||
appendFileBytes(t, filePath, []byte{0x01, 0x02, 0x03})
|
||||
|
||||
replayer := &mockReplayer{}
|
||||
next, err := ReplaySegmentFile(filePath, 70, replayer)
|
||||
next, err := ReplaySegmentFile(filePath, 70, true, replayer)
|
||||
if err == nil {
|
||||
t.Fatal("ReplaySegmentFile succeeded, want tail corruption error")
|
||||
}
|
||||
@@ -250,3 +251,88 @@ func appendFileBytes(t *testing.T, filePath string, data []byte) {
|
||||
t.Fatalf("Write corruption bytes: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// -------- C4 regression guards: isLastSegment controls corruption classification --------
|
||||
|
||||
// Regression guards for C4: isLastSegment controls whether CollectingFragments
|
||||
// at end is tail corruption (truncatable) or hard corruption (must hard-fail).
|
||||
|
||||
func TestReplaySegmentFile_LastSegmentCollectingFragmentsIsTailCorruption(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
filePath := filepath.Join(dir, "segment-0.wal")
|
||||
writeTestSegment(t, dir, 0, 0, [][]*WalEntry{
|
||||
{makePutEntry("k", "v")},
|
||||
})
|
||||
// Append First + Middle (no Last) to leave collector in Collecting state.
|
||||
appendFileBytes(t, filePath, EncodePhysicalRecord(RecFirst, []byte("first")))
|
||||
appendFileBytes(t, filePath, EncodePhysicalRecord(RecMiddle, []byte("middle")))
|
||||
|
||||
_, err := ReplaySegmentFile(filePath, 0, true, &mockReplayer{})
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if !IsTailCorruption(err) {
|
||||
t.Errorf("expected TailCorruptionError for last segment, got: %v", err)
|
||||
}
|
||||
var tce *TailCorruptionError
|
||||
if errors.As(err, &tce) {
|
||||
if tce.SegmentPath != filePath {
|
||||
t.Errorf("SegmentPath = %q, want %q", tce.SegmentPath, filePath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplaySegmentFile_NonLastSegmentCollectingFragmentsIsHardError(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
filePath := filepath.Join(dir, "segment-0.wal")
|
||||
writeTestSegment(t, dir, 0, 0, [][]*WalEntry{
|
||||
{makePutEntry("k", "v")},
|
||||
})
|
||||
appendFileBytes(t, filePath, EncodePhysicalRecord(RecFirst, []byte("first")))
|
||||
appendFileBytes(t, filePath, EncodePhysicalRecord(RecMiddle, []byte("middle")))
|
||||
|
||||
_, err := ReplaySegmentFile(filePath, 0, false, &mockReplayer{})
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if IsTailCorruption(err) {
|
||||
t.Errorf("expected HARD error for non-last segment, got TailCorruptionError: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplaySegmentFile_LastSegmentParseErrorIsTailCorruption(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
filePath := filepath.Join(dir, "segment-0.wal")
|
||||
writeTestSegment(t, dir, 0, 0, [][]*WalEntry{
|
||||
{makePutEntry("k", "v")},
|
||||
})
|
||||
// Append corrupted bytes to trigger ParseBlock's CRC failure path.
|
||||
appendFileBytes(t, filePath, []byte{0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF})
|
||||
|
||||
_, err := ReplaySegmentFile(filePath, 0, true, &mockReplayer{})
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if !IsTailCorruption(err) {
|
||||
t.Errorf("expected TailCorruptionError for last segment parse error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplaySegmentFile_NonLastSegmentParseErrorIsHardError(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
filePath := filepath.Join(dir, "segment-0.wal")
|
||||
writeTestSegment(t, dir, 0, 0, [][]*WalEntry{
|
||||
{makePutEntry("k", "v")},
|
||||
})
|
||||
appendFileBytes(t, filePath, []byte{0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF})
|
||||
|
||||
_, err := ReplaySegmentFile(filePath, 0, false, &mockReplayer{})
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
// Per C4 fix: non-last segment parser corruption is hard error (NOT TailCorruption).
|
||||
// Implemented via %v (not %w) so errors.As cannot find underlying TailCorruptionError.
|
||||
if IsTailCorruption(err) {
|
||||
t.Errorf("expected HARD error for non-last segment parse error, got TailCorruptionError: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user