|
| 1 | +package wal |
| 2 | + |
| 3 | +import ( |
| 4 | + "os" |
| 5 | + "path" |
| 6 | + "testing" |
| 7 | + |
| 8 | + "github.com/rs/zerolog" |
| 9 | + "github.com/stretchr/testify/require" |
| 10 | + |
| 11 | + "github.com/onflow/flow-go/ledger" |
| 12 | + "github.com/onflow/flow-go/ledger/complete/mtrie" |
| 13 | + "github.com/onflow/flow-go/ledger/complete/payloadless" |
| 14 | + "github.com/onflow/flow-go/model/bootstrap" |
| 15 | + "github.com/onflow/flow-go/module/metrics" |
| 16 | + "github.com/onflow/flow-go/utils/unittest" |
| 17 | +) |
| 18 | + |
| 19 | +// TestReplayOnPayloadlessForest_IgnoresV6RootCheckpoint is a regression test for |
| 20 | +// the case where a payloadless node boots with both a V7 root checkpoint (the |
| 21 | +// real seed) and a V6 root.checkpoint present in the trie dir. The forest must |
| 22 | +// be seeded from the V7 checkpoint, and the V6 root.checkpoint must NOT be read. |
| 23 | +// |
| 24 | +// To prove the V6 file is never touched, a corrupt root.checkpoint is placed |
| 25 | +// alongside the V7 checkpoint: the previous implementation routed payloadless |
| 26 | +// segment replay through [DiskWAL.replay], which falls back to loading the V6 |
| 27 | +// root checkpoint when replaying from segment 0 — that fallback would fail on |
| 28 | +// the corrupt file. With the fix, the V6 file is ignored and replay succeeds. |
| 29 | +func TestReplayOnPayloadlessForest_IgnoresV6RootCheckpoint(t *testing.T) { |
| 30 | + unittest.RunWithTempDir(t, func(dir string) { |
| 31 | + logger := zerolog.Nop() |
| 32 | + |
| 33 | + // Build a V7 root checkpoint from a simple trie and write it as the |
| 34 | + // payloadless root checkpoint (root.checkpoint.v7). |
| 35 | + v6Tries := createSimpleTrie(t) |
| 36 | + rootHash := v6Tries[0].RootHash() |
| 37 | + v7Tries, err := FromV6Tries(v6Tries) |
| 38 | + require.NoError(t, err) |
| 39 | + require.NoError(t, StoreCheckpointV7Concurrently(v7Tries, dir, RootCheckpointFilenameV7(), logger)) |
| 40 | + |
| 41 | + // Place a corrupt V6 root checkpoint next to the V7 one. If the |
| 42 | + // payloadless replay path attempts to load it, the load fails — which is |
| 43 | + // exactly the regression this test guards against. |
| 44 | + junkPath := path.Join(dir, bootstrap.FilenameWALRootCheckpoint) |
| 45 | + require.NoError(t, os.WriteFile(junkPath, []byte("not a valid v6 checkpoint"), 0644)) |
| 46 | + |
| 47 | + w, err := NewDiskWAL(logger, nil, metrics.NewNoopCollector(), dir, 10, pathByteSize, segmentSize) |
| 48 | + require.NoError(t, err) |
| 49 | + defer func() { <-w.Done() }() |
| 50 | + |
| 51 | + forest, err := payloadless.NewForest(100, &metrics.NoopCollector{}, nil) |
| 52 | + require.NoError(t, err) |
| 53 | + |
| 54 | + err = w.ReplayOnPayloadlessForest(forest) |
| 55 | + require.NoError(t, err, "replay must seed from V7 and must not load the V6 root checkpoint") |
| 56 | + |
| 57 | + require.True(t, forest.HasTrie(rootHash), "forest must be seeded from the V7 root checkpoint") |
| 58 | + }) |
| 59 | +} |
| 60 | + |
| 61 | +// TestReplayOnPayloadlessForest_ReplaysWALSegments verifies that after seeding |
| 62 | +// the forest from the V7 root checkpoint, WAL segment records that are newer |
| 63 | +// than the checkpoint are still replayed onto the payloadless forest. This |
| 64 | +// guards against the segment-replay refactor accidentally skipping segments. |
| 65 | +func TestReplayOnPayloadlessForest_ReplaysWALSegments(t *testing.T) { |
| 66 | + unittest.RunWithTempDir(t, func(dir string) { |
| 67 | + logger := zerolog.Nop() |
| 68 | + |
| 69 | + // Seed state: a full forest with an initial update, captured as the V7 |
| 70 | + // root checkpoint. |
| 71 | + fullForest, err := mtrie.NewForest(100, &metrics.NoopCollector{}, nil) |
| 72 | + require.NoError(t, err) |
| 73 | + |
| 74 | + paths0, payloads0 := randNPathPayloads(10) |
| 75 | + seed := &ledger.TrieUpdate{ |
| 76 | + RootHash: fullForest.GetEmptyRootHash(), |
| 77 | + Paths: paths0, |
| 78 | + Payloads: toPayloadPtrs(payloads0), |
| 79 | + } |
| 80 | + root0, err := fullForest.Update(seed) |
| 81 | + require.NoError(t, err) |
| 82 | + |
| 83 | + v6Tries, err := fullForest.GetTries() |
| 84 | + require.NoError(t, err) |
| 85 | + v7Tries, err := FromV6Tries(v6Tries) |
| 86 | + require.NoError(t, err) |
| 87 | + require.NoError(t, StoreCheckpointV7Concurrently(v7Tries, dir, RootCheckpointFilenameV7(), logger)) |
| 88 | + |
| 89 | + // A second update, built on root0, recorded into the WAL but NOT in the |
| 90 | + // checkpoint. Replay must apply it to reach root1. |
| 91 | + paths1, payloads1 := randNPathPayloads(10) |
| 92 | + update1 := &ledger.TrieUpdate{ |
| 93 | + RootHash: root0, |
| 94 | + Paths: paths1, |
| 95 | + Payloads: toPayloadPtrs(payloads1), |
| 96 | + } |
| 97 | + root1, err := fullForest.Update(update1) |
| 98 | + require.NoError(t, err) |
| 99 | + |
| 100 | + // Record update1 into the WAL, then close to flush the segment to disk. |
| 101 | + recordWAL, err := NewDiskWAL(logger, nil, metrics.NewNoopCollector(), dir, 10, pathByteSize, segmentSize) |
| 102 | + require.NoError(t, err) |
| 103 | + _, _, err = recordWAL.RecordUpdate(update1) |
| 104 | + require.NoError(t, err) |
| 105 | + <-recordWAL.Done() |
| 106 | + |
| 107 | + // Replay on a fresh WAL: seed from V7 (root0), then replay the WAL |
| 108 | + // segment carrying update1 to reach root1. |
| 109 | + w, err := NewDiskWAL(logger, nil, metrics.NewNoopCollector(), dir, 10, pathByteSize, segmentSize) |
| 110 | + require.NoError(t, err) |
| 111 | + defer func() { <-w.Done() }() |
| 112 | + |
| 113 | + forest, err := payloadless.NewForest(100, &metrics.NoopCollector{}, nil) |
| 114 | + require.NoError(t, err) |
| 115 | + |
| 116 | + require.NoError(t, w.ReplayOnPayloadlessForest(forest)) |
| 117 | + |
| 118 | + require.True(t, forest.HasTrie(root0), "forest must contain the V7 checkpoint root") |
| 119 | + require.True(t, forest.HasTrie(root1), "forest must contain the root produced by replaying the WAL segment") |
| 120 | + }) |
| 121 | +} |
0 commit comments