Skip to content

Commit 5adf4ad

Browse files
authored
eth/protocols/snap: cleanup dangling account trie nodes due to incomplete storage (#30258)
This pull request fixes #30229. During snap sync, large storage will be split into several pieces and synchronized concurrently. Unfortunately, the tradeoff is that the respective merkle trie of each storage chunk will be incomplete due to the incomplete boundaries. The trie nodes on these boundaries will be discarded, and any dangling nodes on disk will also be removed if they fall on these paths, ensuring the state healer won't be blocked. However, the dangling account trie nodes on the path from the root to the associated account are left untouched. This means the dangling account trie nodes could potentially stop the state healing and break the assumption that the entire subtrie should exist if the subtrie root exists. We should consider the account trie node as the ancestor of the corresponding storage trie node. In the scenarios described in the above ticket, the state corruption could occur if there is a dangling account trie node while some storage trie nodes are removed due to synchronization redo. The fixing idea is pretty straightforward, the trie nodes on the path from root to account should all be explicitly removed if an incomplete storage trie occurs. Therefore, a `delete` operation has been added into `gentrie` to explicitly clear the account along with all nodes on this path. The special thing is that it's a cross-trie clearing. In theory, there may be a dangling node at any position on this account key and we have to clear all of them.
1 parent 33a13b6 commit 5adf4ad

File tree

4 files changed

+198
-9
lines changed

4 files changed

+198
-9
lines changed

eth/protocols/snap/gentrie.go

Lines changed: 39 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,9 @@ type genTrie interface {
3131
// update inserts the state item into generator trie.
3232
update(key, value []byte) error
3333

34+
// delete removes the state item from the generator trie.
35+
delete(key []byte) error
36+
3437
// commit flushes the right boundary nodes if complete flag is true. This
3538
// function must be called before flushing the associated database batch.
3639
commit(complete bool) common.Hash
@@ -113,7 +116,7 @@ func (t *pathTrie) onTrieNode(path []byte, hash common.Hash, blob []byte) {
113116
// removed because it's a sibling of the nodes we want to commit, not
114117
// the parent or ancestor.
115118
for i := 0; i < len(path); i++ {
116-
t.delete(path[:i], false)
119+
t.deleteNode(path[:i], false)
117120
}
118121
}
119122
return
@@ -136,7 +139,7 @@ func (t *pathTrie) onTrieNode(path []byte, hash common.Hash, blob []byte) {
136139
// byte key. In either case, no gaps will be left in the path.
137140
if t.last != nil && bytes.HasPrefix(t.last, path) && len(t.last)-len(path) > 1 {
138141
for i := len(path) + 1; i < len(t.last); i++ {
139-
t.delete(t.last[:i], true)
142+
t.deleteNode(t.last[:i], true)
140143
}
141144
}
142145
t.write(path, blob)
@@ -192,8 +195,8 @@ func (t *pathTrie) deleteStorageNode(path []byte, inner bool) {
192195
rawdb.DeleteStorageTrieNode(t.batch, t.owner, path)
193196
}
194197

195-
// delete commits the node deletion to provided database batch in path mode.
196-
func (t *pathTrie) delete(path []byte, inner bool) {
198+
// deleteNode commits the node deletion to provided database batch in path mode.
199+
func (t *pathTrie) deleteNode(path []byte, inner bool) {
197200
if t.owner == (common.Hash{}) {
198201
t.deleteAccountNode(path, inner)
199202
} else {
@@ -207,6 +210,34 @@ func (t *pathTrie) update(key, value []byte) error {
207210
return t.tr.Update(key, value)
208211
}
209212

213+
// delete implements genTrie interface, deleting the item from the stack trie.
214+
func (t *pathTrie) delete(key []byte) error {
215+
// Commit the trie since the right boundary is incomplete because
216+
// of the deleted item. This will implicitly discard the last inserted
217+
// item and clean some ancestor trie nodes of the last committed
218+
// item in the database.
219+
t.commit(false)
220+
221+
// Reset the trie and all the internal trackers
222+
t.first = nil
223+
t.last = nil
224+
t.tr.Reset()
225+
226+
// Explicitly mark the left boundary as incomplete, as the left-side
227+
// item of the next one has been deleted. Be aware that the next item
228+
// to be inserted will be ignored from committing as well as it's on
229+
// the left boundary.
230+
t.skipLeftBoundary = true
231+
232+
// Explicitly delete the potential leftover nodes on the specific
233+
// path from the database.
234+
tkey := t.tr.TrieKey(key)
235+
for i := 0; i <= len(tkey); i++ {
236+
t.deleteNode(tkey[:i], false)
237+
}
238+
return nil
239+
}
240+
210241
// commit implements genTrie interface, flushing the right boundary if it's
211242
// considered as complete. Otherwise, the nodes on the right boundary are
212243
// discarded and cleaned up.
@@ -255,7 +286,7 @@ func (t *pathTrie) commit(complete bool) common.Hash {
255286
// with no issues as they are actually complete. Also, from a database
256287
// perspective, first deleting and then rewriting is a valid data update.
257288
for i := 0; i < len(t.last); i++ {
258-
t.delete(t.last[:i], false)
289+
t.deleteNode(t.last[:i], false)
259290
}
260291
return common.Hash{} // the hash is meaningless for incomplete commit
261292
}
@@ -278,6 +309,9 @@ func (t *hashTrie) update(key, value []byte) error {
278309
return t.tr.Update(key, value)
279310
}
280311

312+
// delete implements genTrie interface, ignoring the state item for deleting.
313+
func (t *hashTrie) delete(key []byte) error { return nil }
314+
281315
// commit implements genTrie interface, committing the nodes on right boundary.
282316
func (t *hashTrie) commit(complete bool) common.Hash {
283317
if !complete {

eth/protocols/snap/gentrie_test.go

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -551,3 +551,145 @@ func TestTinyPartialTree(t *testing.T) {
551551
}
552552
}
553553
}
554+
555+
func TestTrieDelete(t *testing.T) {
556+
var entries []*kv
557+
for i := 0; i < 1024; i++ {
558+
entries = append(entries, &kv{
559+
k: testrand.Bytes(32),
560+
v: testrand.Bytes(32),
561+
})
562+
}
563+
slices.SortFunc(entries, (*kv).cmp)
564+
565+
nodes := make(map[string]common.Hash)
566+
tr := trie.NewStackTrie(func(path []byte, hash common.Hash, blob []byte) {
567+
nodes[string(path)] = hash
568+
})
569+
for i := 0; i < len(entries); i++ {
570+
tr.Update(entries[i].k, entries[i].v)
571+
}
572+
tr.Hash()
573+
574+
check := func(index []int) {
575+
var (
576+
db = rawdb.NewMemoryDatabase()
577+
batch = db.NewBatch()
578+
marks = map[int]struct{}{}
579+
neighbors = map[int]struct{}{}
580+
)
581+
for _, n := range index {
582+
marks[n] = struct{}{}
583+
}
584+
for _, n := range index {
585+
if n != 0 {
586+
if _, ok := marks[n-1]; !ok {
587+
neighbors[n-1] = struct{}{}
588+
}
589+
}
590+
if n != len(entries)-1 {
591+
if _, ok := neighbors[n+1]; !ok {
592+
neighbors[n+1] = struct{}{}
593+
}
594+
}
595+
}
596+
// Write the junk nodes as the dangling
597+
var injects []string
598+
for _, n := range index {
599+
nibbles := byteToHex(entries[n].k)
600+
for i := 0; i <= len(nibbles); i++ {
601+
injects = append(injects, string(nibbles[:i]))
602+
}
603+
}
604+
for _, path := range injects {
605+
rawdb.WriteAccountTrieNode(db, []byte(path), testrand.Bytes(32))
606+
}
607+
tr := newPathTrie(common.Hash{}, false, db, batch)
608+
for i := 0; i < len(entries); i++ {
609+
if _, ok := marks[i]; ok {
610+
tr.delete(entries[i].k)
611+
} else {
612+
tr.update(entries[i].k, entries[i].v)
613+
}
614+
}
615+
tr.commit(true)
616+
617+
r := newBatchReplay()
618+
batch.Replay(r)
619+
batch.Write()
620+
621+
for _, path := range injects {
622+
if rawdb.HasAccountTrieNode(db, []byte(path)) {
623+
t.Fatalf("Unexpected leftover node %v", []byte(path))
624+
}
625+
}
626+
627+
// ensure all the written nodes match with the complete tree
628+
set := make(map[string]common.Hash)
629+
for path, hash := range r.modifies() {
630+
if hash == (common.Hash{}) {
631+
continue
632+
}
633+
n, ok := nodes[path]
634+
if !ok {
635+
t.Fatalf("Unexpected trie node: %v", []byte(path))
636+
}
637+
if n != hash {
638+
t.Fatalf("Unexpected trie node content: %v, want: %x, got: %x", []byte(path), n, hash)
639+
}
640+
set[path] = hash
641+
}
642+
643+
// ensure all the missing nodes either on the deleted path, or
644+
// on the neighbor paths.
645+
isMissing := func(path []byte) bool {
646+
for n := range marks {
647+
key := byteToHex(entries[n].k)
648+
if bytes.HasPrefix(key, path) {
649+
return true
650+
}
651+
}
652+
for n := range neighbors {
653+
key := byteToHex(entries[n].k)
654+
if bytes.HasPrefix(key, path) {
655+
return true
656+
}
657+
}
658+
return false
659+
}
660+
for path := range nodes {
661+
if _, ok := set[path]; ok {
662+
continue
663+
}
664+
if !isMissing([]byte(path)) {
665+
t.Fatalf("Missing node %v", []byte(path))
666+
}
667+
}
668+
}
669+
var cases = []struct {
670+
index []int
671+
}{
672+
// delete the first
673+
{[]int{0}},
674+
675+
// delete the last
676+
{[]int{len(entries) - 1}},
677+
678+
// delete the first two
679+
{[]int{0, 1}},
680+
681+
// delete the last two
682+
{[]int{len(entries) - 2, len(entries) - 1}},
683+
684+
{[]int{
685+
0, 2, 4, 6,
686+
len(entries) - 1,
687+
len(entries) - 3,
688+
len(entries) - 5,
689+
len(entries) - 7,
690+
}},
691+
}
692+
for _, c := range cases {
693+
check(c.index)
694+
}
695+
}

eth/protocols/snap/sync.go

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2424,14 +2424,21 @@ func (s *Syncer) forwardAccountTask(task *accountTask) {
24242424
slim := types.SlimAccountRLP(*res.accounts[i])
24252425
rawdb.WriteAccountSnapshot(batch, hash, slim)
24262426

2427-
// If the task is complete, drop it into the stack trie to generate
2428-
// account trie nodes for it
24292427
if !task.needHeal[i] {
2428+
// If the storage task is complete, drop it into the stack trie
2429+
// to generate account trie nodes for it
24302430
full, err := types.FullAccountRLP(slim) // TODO(karalabe): Slim parsing can be omitted
24312431
if err != nil {
24322432
panic(err) // Really shouldn't ever happen
24332433
}
24342434
task.genTrie.update(hash[:], full)
2435+
} else {
2436+
// If the storage task is incomplete, explicitly delete the corresponding
2437+
// account item from the account trie to ensure that all nodes along the
2438+
// path to the incomplete storage trie are cleaned up.
2439+
if err := task.genTrie.delete(hash[:]); err != nil {
2440+
panic(err) // Really shouldn't ever happen
2441+
}
24352442
}
24362443
}
24372444
// Flush anything written just now and update the stats

trie/stacktrie.go

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,8 +64,7 @@ func (t *StackTrie) Update(key, value []byte) error {
6464
if len(value) == 0 {
6565
return errors.New("trying to insert empty (deletion)")
6666
}
67-
k := keybytesToHex(key)
68-
k = k[:len(k)-1] // chop the termination flag
67+
k := t.TrieKey(key)
6968
if bytes.Compare(t.last, k) >= 0 {
7069
return errors.New("non-ascending key order")
7170
}
@@ -84,6 +83,13 @@ func (t *StackTrie) Reset() {
8483
t.last = nil
8584
}
8685

86+
// TrieKey returns the internal key representation for the given user key.
87+
func (t *StackTrie) TrieKey(key []byte) []byte {
88+
k := keybytesToHex(key)
89+
k = k[:len(k)-1] // chop the termination flag
90+
return k
91+
}
92+
8793
// stNode represents a node within a StackTrie
8894
type stNode struct {
8995
typ uint8 // node type (as in branch, ext, leaf)

0 commit comments

Comments
 (0)