Files
go-kv/wal/recover_test.go
T
dailz 273229ac9b fix: persist WAL tail truncation per design protocol (C5+H8)
Recovery tail-truncation had two compounding bugs in wal/recover.go:

C5: truncateSegment only called os.Truncate. Missing per design §3.2
    line 787-794:
      - Step 2: fsync the truncated segment
      - Step 3: delete empty trailing segments
      - Step 4: fsync WAL directory
    And all errors were swallowed into result.TruncateError with recovery
    still returning success, violating design line 799: "若 ftruncate、
    segment fsync、空 segment 删除或 WAL directory fsync 任一步失败,
    recovery 必须报错,DB 不得进入可写状态".

H8: findValidOffset only checked physical record CRCs, ignoring the
    FragmentCollector state machine. For a tail of First + Middle*
    without Last, it returned the offset AFTER the last Middle fragment
    instead of the last COMPLETE batch end. Result: residual half-batch
    fragments caused repeated tail-corruption reports on every restart.

Changes:
- wal/recover.go:
  - Add findLastCompleteBatchEnd: batch-aware offset finder using
    FragmentCollector state machine. Handles block-boundary padding
    correctly (continue across full-block padding, return on short-block).
  - Add truncateAndPersist: 4-step protocol (ftruncate + fsync segment +
    delete empty trailing + fsync dir). Any step failure is fatal.
  - Add segmentFsyncFn (package-level var for test injection, same
    pattern as C6's dirFsyncFn).
  - Refactor Recover failure path: use new functions, hard-error on
    truncation persist failure (was: swallow to TruncateError).
  - TruncateError field semantics: informational only ("tail corruption
    was detected and repair attempted"). Persist failures return error.
  - Delete findValidOffset and truncateSegment (replaced).
- wal/recover_offset_test.go (new): 8 unit tests for
  findLastCompleteBatchEnd covering clean/partial-tail/no-batch/
  physical-corruption/partial-only/block-boundary-padding/non-zero-tail/
  zero-tail cases. 5 unit tests for truncateAndPersist covering success/
  ftruncate-fail/dir-fsync-fail/segment-fsync-fail/retry-after-failure.
- wal/recover_test.go: add TestRecoverPartialFragmentTailIdempotent
  (H8 e2e regression: truncation point must be at last complete batch),
  TestRecoverTruncationFailureFailsRecovery (C5 e2e regression: any
  step failure fails Recover), TestRecoverInvalidBatchNotTruncatable
  (design line 778-781: invalid batch content hard-fails, NOT truncatable).

Injection note: segmentFsyncFn and dirFsyncFn (from C6) are package-level
vars; tests that override either must not use t.Parallel().

Verified: each new test fails on pre-fix code by logical analysis and
passes after the fix. Full suite green including go test -race ./... .

Phase 1 simplification: emptyTrailingSegments is always nil in Phase 1
(truncated segment is always segments[last]). The parameter is kept in
truncateAndPersist's signature for forward compatibility with the C4 fix.

Audit context: docs/audit-3.2.md C5 and H8 (H8 Oracle-verified bg_ef425776).
2026-06-15 15:15:49 +08:00

454 lines
14 KiB
Go

package wal
import (
"bytes"
"errors"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"github.com/dailz/go-kv/manifest"
)
func TestRecoverFromEmptyDir(t *testing.T) {
dir := t.TempDir()
replayer := &mockReplayer{}
result, err := Recover(dir, replayer)
if err != nil {
t.Fatalf("Recover empty dir: %v", err)
}
if result.NextSequence != 0 {
t.Errorf("NextSequence = %d, want 0", result.NextSequence)
}
if result.Truncated {
t.Error("Truncated = true, want false")
}
if result.TruncateError != nil {
t.Errorf("TruncateError = %v, want nil", result.TruncateError)
}
}
func TestRecoverFullFlow(t *testing.T) {
dir := t.TempDir()
// Write test data: 2 batches across 1 segment.
writeTestSegment(t, dir, 0, 0, [][]*WalEntry{
{makePutEntry("key1", "val1"), makeDeleteEntry("key2")},
{makePutEntry("key3", "val3")},
})
replayer := &mockReplayer{}
result, err := Recover(dir, replayer)
if err != nil {
t.Fatalf("Recover: %v", err)
}
if result.NextSequence != 3 {
t.Errorf("NextSequence = %d, want 3", result.NextSequence)
}
if result.NextSegmentID != 1 {
t.Errorf("NextSegmentID = %d, want 1", result.NextSegmentID)
}
if result.Truncated {
t.Error("Truncated = true, want false")
}
wantPuts := []replayPut{
{key: "key1", value: "val1", seq: 0},
{key: "key3", value: "val3", seq: 2},
}
if !reflect.DeepEqual(replayer.puts, wantPuts) {
t.Errorf("puts = %#v, want %#v", replayer.puts, wantPuts)
}
wantDeletes := []replayDelete{
{key: "key2", seq: 1},
}
if !reflect.DeepEqual(replayer.deletes, wantDeletes) {
t.Errorf("deletes = %#v, want %#v", replayer.deletes, wantDeletes)
}
}
func TestRecoverWithTailCorruption(t *testing.T) {
dir := t.TempDir()
// Write valid batches, then corrupt the tail.
filePath := writeTestSegment(t, dir, 0, 100, [][]*WalEntry{
{makePutEntry("good1", "before-corruption")},
{makePutEntry("good2", "also-before")},
})
appendFileBytes(t, filePath, []byte{0xDE, 0xAD, 0xBE, 0xEF})
replayer := &mockReplayer{}
result, err := Recover(dir, replayer)
if err != nil {
t.Fatalf("Recover with tail corruption: %v", err)
}
if !result.Truncated {
t.Fatal("Truncated = false, want true")
}
if result.TruncateError == nil {
t.Fatal("TruncateError = nil, want non-nil")
}
if result.NextSequence != 102 {
t.Errorf("NextSequence = %d, want 102", result.NextSequence)
}
if result.NextSegmentID != 1 {
t.Errorf("NextSegmentID = %d, want 1", result.NextSegmentID)
}
// Verify only the valid entries were replayed.
wantPuts := []replayPut{
{key: "good1", value: "before-corruption", seq: 100},
{key: "good2", value: "also-before", seq: 101},
}
if !reflect.DeepEqual(replayer.puts, wantPuts) {
t.Errorf("puts = %#v, want %#v", replayer.puts, wantPuts)
}
// Verify the file was truncated — it should be smaller than before.
fi, fiErr := os.Stat(filePath)
if fiErr != nil {
t.Fatalf("Stat truncated file: %v", fiErr)
}
if fi.Size() == 0 {
t.Error("truncated file is empty")
}
// Verify the truncated file still parses cleanly.
replayer2 := &mockReplayer{}
_, parseErr := ReplaySegmentFile(filePath, 100, replayer2)
if parseErr != nil {
t.Fatalf("replay after truncation: %v", parseErr)
}
if !reflect.DeepEqual(replayer2.puts, wantPuts) {
t.Errorf("replay after truncation puts = %#v, want %#v", replayer2.puts, wantPuts)
}
}
func TestRecoverDoesNotUpdateManifest(t *testing.T) {
dir := t.TempDir()
writeTestSegment(t, dir, 0, 50, [][]*WalEntry{
{makePutEntry("a", "b")},
{makePutEntry("c", "d")},
})
beforeMF, err := manifest.Load(dir)
if err != nil {
t.Fatalf("manifest.Load before recover: %v", err)
}
beforeExists := fileExists(t, filepath.Join(dir, "MANIFEST"))
replayer := &mockReplayer{}
if _, err := Recover(dir, replayer); err != nil {
t.Fatalf("Recover: %v", err)
}
afterMF, err := manifest.Load(dir)
if err != nil {
t.Fatalf("manifest.Load after recover: %v", err)
}
if afterMF.RecoverySegmentID != beforeMF.RecoverySegmentID {
t.Errorf("MANIFEST RecoverySegmentID changed: %d -> %d",
beforeMF.RecoverySegmentID, afterMF.RecoverySegmentID)
}
afterExists := fileExists(t, filepath.Join(dir, "MANIFEST"))
if beforeExists != afterExists {
t.Errorf("MANIFEST file existence changed: before=%v after=%v",
beforeExists, afterExists)
}
}
// Regression guard for C3: covers the case where MANIFEST already exists.
// The fresh-DB test above cannot catch accidental overwrites of an existing
// MANIFEST.
func TestRecoverPreservesExistingManifest(t *testing.T) {
dir := t.TempDir()
writeTestSegment(t, dir, 0, 50, [][]*WalEntry{
{makePutEntry("a", "b")},
})
if err := manifest.Save(dir, 0); err != nil {
t.Fatalf("manifest.Save setup: %v", err)
}
beforeBytes, err := os.ReadFile(filepath.Join(dir, "MANIFEST"))
if err != nil {
t.Fatalf("ReadFile MANIFEST: %v", err)
}
replayer := &mockReplayer{}
if _, err := Recover(dir, replayer); err != nil {
t.Fatalf("Recover: %v", err)
}
afterBytes, err := os.ReadFile(filepath.Join(dir, "MANIFEST"))
if err != nil {
t.Fatalf("ReadFile MANIFEST after recover: %v", err)
}
if !bytes.Equal(beforeBytes, afterBytes) {
t.Errorf("MANIFEST bytes changed:\n before=%q\n after=%q",
string(beforeBytes), string(afterBytes))
}
}
// Regression guard for C3: design §3.2 line 280 requires recovery to be
// idempotent on a clean WAL (no MANIFEST side effects).
func TestRecoverIdempotentClean(t *testing.T) {
dir := t.TempDir()
writeTestSegment(t, dir, 0, 0, [][]*WalEntry{
{makePutEntry("k1", "v1")},
{makePutEntry("k2", "v2")},
})
replayer1 := &mockReplayer{}
result1, err := Recover(dir, replayer1)
if err != nil {
t.Fatalf("Recover (1st): %v", err)
}
replayer2 := &mockReplayer{}
result2, err := Recover(dir, replayer2)
if err != nil {
t.Fatalf("Recover (2nd): %v", err)
}
if result1.NextSequence != result2.NextSequence {
t.Errorf("NextSequence differs: %d vs %d", result1.NextSequence, result2.NextSequence)
}
if result1.NextSegmentID != result2.NextSegmentID {
t.Errorf("NextSegmentID differs: %d vs %d", result1.NextSegmentID, result2.NextSegmentID)
}
if result1.Truncated || result2.Truncated {
t.Errorf("Truncated should be false for clean WAL: r1=%v r2=%v",
result1.Truncated, result2.Truncated)
}
if !reflect.DeepEqual(replayer1.puts, replayer2.puts) {
t.Errorf("replayed puts differ:\n r1=%#v\n r2=%#v", replayer1.puts, replayer2.puts)
}
}
// Regression guard for C3: after the first recovery truncates a corrupted
// tail, the second recovery must observe stable state (Truncated=false, same
// NextSequence, same replayed entries).
func TestRecoverIdempotentAfterTruncation(t *testing.T) {
dir := t.TempDir()
filePath := writeTestSegment(t, dir, 0, 100, [][]*WalEntry{
{makePutEntry("good1", "before-corruption")},
{makePutEntry("good2", "also-before")},
})
appendFileBytes(t, filePath, []byte{0xDE, 0xAD, 0xBE, 0xEF})
replayer1 := &mockReplayer{}
result1, err := Recover(dir, replayer1)
if err != nil {
t.Fatalf("Recover (1st): %v", err)
}
if !result1.Truncated {
t.Fatal("1st Recover: Truncated = false, want true")
}
replayer2 := &mockReplayer{}
result2, err := Recover(dir, replayer2)
if err != nil {
t.Fatalf("Recover (2nd): %v", err)
}
if result2.Truncated {
t.Error("2nd Recover: Truncated = true, want false (tail already repaired)")
}
if result1.NextSequence != result2.NextSequence {
t.Errorf("NextSequence differs: %d vs %d", result1.NextSequence, result2.NextSequence)
}
if !reflect.DeepEqual(replayer1.puts, replayer2.puts) {
t.Errorf("replayed puts differ:\n r1=%#v\n r2=%#v", replayer1.puts, replayer2.puts)
}
}
// Regression guard for C2: design §3.2 line 604-06 forbids using CURRENT as
// recovery start. writeTestSegment uses NewSegmentWriter directly, which does
// NOT write CURRENT (only SegmentManager does), so we write CURRENT explicitly
// to simulate the Phase 1 default state.
func TestRecoverIgnoresCurrentFallback(t *testing.T) {
dir := t.TempDir()
writeTestSegment(t, dir, 0, 0, [][]*WalEntry{
{makePutEntry("seg0-k1", "v1")},
})
writeTestSegment(t, dir, 1, 1, [][]*WalEntry{
{makePutEntry("seg1-k1", "v1")},
})
writeTestSegment(t, dir, 2, 2, [][]*WalEntry{
{makePutEntry("seg2-k1", "v1")},
})
if err := manifest.WriteCurrent(dir, 2); err != nil {
t.Fatalf("WriteCurrent: %v", err)
}
currentSegID, ok := manifest.ReadCurrent(dir)
if !ok || currentSegID != 2 {
t.Fatalf("CURRENT setup wrong: segID=%d ok=%v", currentSegID, ok)
}
mf, err := manifest.Load(dir)
if err != nil {
t.Fatalf("Load: %v", err)
}
if mf.RecoverySegmentID != 0 {
t.Fatalf("MANIFEST.RecoverySegmentID = %d, want 0", mf.RecoverySegmentID)
}
replayer := &mockReplayer{}
result, err := Recover(dir, replayer)
if err != nil {
t.Fatalf("Recover: %v", err)
}
wantPuts := []replayPut{
{key: "seg0-k1", value: "v1", seq: 0},
{key: "seg1-k1", value: "v1", seq: 1},
{key: "seg2-k1", value: "v1", seq: 2},
}
if !reflect.DeepEqual(replayer.puts, wantPuts) {
t.Errorf("puts = %#v, want %#v", replayer.puts, wantPuts)
}
if result.NextSequence != 3 {
t.Errorf("NextSequence = %d, want 3", result.NextSequence)
}
if result.NextSegmentID != 3 {
t.Errorf("NextSegmentID = %d, want 3", result.NextSegmentID)
}
}
func fileExists(t *testing.T, path string) bool {
t.Helper()
_, err := os.Stat(path)
if err == nil {
return true
}
if os.IsNotExist(err) {
return false
}
t.Fatalf("stat %s: %v", path, err)
return false
}
// Regression guard for C5+H8: end-to-end recovery with partial fragment
// tail must persist truncation at the last COMPLETE batch boundary
// (H8), and the truncation must be persisted with all 4 steps (C5).
// After repair, second recovery must not see corruption.
func TestRecoverPartialFragmentTailIdempotent(t *testing.T) {
dir := t.TempDir()
batchA := []*WalEntry{makePutEntry("key-A", "val-A")}
batchB := []*WalEntry{makePutEntry("key-B", "val-B")}
filePath := writeTestSegment(t, dir, 0, 0, [][]*WalEntry{batchA, batchB})
// Compute exact byte offset where Batch B's Full record ends.
// Layout: [header][Batch A Full record][Batch B Full record][padding to 32KB]
encA, _ := EncodeWalBatch(0, batchA)
encB, _ := EncodeWalBatch(1, batchB)
endOfBatchB := int64(WalFileHeaderSize) +
int64(PhysicalRecordHeaderSize+len(encA)) +
int64(PhysicalRecordHeaderSize+len(encB))
fiBefore, _ := os.Stat(filePath)
// Append First + Middle* (no Last) to simulate partial fragment tail.
appendFileBytes(t, filePath, EncodePhysicalRecord(RecFirst, []byte("first-fragment-payload")))
appendFileBytes(t, filePath, EncodePhysicalRecord(RecMiddle, []byte("middle-fragment-payload")))
replayer1 := &mockReplayer{}
result1, err := Recover(dir, replayer1)
if err != nil {
t.Fatalf("1st Recover: %v", err)
}
if !result1.Truncated {
t.Fatal("1st Recover: Truncated = false, want true")
}
fiAfter, _ := os.Stat(filePath)
if fiAfter.Size() != endOfBatchB {
t.Errorf("file size after truncation = %d, want %d (end of Batch B, H8)",
fiAfter.Size(), endOfBatchB)
}
if fiAfter.Size() >= fiBefore.Size() {
t.Errorf("file should shrink after truncation: before=%d after=%d",
fiBefore.Size(), fiAfter.Size())
}
replayer2 := &mockReplayer{}
result2, err := Recover(dir, replayer2)
if err != nil {
t.Fatalf("2nd Recover: %v", err)
}
if result2.Truncated {
t.Error("2nd Recover: Truncated = true, want false (truncation should be persisted)")
}
if result1.NextSequence != result2.NextSequence {
t.Errorf("NextSequence differs: %d vs %d", result1.NextSequence, result2.NextSequence)
}
}
// Regression guard for C5: any truncation persist step failure must
// fail Recover, causing DB.Open to fail. No swallowing allowed.
func TestRecoverTruncationFailureFailsRecovery(t *testing.T) {
dir := t.TempDir()
filePath := writeTestSegment(t, dir, 0, 0, [][]*WalEntry{
{makePutEntry("key-A", "val-A")},
})
// Append corruption to trigger tail corruption path.
appendFileBytes(t, filePath, []byte{0xDE, 0xAD, 0xBE, 0xEF})
// Inject dir fsync failure (Step 4 of truncateAndPersist).
orig := dirFsyncFn
dirFsyncFn = func(string) error { return errors.New("simulated dir fsync failure") }
t.Cleanup(func() { dirFsyncFn = orig })
_, err := Recover(dir, &mockReplayer{})
if err == nil {
t.Fatal("expected Recover to fail when truncation persist fails")
}
if !strings.Contains(err.Error(), "persist tail truncation") {
t.Errorf("error should mention 'persist tail truncation', got: %v", err)
}
}
// Regression guard for design line 778-781: CRC-valid but batch-content-
// invalid must hard-fail through DecodeWalBatch, NOT enter truncation path.
func TestRecoverInvalidBatchNotTruncatable(t *testing.T) {
dir := t.TempDir()
// Build segment with: physical records CRC-valid, but assembled batch
// has invalid header (entryCount=0).
filePath := filepath.Join(dir, "segment-0.wal")
writeRawSegmentHeader(t, filePath)
// Construct an "invalid batch": WalBatchHeaderSize=18 bytes, with
// entryCount=0 (invalid per ReplayBatch check at recovery.go).
invalidBatch := make([]byte, WalBatchHeaderSize)
// flags(2) + baseSequence(8) + entryCount(4)=0 + entriesSize(4)=0
// All zeros, except entryCount=0 is invalid by itself.
// Encode as Full physical record (CRC-valid).
rec := EncodePhysicalRecord(RecFull, invalidBatch)
appendFileBytes(t, filePath, rec)
_, err := Recover(dir, &mockReplayer{})
if err == nil {
t.Fatal("expected Recover to fail on invalid batch content")
}
// Should NOT mention truncation — must be a different error path.
if strings.Contains(err.Error(), "truncat") {
t.Errorf("error should not be about truncation; got: %v", err)
}
// File must NOT have been truncated (size unchanged).
fi, _ := os.Stat(filePath)
if fi.Size() != int64(WalFileHeaderSize)+int64(len(rec)) {
t.Errorf("file was truncated; size = %d, want %d",
fi.Size(), int64(WalFileHeaderSize)+int64(len(rec)))
}
}