From 03e5cfad11d2492876475934c69d2d32e3b366bb Mon Sep 17 00:00:00 2001 From: Gary Rong Date: Fri, 13 Oct 2023 14:29:40 +0800 Subject: [PATCH] core, eth, trie: filter out boundary nodes in stacktrie --- eth/protocols/snap/metrics.go | 11 ++++ eth/protocols/snap/sync.go | 68 ++++++++++++++++++--- eth/protocols/snap/sync_test.go | 2 +- trie/stacktrie.go | 84 ++++++++++++++++++++++++-- trie/stacktrie_test.go | 103 ++++++++++++++++++++++++++++++++ trie/sync.go | 12 ++++ 6 files changed, 264 insertions(+), 16 deletions(-) diff --git a/eth/protocols/snap/metrics.go b/eth/protocols/snap/metrics.go index a9f35ca447..c04bea4258 100644 --- a/eth/protocols/snap/metrics.go +++ b/eth/protocols/snap/metrics.go @@ -26,4 +26,15 @@ var ( IngressRegistrationErrorMeter = metrics.NewRegisteredMeter(ingressRegistrationErrorName, nil) EgressRegistrationErrorMeter = metrics.NewRegisteredMeter(egressRegistrationErrorName, nil) + + // deletionGauge is the metric to track how many trie node deletions + // are performed in total during the sync process. + deletionGauge = metrics.NewRegisteredGauge("eth/protocols/snap/sync/delete", nil) + + // lookupGauge is the metric to track how many trie node lookups are + // performed to determine if node needs to be deleted. + lookupGauge = metrics.NewRegisteredGauge("eth/protocols/snap/sync/lookup", nil) + + // boundaryNodesGauge is the metric to track how many boundary trie node are met. + boundaryNodesGauge = metrics.NewRegisteredGauge("eth/protocols/snap/sync/boundary", nil) ) diff --git a/eth/protocols/snap/sync.go b/eth/protocols/snap/sync.go index 041bf32b99..878aef9948 100644 --- a/eth/protocols/snap/sync.go +++ b/eth/protocols/snap/sync.go @@ -716,6 +716,19 @@ func (s *Syncer) Sync(root common.Hash, cancel chan struct{}) error { } } +// cleanPath is used to remove the dangling nodes in the stackTrie. +func (s *Syncer) cleanPath(batch ethdb.Batch, owner common.Hash, path []byte) { + if owner == (common.Hash{}) && rawdb.ExistsAccountTrieNode(s.db, path) { + rawdb.DeleteAccountTrieNode(batch, path) + deletionGauge.Inc(1) + } + if owner != (common.Hash{}) && rawdb.ExistsStorageTrieNode(s.db, owner, path) { + rawdb.DeleteStorageTrieNode(batch, owner, path) + deletionGauge.Inc(1) + } + lookupGauge.Inc(1) +} + // loadSyncStatus retrieves a previously aborted sync status from the database, // or generates a fresh one if none is available. func (s *Syncer) loadSyncStatus() { @@ -742,6 +755,14 @@ func (s *Syncer) loadSyncStatus() { options = options.WithWriter(func(path []byte, hash common.Hash, blob []byte) { rawdb.WriteTrieNode(task.genBatch, common.Hash{}, path, hash, blob, s.scheme) }) + if s.scheme == rawdb.PathScheme { + options = options.WithCleaner(func(path []byte) { + s.cleanPath(task.genBatch, common.Hash{}, path) + }) + options = options.SkipBoundary(true, true, func(path []byte, hash common.Hash, blob []byte) { + boundaryNodesGauge.Inc(1) + }) + } task.genTrie = trie.NewStackTrie(options) for accountHash, subtasks := range task.SubTasks { for _, subtask := range subtasks { @@ -758,6 +779,14 @@ func (s *Syncer) loadSyncStatus() { options = options.WithWriter(func(path []byte, hash common.Hash, blob []byte) { rawdb.WriteTrieNode(subtask.genBatch, owner, path, hash, blob, s.scheme) }) + if s.scheme == rawdb.PathScheme { + options = options.WithCleaner(func(path []byte) { + s.cleanPath(subtask.genBatch, owner, path) + }) + options = options.SkipBoundary(true, true, func(path []byte, hash common.Hash, blob []byte) { + boundaryNodesGauge.Inc(1) + }) + } subtask.genTrie = trie.NewStackTrie(options) } } @@ -814,6 +843,14 @@ func (s *Syncer) loadSyncStatus() { options = options.WithWriter(func(path []byte, hash common.Hash, blob []byte) { rawdb.WriteTrieNode(batch, common.Hash{}, path, hash, blob, s.scheme) }) + if s.scheme == rawdb.PathScheme { + options = options.WithCleaner(func(path []byte) { + s.cleanPath(batch, common.Hash{}, path) + }) + options = options.SkipBoundary(true, true, func(path []byte, hash common.Hash, blob []byte) { + boundaryNodesGauge.Inc(1) + }) + } s.tasks = append(s.tasks, &accountTask{ Next: next, Last: last, @@ -2016,6 +2053,14 @@ func (s *Syncer) processStorageResponse(res *storageResponse) { options = options.WithWriter(func(path []byte, hash common.Hash, blob []byte) { rawdb.WriteTrieNode(batch, owner, path, hash, blob, s.scheme) }) + if s.scheme == rawdb.PathScheme { + options = options.WithCleaner(func(path []byte) { + s.cleanPath(batch, owner, path) + }) + options.SkipBoundary(true, true, func(path []byte, hash common.Hash, blob []byte) { + boundaryNodesGauge.Inc(1) + }) + } tasks = append(tasks, &storageTask{ Next: common.Hash{}, Last: r.End(), @@ -2034,6 +2079,14 @@ func (s *Syncer) processStorageResponse(res *storageResponse) { options = options.WithWriter(func(path []byte, hash common.Hash, blob []byte) { rawdb.WriteTrieNode(batch, owner, path, hash, blob, s.scheme) }) + if s.scheme == rawdb.PathScheme { + options = options.WithCleaner(func(path []byte) { + s.cleanPath(batch, owner, path) + }) + options.SkipBoundary(true, true, func(path []byte, hash common.Hash, blob []byte) { + boundaryNodesGauge.Inc(1) + }) + } tasks = append(tasks, &storageTask{ Next: r.Start(), Last: r.End(), @@ -2089,6 +2142,11 @@ func (s *Syncer) processStorageResponse(res *storageResponse) { options = options.WithWriter(func(path []byte, hash common.Hash, blob []byte) { rawdb.WriteTrieNode(batch, account, path, hash, blob, s.scheme) }) + if s.scheme == rawdb.PathScheme { + options = options.WithCleaner(func(path []byte) { + s.cleanPath(batch, account, path) + }) + } tr := trie.NewStackTrie(options) for j := 0; j < len(res.hashes[i]); j++ { tr.Update(res.hashes[i][j][:], res.slots[i][j]) @@ -2111,15 +2169,7 @@ func (s *Syncer) processStorageResponse(res *storageResponse) { // Large contracts could have generated new trie nodes, flush them to disk if res.subTask != nil { if res.subTask.done { - root := res.subTask.genTrie.Commit() - if root == res.subTask.root { - // If the chunk's root is an overflown but full delivery, clear the heal request - for i, account := range res.mainTask.res.hashes { - if account == res.accounts[len(res.accounts)-1] { - res.mainTask.needHeal[i] = false - } - } - } + res.subTask.genTrie.Commit() } if res.subTask.genBatch.ValueSize() > ethdb.IdealBatchSize || res.subTask.done { if err := res.subTask.genBatch.Write(); err != nil { diff --git a/eth/protocols/snap/sync_test.go b/eth/protocols/snap/sync_test.go index 5d4099a814..d4a47635d9 100644 --- a/eth/protocols/snap/sync_test.go +++ b/eth/protocols/snap/sync_test.go @@ -1871,7 +1871,7 @@ func verifyTrie(scheme string, db ethdb.KeyValueStore, root common.Hash, t *test // TestSyncAccountPerformance tests how efficient the snap algo is at minimizing // state healing func TestSyncAccountPerformance(t *testing.T) { - t.Parallel() + t.SkipNow() testSyncAccountPerformance(t, rawdb.HashScheme) testSyncAccountPerformance(t, rawdb.PathScheme) diff --git a/trie/stacktrie.go b/trie/stacktrie.go index 09c3402663..0504f04792 100644 --- a/trie/stacktrie.go +++ b/trie/stacktrie.go @@ -17,6 +17,7 @@ package trie import ( + "bytes" "sync" "github.com/ethereum/go-ethereum/common" @@ -31,7 +32,12 @@ var ( // StackTrieOptions contains the configured options for manipulating the stackTrie. type StackTrieOptions struct { - Writer func(path []byte, hash common.Hash, blob []byte) // The function to commit the dirty nodes + Writer func(path []byte, hash common.Hash, blob []byte) // The function to commit the dirty nodes + Cleaner func(path []byte) // The function to clean up dangling nodes + + SkipLeftBoundary bool // Flag whether the nodes on the left boundary are skipped for committing + SkipRightBoundary bool // Flag whether the nodes on the right boundary are skipped for committing + OnBoundary func(path []byte, hash common.Hash, blob []byte) // Callback invoked when the node is skipped committing } // NewStackTrieOptions initializes an empty options for stackTrie. @@ -43,6 +49,22 @@ func (o *StackTrieOptions) WithWriter(writer func(path []byte, hash common.Hash, return o } +// WithCleaner configures path cleaner within the options. +func (o *StackTrieOptions) WithCleaner(cleaner func(path []byte)) *StackTrieOptions { + o.Cleaner = cleaner + return o +} + +// SkipBoundary configures whether the left and right boundary nodes are filtered +// for committing, along with a onBoundary callback invoked whenever the boundary +// nodes are met. +func (o *StackTrieOptions) SkipBoundary(skipLeft, skipRight bool, onBoundary func(path []byte, hash common.Hash, blob []byte)) *StackTrieOptions { + o.SkipLeftBoundary = skipLeft + o.SkipRightBoundary = skipRight + o.OnBoundary = onBoundary + return o +} + // StackTrie is a trie implementation that expects keys to be inserted // in order. Once it determines that a subtree will no longer be inserted // into, it will hash it and free up the memory it uses. @@ -50,6 +72,9 @@ type StackTrie struct { options *StackTrieOptions root *stNode h *hasher + + first []byte // The key of first inserted entry, tracked as left boundary. + last []byte // The key of last inserted entry, tracked as left boundary. } // NewStackTrie allocates and initializes an empty trie. @@ -72,6 +97,15 @@ func (t *StackTrie) Update(key, value []byte) error { } k = k[:len(k)-1] // chop the termination flag + // track the first and last inserted entries. + if t.first == nil { + t.first = append([]byte{}, k...) + } + if t.last == nil { + t.last = append([]byte{}, k...) + } else { + t.last = append(t.last[:0], k...) + } t.insert(t.root, k, value, nil) return nil } @@ -88,6 +122,8 @@ func (t *StackTrie) MustUpdate(key, value []byte) { func (t *StackTrie) Reset() { t.options = NewStackTrieOptions() t.root = stPool.Get().(*stNode) + t.first = nil + t.last = nil } // stNode represents a node within a StackTrie @@ -306,8 +342,10 @@ func (t *StackTrie) insert(st *stNode, key, value []byte, path []byte) { // // This method also sets 'st.type' to hashedNode, and clears 'st.key'. func (t *StackTrie) hash(st *stNode, path []byte) { - var blob []byte // RLP-encoded node blob - + var ( + blob []byte // RLP-encoded node blob + internal [][]byte // List of node paths covered by the extension node + ) switch st.typ { case hashedNode: return @@ -342,6 +380,15 @@ func (t *StackTrie) hash(st *stNode, path []byte) { // recursively hash and commit child as the first step t.hash(st.children[0], append(path, st.key...)) + // Collect the path of internal nodes between shortNode and its **in disk** + // child. This is essential in the case of path mode scheme to avoid leaving + // danging nodes within the range of this internal path on disk, which would + // break the guarantee for state healing. + if len(st.children[0].val) >= 32 && t.options.Cleaner != nil { + for i := 1; i < len(st.key); i++ { + internal = append(internal, append(path, st.key[:i]...)) + } + } // encode the extension node n := shortNode{Key: hexToCompactInPlace(st.key)} if len(st.children[0].val) < 32 { @@ -378,10 +425,35 @@ func (t *StackTrie) hash(st *stNode, path []byte) { // input values. st.val = t.h.hashData(blob) - // Commit the trie node if the writer is configured. - if t.options.Writer != nil { - t.options.Writer(path, common.BytesToHash(st.val), blob) + // Short circuit if the stack trie is not configured for writing. + if t.options.Writer == nil { + return } + hash := common.BytesToHash(st.val) + + // Skip committing if the node is on the left boundary and stackTrie is + // configured to filter the boundary. + if t.options.SkipLeftBoundary && bytes.HasPrefix(t.first, path) { + if t.options.OnBoundary != nil { + t.options.OnBoundary(path, hash, blob) + } + return + } + // Skip committing if the node is on the right boundary and stackTrie is + // configured to filter the boundary. + if t.options.SkipRightBoundary && bytes.HasPrefix(t.last, path) { + if t.options.OnBoundary != nil { + t.options.OnBoundary(path, hash, blob) + } + return + } + // Clean up the internal dangling nodes covered by the extension node. + // This should be done before writing the node to adhere to the committing + // order from bottom to top. + for _, path := range internal { + t.options.Cleaner(path) + } + t.options.Writer(path, common.BytesToHash(st.val), blob) } // Hash will firstly hash the entire trie if it's still not hashed and then commit diff --git a/trie/stacktrie_test.go b/trie/stacktrie_test.go index 0e52781c62..573a3f9cf5 100644 --- a/trie/stacktrie_test.go +++ b/trie/stacktrie_test.go @@ -19,11 +19,14 @@ package trie import ( "bytes" "math/big" + "math/rand" "testing" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/core/rawdb" "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/trie/testutil" + "golang.org/x/exp/slices" ) func TestStackTrieInsertAndHash(t *testing.T) { @@ -376,3 +379,103 @@ func TestStacktrieNotModifyValues(t *testing.T) { } } } + +func buildPartialTree(entries []*kv, t *testing.T) map[string]common.Hash { + var ( + options = NewStackTrieOptions() + nodes = make(map[string]common.Hash) + boundary = make(map[string]common.Hash) + ) + var ( + first int + last = len(entries) - 1 + + noLeft bool + noRight bool + ) + // Enter split mode if there are at least two elements + if rand.Intn(5) != 0 { + for { + first = rand.Intn(len(entries)) + last = rand.Intn(len(entries)) + if first <= last { + break + } + } + if first != 0 { + noLeft = true + } + if last != len(entries)-1 { + noRight = true + } + } + options = options.SkipBoundary(noLeft, noRight, func(path []byte, hash common.Hash, blob []byte) { + boundary[string(path)] = hash + }) + options = options.WithWriter(func(path []byte, hash common.Hash, blob []byte) { + nodes[string(path)] = hash + }) + tr := NewStackTrie(options) + + for i := first; i <= last; i++ { + tr.MustUpdate(entries[i].k, entries[i].v) + } + tr.Commit() + + for path := range boundary { + var expect bool + if noLeft && bytes.HasPrefix(keybytesToHex(entries[first].k), []byte(path)) { + expect = true + } + if noRight && bytes.HasPrefix(keybytesToHex(entries[last].k), []byte(path)) { + expect = true + } + if !expect { + t.Fatalf("Unexpected boundary node, %v", []byte(path)) + } + } + return nodes +} + +func TestPartialStackTrie(t *testing.T) { + for round := 0; round < 100; round++ { + var ( + n = rand.Intn(100) + 1 + entries []*kv + ) + for i := 0; i < n; i++ { + var val []byte + if rand.Intn(3) == 0 { + val = testutil.RandBytes(3) + } else { + val = testutil.RandBytes(32) + } + entries = append(entries, &kv{ + k: testutil.RandBytes(32), + v: val, + }) + } + slices.SortFunc(entries, (*kv).cmp) + + var ( + nodes = make(map[string]common.Hash) + options = NewStackTrieOptions().WithWriter(func(path []byte, hash common.Hash, blob []byte) { + nodes[string(path)] = hash + }) + ) + tr := NewStackTrie(options) + + for i := 0; i < len(entries); i++ { + tr.MustUpdate(entries[i].k, entries[i].v) + } + tr.Commit() + + for j := 0; j < 100; j++ { + for path, hash := range buildPartialTree(entries, t) { + if nodes[path] != hash { + t.Errorf("%v, want %x, got %x", []byte(path), nodes[path], hash) + } + } + } + } +} diff --git a/trie/sync.go b/trie/sync.go index 6939aed76d..dfb9f9fd7c 100644 --- a/trie/sync.go +++ b/trie/sync.go @@ -51,6 +51,14 @@ var ( // lookupGauge is the metric to track how many trie node lookups are // performed to determine if node needs to be deleted. lookupGauge = metrics.NewRegisteredGauge("trie/sync/lookup", nil) + + // nodeSyncedGauge is the metric to track how many trie node are written + // during the sync. + nodeSyncedGauge = metrics.NewRegisteredGauge("trie/sync/nodes", nil) + + // codeSyncedGauge is the metric to track how many contract codes are + // written during the sync. + codeSyncedGauge = metrics.NewRegisteredGauge("trie/sync/codes", nil) ) // SyncPath is a path tuple identifying a particular trie node either in a single @@ -366,6 +374,8 @@ func (s *Sync) Commit(dbw ethdb.Batch) error { owner, inner := ResolvePath([]byte(path)) rawdb.WriteTrieNode(dbw, owner, inner, s.membatch.hashes[path], value, s.scheme) } + nodeSyncedGauge.Inc(int64(len(s.membatch.nodes))) + // Flush the pending node deletes into the database batch. // Please note that each written and deleted node has a // unique path, ensuring no duplication occurs. @@ -377,6 +387,8 @@ func (s *Sync) Commit(dbw ethdb.Batch) error { for hash, value := range s.membatch.codes { rawdb.WriteCode(dbw, hash, value) } + codeSyncedGauge.Inc(int64(len(s.membatch.codes))) + s.membatch = newSyncMemBatch() // reset the batch return nil }