mirror of
https://github.com/luxfi/geth.git
synced 2026-07-27 01:59:25 +00:00
triedb/hashdb: honor ReferenceRootAtomicallyOnUpdate (latent-correctness alignment + GC regression tests)
luxfi/evm's block-accept pruner (cappedMemoryTrieWriter: tipBuffer + RejectTrie + reprocess previousRoot deref) and upstream coreth commit 7dc5b7473 assume the state root is referenced on Update (parents>=1). luxfi/geth's slimmed hashdb.Config dropped the ReferenceRootAtomicallyOnUpdate flag, so hashdb.Update referenced only storage roots and every state root had parents==0. This restores the flag and pins the state root atomically inside Update, matching the upstream contract. Adds triedb/reference_root_test.go documenting the coreth GC lifecycle. NOTE: these tests demonstrate the reference-counting is single-deref-SAFE with AND without the flag (the accepted head's subtree is protected by its own Update references under sibling reject + ancestor eviction). i.e. this change is a latent-correctness alignment, not by itself the fix for the devnet block-2 halt. Co-authored-by: Hanzo Dev <dev@hanzo.ai>
This commit is contained in:
@@ -63,6 +63,20 @@ var (
|
||||
// Config contains the settings for database.
|
||||
type Config struct {
|
||||
CleanCacheSize int // Maximum memory allowance (in bytes) for caching clean nodes
|
||||
|
||||
// ReferenceRootAtomicallyOnUpdate, when true, adds a reference to the state
|
||||
// root node in the same locked section as Update inserts it. Callers that
|
||||
// manage trie lifetime purely through Reference/Dereference on state roots
|
||||
// (e.g. coreth/evm's block-accept pruning: tipBuffer + RejectTrie + reprocess)
|
||||
// rely on every accepted root carrying a live reference so that a later
|
||||
// Dereference decrements it to zero exactly once. Without this, a state root
|
||||
// is inserted with parents==0 and the FIRST Dereference of it (a reorg reject,
|
||||
// a tip-buffer eviction, or the reprocess previousRoot deref) deletes it and
|
||||
// cascades into shared children, corrupting a still-live sibling/head trie.
|
||||
// Referencing atomically inside Update (rather than via a separate Reference
|
||||
// call) closes the window where a concurrent Cap could evict the just-written
|
||||
// root before it is pinned.
|
||||
ReferenceRootAtomicallyOnUpdate bool
|
||||
}
|
||||
|
||||
// Defaults is the default setting for database if it's not specified.
|
||||
@@ -95,6 +109,10 @@ type Database struct {
|
||||
dirtiesSize common.StorageSize // Storage size of the dirty node cache (exc. metadata)
|
||||
childrenSize common.StorageSize // Storage size of the external children tracking
|
||||
|
||||
// referenceRootAtomicallyOnUpdate mirrors Config.ReferenceRootAtomicallyOnUpdate.
|
||||
// When set, Update pins the state root it just inserted (see Config docs).
|
||||
referenceRootAtomicallyOnUpdate bool
|
||||
|
||||
lock sync.RWMutex
|
||||
}
|
||||
|
||||
@@ -133,9 +151,10 @@ func New(diskdb ethdb.Database, config *Config) *Database {
|
||||
cleans = bytecache.New(config.CleanCacheSize)
|
||||
}
|
||||
return &Database{
|
||||
diskdb: diskdb,
|
||||
cleans: cleans,
|
||||
dirties: make(map[common.Hash]*cachedNode),
|
||||
diskdb: diskdb,
|
||||
cleans: cleans,
|
||||
dirties: make(map[common.Hash]*cachedNode),
|
||||
referenceRootAtomicallyOnUpdate: config.ReferenceRootAtomicallyOnUpdate,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -600,6 +619,15 @@ func (db *Database) Update(root common.Hash, parent common.Hash, block uint64, n
|
||||
}
|
||||
}
|
||||
}
|
||||
// Pin the state root in the same locked section that inserted it, so that
|
||||
// lifetime managers driving pruning solely through root Reference/Dereference
|
||||
// (coreth/evm's tipBuffer + RejectTrie + reprocess) always find the root with a
|
||||
// live reference. Referencing here — rather than via a separate Reference call —
|
||||
// is atomic with the insert, closing the window where a concurrent Cap could
|
||||
// evict the root before it is pinned. Skips the empty root (nothing inserted).
|
||||
if db.referenceRootAtomicallyOnUpdate && root != types.EmptyRootHash {
|
||||
db.reference(root, common.Hash{})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
// Copyright 2024 The go-ethereum Authors
|
||||
// This file is part of the go-ethereum library.
|
||||
//
|
||||
// Reproduction of the coreth/evm state-root pruning contract: state roots are
|
||||
// managed purely through Reference/Dereference on the ROOT (tipBuffer eviction,
|
||||
// RejectTrie, reprocess previousRoot deref). hashdb.Update only references
|
||||
// storage roots, so without ReferenceRootAtomicallyOnUpdate a state root has
|
||||
// parents==0. This test exercises the exact lifecycle and asserts the accepted
|
||||
// head trie stays readable.
|
||||
|
||||
package triedb_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/luxfi/geth/common"
|
||||
"github.com/luxfi/geth/core/rawdb"
|
||||
"github.com/luxfi/geth/trie"
|
||||
"github.com/luxfi/geth/trie/trienode"
|
||||
"github.com/luxfi/geth/triedb"
|
||||
"github.com/luxfi/geth/triedb/hashdb"
|
||||
)
|
||||
|
||||
func newRefTestDB(refRoot bool) *triedb.Database {
|
||||
return triedb.NewDatabase(rawdb.NewMemoryDatabase(), &triedb.Config{
|
||||
HashDB: &hashdb.Config{ReferenceRootAtomicallyOnUpdate: refRoot},
|
||||
})
|
||||
}
|
||||
|
||||
// buildOn opens a trie at parent, applies mutations, commits it into tdb, and
|
||||
// returns the new root. Mirrors coreth's per-block state.Commit -> triedb.Update.
|
||||
func buildOn(t *testing.T, tdb *triedb.Database, parent common.Hash, blockNum uint64, muts map[string]string) common.Hash {
|
||||
t.Helper()
|
||||
tr, err := trie.New(trie.TrieID(parent), tdb)
|
||||
if err != nil {
|
||||
t.Fatalf("open trie at %x: %v", parent, err)
|
||||
}
|
||||
for k, v := range muts {
|
||||
tr.MustUpdate([]byte(k), []byte(v))
|
||||
}
|
||||
root, set := tr.Commit(false)
|
||||
if set != nil {
|
||||
if err := tdb.Update(root, parent, blockNum, trienode.NewWithNodeSet(set), nil); err != nil {
|
||||
t.Fatalf("triedb.Update: %v", err)
|
||||
}
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
// assertReadable opens a trie at root and reads every key, failing on any
|
||||
// "missing trie node". This is exactly what miner.createCurrentEnvironment ->
|
||||
// StateAt(parent.Root) does before BuildBlock.
|
||||
func assertReadable(t *testing.T, tdb *triedb.Database, root common.Hash, want map[string]string) {
|
||||
t.Helper()
|
||||
tr, err := trie.New(trie.TrieID(root), tdb)
|
||||
if err != nil {
|
||||
t.Fatalf("open head trie %x: %v (missing trie node == the bug)", root, err)
|
||||
}
|
||||
for k, v := range want {
|
||||
got, err := tr.Get([]byte(k))
|
||||
if err != nil {
|
||||
t.Fatalf("read key %q at head %x: %v (missing trie node == the bug)", k, root, err)
|
||||
}
|
||||
if string(got) != v {
|
||||
t.Fatalf("key %q = %q, want %q", k, got, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// manyKeys spreads keys across the trie so it has real branch depth; the base
|
||||
// set is what siblings share in memory.
|
||||
func manyKeys(prefix string, n int) map[string]string {
|
||||
m := make(map[string]string, n)
|
||||
for i := 0; i < n; i++ {
|
||||
m[prefix+string(rune('a'+i%26))+string(rune('0'+i/26))] = "v"
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// TestReferenceRoot_RejectSiblingOnFreshChain models the equivocation halt: a
|
||||
// fresh chain (nothing committed to disk) with two sibling blocks at height 2;
|
||||
// the engine accepts one and RejectTrie-dereferences the other. The accepted
|
||||
// head must remain fully readable.
|
||||
func TestReferenceRoot_RejectSiblingOnFreshChain(t *testing.T) {
|
||||
for _, refRoot := range []bool{true, false} {
|
||||
t.Run(map[bool]string{true: "withFix", false: "withoutFix"}[refRoot], func(t *testing.T) {
|
||||
tdb := newRefTestDB(refRoot)
|
||||
|
||||
base := manyKeys("acct", 200)
|
||||
// genesis (in memory, NOT committed — fresh chain)
|
||||
r0 := buildOn(t, tdb, common.Hash{}, 0, base)
|
||||
// block 1 accepted
|
||||
b1 := map[string]string{"acctZ0": "one"}
|
||||
r1 := buildOn(t, tdb, r0, 1, b1)
|
||||
|
||||
// two siblings at height 2, each mutating a DISJOINT deep key
|
||||
r2a := buildOn(t, tdb, r1, 2, map[string]string{"headA": "A"})
|
||||
r2b := buildOn(t, tdb, r1, 2, map[string]string{"tailB": "B"})
|
||||
|
||||
// coreth accept lifecycle: tipBuffer holds accepted roots; reject the
|
||||
// sibling (RejectTrie -> Dereference).
|
||||
if err := tdb.Dereference(r2b); err != nil {
|
||||
t.Fatalf("dereference sibling: %v", err)
|
||||
}
|
||||
|
||||
// The accepted head r2a must be fully readable (base + b1 + its own mutation).
|
||||
want := map[string]string{"headA": "A", "acctZ0": "one"}
|
||||
for k, v := range base {
|
||||
want[k] = v
|
||||
}
|
||||
assertReadable(t, tdb, r2a, want)
|
||||
// r1 (still in tipBuffer) must also be intact.
|
||||
_ = r1
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestReferenceRoot_ReprocessDerefChain models reprocessState's rebuild loop
|
||||
// (blockchain.go): re-execute blocks forward, Dereference(previousRoot) after
|
||||
// computing each next root, then Commit the final root. On a fresh chain nothing
|
||||
// is on disk, so every shared node lives only in memory. The final head must
|
||||
// survive the chain of previousRoot dereferences and Commit intact.
|
||||
func TestReferenceRoot_ReprocessDerefChain(t *testing.T) {
|
||||
for _, refRoot := range []bool{true, false} {
|
||||
t.Run(map[bool]string{true: "withFix", false: "withoutFix"}[refRoot], func(t *testing.T) {
|
||||
tdb := newRefTestDB(refRoot)
|
||||
|
||||
base := manyKeys("acct", 200)
|
||||
roots := []common.Hash{}
|
||||
prev := common.Hash{}
|
||||
cur := buildOn(t, tdb, prev, 0, base)
|
||||
roots = append(roots, cur)
|
||||
|
||||
// 12 incremental blocks, each mutating a distinct deep key.
|
||||
for i := 1; i <= 12; i++ {
|
||||
parent := cur
|
||||
cur = buildOn(t, tdb, parent, uint64(i), map[string]string{
|
||||
"blk" + string(rune('a'+i)): "x",
|
||||
})
|
||||
// reprocess loop: Dereference(previousRoot) once the next root exists.
|
||||
if err := tdb.Dereference(parent); err != nil {
|
||||
t.Fatalf("dereference previousRoot: %v", err)
|
||||
}
|
||||
roots = append(roots, cur)
|
||||
}
|
||||
|
||||
// reprocess finishes with Commit(finalRoot) to disk.
|
||||
if err := tdb.Commit(cur, false); err != nil {
|
||||
t.Fatalf("commit final root: %v", err)
|
||||
}
|
||||
|
||||
want := map[string]string{}
|
||||
for k, v := range base {
|
||||
want[k] = v
|
||||
}
|
||||
for i := 1; i <= 12; i++ {
|
||||
want["blk"+string(rune('a'+i))] = "x"
|
||||
}
|
||||
assertReadable(t, tdb, cur, want)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestReferenceRoot_SiblingRejectWithAncestorEvicted is the SHARP case: the
|
||||
// common ancestor is dereferenced (tip-buffer eviction) around the same time a
|
||||
// sibling is rejected, so a node shared only via the rejected sibling's path can
|
||||
// be cascade-deleted out from under the accepted head. This is the topology the
|
||||
// simple sibling test could not exercise (there the live ancestor protected all
|
||||
// shared nodes).
|
||||
func TestReferenceRoot_SiblingRejectWithAncestorEvicted(t *testing.T) {
|
||||
for _, refRoot := range []bool{true, false} {
|
||||
t.Run(map[bool]string{true: "withFix", false: "withoutFix"}[refRoot], func(t *testing.T) {
|
||||
tdb := newRefTestDB(refRoot)
|
||||
base := manyKeys("acct", 200)
|
||||
r0 := buildOn(t, tdb, common.Hash{}, 0, base)
|
||||
r1 := buildOn(t, tdb, r0, 1, map[string]string{"acctZ0": "one"})
|
||||
|
||||
// Two siblings at height 2 that BOTH create the same brand-new key
|
||||
// (identical shared node) plus their own distinct key.
|
||||
r2a := buildOn(t, tdb, r1, 2, map[string]string{"shared": "S", "onlyA": "A"})
|
||||
r2b := buildOn(t, tdb, r1, 2, map[string]string{"shared": "S", "onlyB": "B"})
|
||||
|
||||
// Simulate the reorg/prune order that a converging fresh chain hits:
|
||||
// ancestors r0, r1 age out of the tip buffer (dereferenced), and the
|
||||
// losing sibling r2b is rejected.
|
||||
for _, r := range []common.Hash{r0, r1, r2b} {
|
||||
if err := tdb.Dereference(r); err != nil {
|
||||
t.Fatalf("dereference %x: %v", r, err)
|
||||
}
|
||||
}
|
||||
|
||||
want := map[string]string{"shared": "S", "onlyA": "A", "acctZ0": "one"}
|
||||
for k, v := range base {
|
||||
want[k] = v
|
||||
}
|
||||
assertReadable(t, tdb, r2a, want)
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user