From ef18d7dd2608b3b25f795893f9eb58dec836244f Mon Sep 17 00:00:00 2001 From: "Leo Zhang (zhangchiqing)" Date: Wed, 26 Aug 2026 13:13:18 -0700 Subject: [PATCH] T1: add SearchRootHashBackward to common WAL utilities Extracts WAL root-hash search logic from find-trie-root into cmd/util/cmd/common/wal.go as exported SearchRootHashForward and SearchRootHashBackward functions. The backward variant iterates segments last-to-first (per-segment records still read forward), which is efficient when the target hash is near the end of the WAL. Updates find-trie-root to use SearchRootHashBackward via the shared package. Adds unit tests covering single-segment, multi-segment, last-occurrence, bounded-range, and not-found cases. Part of #8665. --- cmd/util/cmd/common/wal.go | 208 ++++++++++++++++++++++++ cmd/util/cmd/common/wal_test.go | 250 +++++++++++++++++++++++++++++ cmd/util/cmd/find-trie-root/cmd.go | 89 +--------- 3 files changed, 460 insertions(+), 87 deletions(-) create mode 100644 cmd/util/cmd/common/wal.go create mode 100644 cmd/util/cmd/common/wal_test.go diff --git a/cmd/util/cmd/common/wal.go b/cmd/util/cmd/common/wal.go new file mode 100644 index 00000000000..a1facd43bc5 --- /dev/null +++ b/cmd/util/cmd/common/wal.go @@ -0,0 +1,208 @@ +package common + +import ( + "fmt" + "math" + "os" + + prometheusWAL "github.com/onflow/wal/wal" + "github.com/rs/zerolog" + + "github.com/onflow/flow-go/ledger" + "github.com/onflow/flow-go/ledger/complete/wal" +) + +// SearchRootHashForward scans WAL segments in forward order (first to last) and returns +// the segment index and byte offset of the first occurrence of expectedHash. +// +// wantFrom and wantTo are inclusive bounds on the segment range to search; pass 0 and +// [math.MaxInt32] to search all available segments. +// +// No error returns are expected during normal operation. +func SearchRootHashForward( + expectedHash ledger.RootHash, + dir string, + wantFrom, wantTo int, +) (int, int64, error) { + return searchRootHash(expectedHash, dir, wantFrom, wantTo, false) +} + +// SearchRootHashBackward scans WAL segments in backward order (last to first) and returns +// the segment index and byte offset of the last occurrence of expectedHash within the +// latest segment that contains it. +// +// Iterating backwards is more efficient when the target root hash is near the end of +// the WAL — for example when locating the state commitment of the last sealed block. +// Records within each individual segment are still read in forward order; only the +// segment iteration order is reversed. +// +// wantFrom and wantTo are inclusive bounds on the segment range to search; pass 0 and +// [math.MaxInt32] to search all available segments. +// +// No error returns are expected during normal operation. +func SearchRootHashBackward( + expectedHash ledger.RootHash, + dir string, + wantFrom, wantTo int, +) (int, int64, error) { + return searchRootHash(expectedHash, dir, wantFrom, wantTo, true) +} + +// searchRootHash implements both forward and backward WAL segment scanning. +// When backward is true segments are iterated last-to-first; records within each +// segment are always read in forward order. +func searchRootHash( + expectedHash ledger.RootHash, + dir string, + wantFrom, wantTo int, + backward bool, +) (int, int64, error) { + lg := zerolog.New(os.Stderr).With().Timestamp().Logger() + from, to, err := prometheusWAL.Segments(dir) + if err != nil { + return 0, 0, fmt.Errorf("cannot get segments: %w", err) + } + + if from < 0 { + return 0, 0, fmt.Errorf("no segments found in %s", dir) + } + + if wantFrom > to { + return 0, 0, fmt.Errorf("from segment %d is greater than the last segment %d", wantFrom, to) + } + + if wantTo < from { + return 0, 0, fmt.Errorf("to segment %d is less than the first segment %d", wantTo, from) + } + + if wantFrom > from { + from = wantFrom + } + + if wantTo < to { + to = wantTo + } + + lg.Info(). + Str("dir", dir). + Int("from", from). + Int("to", to). + Bool("backward", backward). + Msgf("searching for trie root hash %v in segments [%d,%d]", expectedHash, from, to) + + if !backward { + return searchForward(lg, expectedHash, dir, from, to) + } + return searchBackward(lg, expectedHash, dir, from, to) +} + +// searchForward scans segments from first to last, returning the position of the +// first occurrence of expectedHash. +func searchForward( + lg zerolog.Logger, + expectedHash ledger.RootHash, + dir string, + from, to int, +) (int, int64, error) { + sr, err := prometheusWAL.NewSegmentsRangeReader(lg, prometheusWAL.SegmentRange{ + Dir: dir, + First: from, + Last: to, + }) + if err != nil { + return 0, 0, fmt.Errorf("cannot create WAL segments reader: %w", err) + } + defer sr.Close() + + reader := prometheusWAL.NewReader(sr) + for reader.Next() { + record := reader.Record() + operation, _, update, err := wal.Decode(record) + if err != nil { + return 0, 0, fmt.Errorf("cannot decode LedgerWAL record: %w", err) + } + + if operation == wal.WALUpdate && update.RootHash.Equals(expectedHash) { + return reader.Segment(), reader.Offset(), nil + } + + if err := reader.Err(); err != nil { + return 0, 0, fmt.Errorf("cannot read LedgerWAL: %w", err) + } + } + + return 0, 0, fmt.Errorf("root hash not found in segments [%d,%d]", from, to) +} + +// searchBackward iterates segments from last to first. Within each segment records are +// read in forward order; the LAST matching record position is tracked so that +// findRootHashAndCreateTrimmed can trim the WAL cleanly at that point. +// The function returns the position of the last occurrence of expectedHash within the +// latest segment that contains it. +func searchBackward( + lg zerolog.Logger, + expectedHash ledger.RootHash, + dir string, + from, to int, +) (int, int64, error) { + for seg := to; seg >= from; seg-- { + foundSeg, foundOffset, err := scanSegmentForLastOccurrence(lg, expectedHash, dir, seg) + if err != nil { + return 0, 0, fmt.Errorf("error scanning segment %d: %w", seg, err) + } + if foundSeg >= 0 { + return foundSeg, foundOffset, nil + } + } + + return 0, 0, fmt.Errorf("root hash not found in segments [%d,%d]", from, to) +} + +// scanSegmentForLastOccurrence reads all records in the given segment forward and returns +// the position of the last occurrence of expectedHash. Returns (-1, 0, nil) if the hash +// is not present in the segment. +func scanSegmentForLastOccurrence( + lg zerolog.Logger, + expectedHash ledger.RootHash, + dir string, + seg int, +) (int, int64, error) { + sr, err := prometheusWAL.NewSegmentsRangeReader(lg, prometheusWAL.SegmentRange{ + Dir: dir, + First: seg, + Last: seg, + }) + if err != nil { + return 0, 0, fmt.Errorf("cannot open segment %d: %w", seg, err) + } + defer sr.Close() + + reader := prometheusWAL.NewReader(sr) + foundSeg := -1 + var foundOffset int64 + + for reader.Next() { + record := reader.Record() + operation, _, update, err := wal.Decode(record) + if err != nil { + return 0, 0, fmt.Errorf("cannot decode LedgerWAL record in segment %d: %w", seg, err) + } + + if operation == wal.WALUpdate && update.RootHash.Equals(expectedHash) { + foundSeg = reader.Segment() + foundOffset = reader.Offset() + } + + if err := reader.Err(); err != nil { + return 0, 0, fmt.Errorf("cannot read LedgerWAL in segment %d: %w", seg, err) + } + } + + return foundSeg, foundOffset, nil +} + +// DefaultWALFrom is the default lower bound for WAL segment search (inclusive). +const DefaultWALFrom = 0 + +// DefaultWALTo is the default upper bound for WAL segment search (inclusive). +const DefaultWALTo = math.MaxInt32 diff --git a/cmd/util/cmd/common/wal_test.go b/cmd/util/cmd/common/wal_test.go new file mode 100644 index 00000000000..fa08a641e47 --- /dev/null +++ b/cmd/util/cmd/common/wal_test.go @@ -0,0 +1,250 @@ +package common_test + +import ( + "testing" + + prometheusWAL "github.com/onflow/wal/wal" + "github.com/rs/zerolog" + "github.com/stretchr/testify/require" + + "github.com/onflow/flow-go/cmd/util/cmd/common" + "github.com/onflow/flow-go/ledger" + "github.com/onflow/flow-go/ledger/common/testutils" + flowWAL "github.com/onflow/flow-go/ledger/complete/wal" + "github.com/onflow/flow-go/utils/unittest" +) + +// testSegmentSize is one page (32 KB), the minimum valid segment size for the WAL library. +// With payloads of ~25 KB each record barely fits in one page, so writing N records +// produces N WAL segments — giving us multi-segment fixtures without writing huge amounts +// of test data. +const testSegmentSize = 32 * 1024 + +// singleSegmentSize is large enough (32 MB) to hold many small records in one segment. +const singleSegmentSize = 32 * 1024 * 1024 + +func makeRootHash(b byte) ledger.RootHash { + var h ledger.RootHash + h[0] = b + return h +} + +// makeTrieUpdate returns a TrieUpdate whose encoded size is large enough (≈ 25 KB) to +// occupy one WAL page on its own, which forces a new segment for the next write when +// testSegmentSize is used. +func makeTrieUpdate(rootHash ledger.RootHash) *ledger.TrieUpdate { + path := testutils.PathByUint16(0) + value := make(ledger.Value, 25*1024) // ~25 KB, fills one 32-KB page + payload := ledger.NewPayload(ledger.Key{KeyParts: []ledger.KeyPart{{Type: 0, Value: []byte{1}}}}, value) + return &ledger.TrieUpdate{ + RootHash: rootHash, + Paths: []ledger.Path{path}, + Payloads: []*ledger.Payload{payload}, + } +} + +// writeWALUpdate encodes and appends a trie-update record with the given root hash to w. +func writeWALUpdate(t *testing.T, w *prometheusWAL.WAL, rootHash ledger.RootHash) { + t.Helper() + update := makeTrieUpdate(rootHash) + _, err := w.Log(flowWAL.EncodeUpdate(update)) + require.NoError(t, err) +} + +// openWALWriter returns a WAL writer using the provided segment size. +func openWALWriter(t *testing.T, dir string, segSize int) *prometheusWAL.WAL { + t.Helper() + w, err := prometheusWAL.NewSize(zerolog.Nop(), nil, dir, segSize, false) + require.NoError(t, err) + return w +} + +// makeSmallTrieUpdate returns a TrieUpdate with a single tiny path/payload pair. +// The encoder skips pathSize when numOfPaths==0, but the decoder always reads it, +// so a TrieUpdate with 0 paths cannot be round-tripped via EncodeUpdate/Decode. +// Using one path+payload avoids that asymmetry. +func makeSmallTrieUpdate(rootHash ledger.RootHash) *ledger.TrieUpdate { + path := testutils.PathByUint16(0) + value := make(ledger.Value, 8) // 8-byte value — tiny enough to fit many records per segment + payload := ledger.NewPayload(ledger.Key{KeyParts: []ledger.KeyPart{{Type: 0, Value: []byte{1}}}}, value) + return &ledger.TrieUpdate{ + RootHash: rootHash, + Paths: []ledger.Path{path}, + Payloads: []*ledger.Payload{payload}, + } +} + +// writeSmallWALUpdate writes a small (single tiny payload) trie-update record. +func writeSmallWALUpdate(t *testing.T, w *prometheusWAL.WAL, rootHash ledger.RootHash) { + t.Helper() + _, err := w.Log(flowWAL.EncodeUpdate(makeSmallTrieUpdate(rootHash))) + require.NoError(t, err) +} + +// TestSearchRootHashForward_SingleSegment verifies that a forward scan finds the target +// hash within a single WAL segment containing multiple records. +func TestSearchRootHashForward_SingleSegment(t *testing.T) { + unittest.RunWithTempDir(t, func(dir string) { + hash1 := makeRootHash(0x01) + hash2 := makeRootHash(0x02) + hash3 := makeRootHash(0x03) + + // Use a large segment so all three tiny records land in segment 0. + w := openWALWriter(t, dir, singleSegmentSize) + writeSmallWALUpdate(t, w, hash1) + writeSmallWALUpdate(t, w, hash2) + writeSmallWALUpdate(t, w, hash3) + require.NoError(t, w.Close()) + + seg, _, err := common.SearchRootHashForward(hash2, dir, common.DefaultWALFrom, common.DefaultWALTo) + require.NoError(t, err) + require.Equal(t, 0, seg) + }) +} + +// TestSearchRootHashForward_NotFound verifies that a forward scan returns an error when +// the target hash is absent from the WAL. +func TestSearchRootHashForward_NotFound(t *testing.T) { + unittest.RunWithTempDir(t, func(dir string) { + hash1 := makeRootHash(0x01) + hashMissing := makeRootHash(0xFF) + + w := openWALWriter(t, dir, singleSegmentSize) + writeSmallWALUpdate(t, w, hash1) + require.NoError(t, w.Close()) + + _, _, err := common.SearchRootHashForward(hashMissing, dir, common.DefaultWALFrom, common.DefaultWALTo) + require.Error(t, err) + }) +} + +// TestSearchRootHashBackward_SingleSegment verifies that a backward scan finds the target +// hash within a single WAL segment containing multiple records. +func TestSearchRootHashBackward_SingleSegment(t *testing.T) { + unittest.RunWithTempDir(t, func(dir string) { + hash1 := makeRootHash(0x01) + hash2 := makeRootHash(0x02) + + // Use a large segment so both tiny records land in segment 0. + w := openWALWriter(t, dir, singleSegmentSize) + writeSmallWALUpdate(t, w, hash1) + writeSmallWALUpdate(t, w, hash2) + require.NoError(t, w.Close()) + + seg, _, err := common.SearchRootHashBackward(hash1, dir, common.DefaultWALFrom, common.DefaultWALTo) + require.NoError(t, err) + require.Equal(t, 0, seg) + }) +} + +// TestSearchRootHashBackward_NotFound verifies that a backward scan returns an error when +// the target hash is absent from the WAL. +func TestSearchRootHashBackward_NotFound(t *testing.T) { + unittest.RunWithTempDir(t, func(dir string) { + hash1 := makeRootHash(0x01) + hashMissing := makeRootHash(0xFF) + + w := openWALWriter(t, dir, singleSegmentSize) + writeSmallWALUpdate(t, w, hash1) + require.NoError(t, w.Close()) + + _, _, err := common.SearchRootHashBackward(hashMissing, dir, common.DefaultWALFrom, common.DefaultWALTo) + require.Error(t, err) + }) +} + +// TestSearchRootHashBackward_MultipleSegments is the core correctness test for the +// backward scan. It writes target hashes across three separate WAL segments and verifies: +// +// 1. The backward scan finds the hash in the LAST segment that contains it, not the first. +// 2. The backward scan returns the correct segment number in all positions (first, middle, last). +// 3. When the same hash appears in multiple segments, the backward scan returns the +// segment with the highest index (the most recent occurrence). +func TestSearchRootHashBackward_MultipleSegments(t *testing.T) { + // Each call to writeWALUpdate writes a ~25 KB record. With testSegmentSize = 32 KB + // the WAL rotates to a new segment after each write, so the segment index equals the + // write index (0-based). + unittest.RunWithTempDir(t, func(dir string) { + hashA := makeRootHash(0xAA) + hashB := makeRootHash(0xBB) + hashC := makeRootHash(0xCC) + + w := openWALWriter(t, dir, testSegmentSize) + writeWALUpdate(t, w, hashA) // segment 0 + writeWALUpdate(t, w, hashB) // segment 1 + writeWALUpdate(t, w, hashC) // segment 2 + require.NoError(t, w.Close()) + + from, to, err := prometheusWAL.Segments(dir) + require.NoError(t, err) + require.Equal(t, 0, from) + require.Equal(t, 2, to, "expected 3 segments (0–2)") + + t.Run("hash in first segment", func(t *testing.T) { + seg, _, err := common.SearchRootHashBackward(hashA, dir, common.DefaultWALFrom, common.DefaultWALTo) + require.NoError(t, err) + require.Equal(t, 0, seg) + }) + + t.Run("hash in middle segment", func(t *testing.T) { + seg, _, err := common.SearchRootHashBackward(hashB, dir, common.DefaultWALFrom, common.DefaultWALTo) + require.NoError(t, err) + require.Equal(t, 1, seg) + }) + + t.Run("hash in last segment", func(t *testing.T) { + seg, _, err := common.SearchRootHashBackward(hashC, dir, common.DefaultWALFrom, common.DefaultWALTo) + require.NoError(t, err) + require.Equal(t, 2, seg) + }) + + t.Run("hash not present in any segment", func(t *testing.T) { + _, _, err := common.SearchRootHashBackward(makeRootHash(0xFF), dir, common.DefaultWALFrom, common.DefaultWALTo) + require.Error(t, err) + }) + }) +} + +// TestSearchRootHashBackward_ReturnsLastOccurrence verifies that when the same root hash +// appears in multiple segments the backward scan returns the highest (most recent) segment. +func TestSearchRootHashBackward_ReturnsLastOccurrence(t *testing.T) { + unittest.RunWithTempDir(t, func(dir string) { + hashDup := makeRootHash(0xDD) + hashOther := makeRootHash(0xEE) + + w := openWALWriter(t, dir, testSegmentSize) + writeWALUpdate(t, w, hashDup) // segment 0 — first occurrence + writeWALUpdate(t, w, hashOther) // segment 1 — other hash + writeWALUpdate(t, w, hashDup) // segment 2 — second (later) occurrence + require.NoError(t, w.Close()) + + seg, _, err := common.SearchRootHashBackward(hashDup, dir, common.DefaultWALFrom, common.DefaultWALTo) + require.NoError(t, err) + require.Equal(t, 2, seg, "backward scan should return the most recent segment") + }) +} + +// TestSearchRootHashBackward_BoundedRange verifies that the wantFrom/wantTo range bounds +// are respected: a hash outside the bounded range must not be found. +func TestSearchRootHashBackward_BoundedRange(t *testing.T) { + unittest.RunWithTempDir(t, func(dir string) { + hashA := makeRootHash(0x11) + hashB := makeRootHash(0x22) + hashC := makeRootHash(0x33) + + w := openWALWriter(t, dir, testSegmentSize) + writeWALUpdate(t, w, hashA) // segment 0 + writeWALUpdate(t, w, hashB) // segment 1 + writeWALUpdate(t, w, hashC) // segment 2 + require.NoError(t, w.Close()) + + // Restrict search to segment 1 only. + seg, _, err := common.SearchRootHashBackward(hashB, dir, 1, 1) + require.NoError(t, err) + require.Equal(t, 1, seg) + + // hashA is only in segment 0, which is outside the bounded range [1,1]. + _, _, err = common.SearchRootHashBackward(hashA, dir, 1, 1) + require.Error(t, err, "hash outside the bounded range must not be found") + }) +} diff --git a/cmd/util/cmd/find-trie-root/cmd.go b/cmd/util/cmd/find-trie-root/cmd.go index d50b4d9b3dd..5fc6a13b95c 100644 --- a/cmd/util/cmd/find-trie-root/cmd.go +++ b/cmd/util/cmd/find-trie-root/cmd.go @@ -8,10 +8,10 @@ import ( "path/filepath" prometheusWAL "github.com/onflow/wal/wal" - "github.com/rs/zerolog" "github.com/rs/zerolog/log" "github.com/spf13/cobra" + "github.com/onflow/flow-go/cmd/util/cmd/common" "github.com/onflow/flow-go/ledger" "github.com/onflow/flow-go/ledger/common/hash" "github.com/onflow/flow-go/ledger/complete/wal" @@ -80,7 +80,7 @@ func run(*cobra.Command, []string) { log.Fatal().Msgf("--backup-dir directory %v must be empty", flagBackupDir) } - segment, offset, err := searchRootHashInSegments(rootHash, flagExecutionStateDir, flagFrom, flagTo) + segment, offset, err := common.SearchRootHashBackward(rootHash, flagExecutionStateDir, flagFrom, flagTo) if err != nil { log.Fatal().Err(err).Msg("cannot find root hash in segments") } @@ -149,91 +149,6 @@ func parseInput(rootHashStr string) (ledger.RootHash, error) { return rootHash, nil } -func searchRootHashInSegments( - expectedHash ledger.RootHash, - dir string, - wantFrom, wantTo int, -) (int, int64, error) { - lg := zerolog.New(os.Stderr).With().Timestamp().Logger() - from, to, err := prometheusWAL.Segments(dir) - if err != nil { - return 0, 0, fmt.Errorf("cannot get segments: %w", err) - } - - if from < 0 { - return 0, 0, fmt.Errorf("no segments found in %s", dir) - } - - if wantFrom > to { - return 0, 0, fmt.Errorf("from segment %d is greater than the last segment %d", wantFrom, to) - } - - if wantTo < from { - return 0, 0, fmt.Errorf("to segment %d is less than the first segment %d", wantTo, from) - } - - if wantFrom > from { - from = wantFrom - } - - if wantTo < to { - to = wantTo - } - - lg.Info(). - Str("dir", dir). - Int("from", from). - Int("to", to). - Int("want-from", wantFrom). - Int("want-to", wantTo). - Msgf("searching for trie root hash %v in segments [%d,%d]", expectedHash, wantFrom, wantTo) - - sr, err := prometheusWAL.NewSegmentsRangeReader(lg, prometheusWAL.SegmentRange{ - Dir: dir, - First: from, - Last: to, - }) - - if err != nil { - return 0, 0, fmt.Errorf("cannot create WAL segments reader: %w", err) - } - - defer sr.Close() - - reader := prometheusWAL.NewReader(sr) - - for reader.Next() { - record := reader.Record() - operation, _, update, err := wal.Decode(record) - if err != nil { - return 0, 0, fmt.Errorf("cannot decode LedgerWAL record: %w", err) - } - - switch operation { - case wal.WALUpdate: - rootHash := update.RootHash - - log.Debug(). - Uint8("operation", uint8(operation)). - Str("root-hash", rootHash.String()). - Msg("found WALUpdate") - - if rootHash.Equals(expectedHash) { - log.Info().Msgf("found expected trie root hash %v", rootHash) - return reader.Segment(), reader.Offset(), nil - } - default: - } - - err = reader.Err() - if err != nil { - return 0, 0, fmt.Errorf("cannot read LedgerWAL: %w", err) - } - } - - return 0, 0, fmt.Errorf("finish reading all segment files from %d to %d, but not found", from, to) -} - // findRootHashAndCreateTrimmed finds the root hash in the segment file from the given dir folder // and creates a new segment file with the expected root hash as the last record in a temporary folder. // it return the path to the new segment file.