diff --git a/core/state/database_history.go b/core/state/database_history.go index fbf4ab5f9c..170f79da93 100644 --- a/core/state/database_history.go +++ b/core/state/database_history.go @@ -62,7 +62,7 @@ func (r *historicStateReader) Account(addr common.Address) (*types.StateAccount, Root: common.BytesToHash(account.Root), } if len(acct.CodeHash) == 0 { - acct.CodeHash = types.EmptyCodeHash.Bytes() + acct.CodeHash = types.EmptyCodeHash[:] } if acct.Root == (common.Hash{}) { acct.Root = types.EmptyRootHash diff --git a/core/state/reader.go b/core/state/reader.go index be07cec0f9..2163da8a9f 100644 --- a/core/state/reader.go +++ b/core/state/reader.go @@ -112,7 +112,7 @@ func (r *flatReader) Account(addr common.Address) (*types.StateAccount, error) { Root: common.BytesToHash(account.Root), } if len(acct.CodeHash) == 0 { - acct.CodeHash = types.EmptyCodeHash.Bytes() + acct.CodeHash = types.EmptyCodeHash[:] } if acct.Root == (common.Hash{}) { acct.Root = types.EmptyRootHash diff --git a/core/state/reader_test.go b/core/state/reader_test.go new file mode 100644 index 0000000000..7a38971f28 --- /dev/null +++ b/core/state/reader_test.go @@ -0,0 +1,93 @@ +// Copyright 2026 The go-ethereum Authors +// This file is part of the go-ethereum library. +// +// The go-ethereum library is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// The go-ethereum library is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with the go-ethereum library. If not, see . + +package state + +import ( + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/holiman/uint256" +) + +// benchStateReader is a minimal database.StateReader backed by an in-memory map. +// It isolates the conversion overhead in flatReader.Account (address hashing, +// the *types.StateAccount allocation and the slim->full fixups) from any trie, +// snapshot or disk access. +type benchStateReader struct { + account *types.SlimAccount +} + +func (r *benchStateReader) Account(hash common.Hash) (*types.SlimAccount, error) { + // Return a fresh copy on every call, mirroring the contract that the + // returned account is safe to modify by the caller. + if r.account == nil { + return nil, nil + } + cpy := *r.account + return &cpy, nil +} + +func (r *benchStateReader) Storage(accountHash, storageHash common.Hash) ([]byte, error) { + return nil, nil +} + +// benchmarkFlatReaderAccount measures flatReader.Account for a single address. +// The provided slim account dictates which branches of the conversion are hit: +// a slim account with nil Root/CodeHash exercises the EmptyCodeHash.Bytes() and +// EmptyRootHash fixups (the lines that dominate the production profile), whereas +// a fully populated one skips them. +func benchmarkFlatReaderAccount(b *testing.B, slim *types.SlimAccount) { + r := newFlatReader(&benchStateReader{account: slim}) + addr := common.HexToAddress("0x1234567890123456789012345678901234567890") + + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + acct, err := r.Account(addr) + if err != nil { + b.Fatal(err) + } + if acct == nil { + b.Fatal("unexpected nil account") + } + } +} + +// BenchmarkFlatReaderAccountEmpty benchmarks the common EOA case: an account +// with no code and no storage. This is the hot path from the profile, hitting +// both the EmptyCodeHash and EmptyRootHash fixups. +func BenchmarkFlatReaderAccountEmpty(b *testing.B) { + benchmarkFlatReaderAccount(b, &types.SlimAccount{ + Nonce: 1, + Balance: uint256.NewInt(100), + // Root and CodeHash left nil: slim encoding of an EOA. + }) +} + +// BenchmarkFlatReaderAccountContract benchmarks a contract account with a +// non-empty storage root and code hash, skipping the empty-value fixups. +func BenchmarkFlatReaderAccountContract(b *testing.B) { + root := common.HexToHash("0xaabbccddeeff00112233445566778899aabbccddeeff00112233445566778899") + codeHash := common.HexToHash("0x112233445566778899aabbccddeeff00112233445566778899aabbccddeeff00") + benchmarkFlatReaderAccount(b, &types.SlimAccount{ + Nonce: 7, + Balance: uint256.NewInt(1000), + Root: root.Bytes(), + CodeHash: codeHash.Bytes(), + }) +} diff --git a/triedb/pathdb/buffer.go b/triedb/pathdb/buffer.go index 5d3099285f..7afc1826c7 100644 --- a/triedb/pathdb/buffer.go +++ b/triedb/pathdb/buffer.go @@ -24,6 +24,7 @@ import ( "github.com/VictoriaMetrics/fastcache" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/core/rawdb" + "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/ethdb" "github.com/ethereum/go-ethereum/log" "github.com/ethereum/go-ethereum/trie/trienode" @@ -64,8 +65,9 @@ func newBuffer(limit int, nodes *nodeSet, states *stateSet, layers uint64) *buff } } -// account retrieves the account blob with account address hash. -func (b *buffer) account(hash common.Hash) ([]byte, bool) { +// account retrieves the decoded slim account with account address hash. A nil +// account with found=true denotes a known-deleted account. +func (b *buffer) account(hash common.Hash) (*types.SlimAccount, bool) { return b.states.account(hash) } diff --git a/triedb/pathdb/database.go b/triedb/pathdb/database.go index e52949c93e..ca31647997 100644 --- a/triedb/pathdb/database.go +++ b/triedb/pathdb/database.go @@ -56,6 +56,16 @@ type layer interface { // - no error will be returned if the requested account is not found in database. account(hash common.Hash, depth int) ([]byte, error) + // accountObject directly retrieves the decoded slim account associated with + // a particular hash. It mirrors account but avoids re-encoding/decoding for + // the diff-layer hot read path, where accounts are already held in decoded + // form. An error will be returned if the read operation exits abnormally. + // + // Note: + // - the returned account is not a copy, please don't modify it. + // - a nil account is returned if the requested account is not found or was deleted. + accountObject(hash common.Hash, depth int) (*types.SlimAccount, error) + // storage directly retrieves the storage data associated with a particular hash, // within a particular account. An error will be returned if the read operation // exits abnormally. Specifically, if the layer is already stale. diff --git a/triedb/pathdb/difflayer.go b/triedb/pathdb/difflayer.go index 8ca3a39cf3..f92b380118 100644 --- a/triedb/pathdb/difflayer.go +++ b/triedb/pathdb/difflayer.go @@ -21,6 +21,7 @@ import ( "sync" "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/log" ) @@ -102,25 +103,41 @@ func (dl *diffLayer) node(owner common.Hash, path []byte, depth int) ([]byte, co // // Note the returned account is not a copy, please don't modify it. func (dl *diffLayer) account(hash common.Hash, depth int) ([]byte, error) { + account, err := dl.accountObject(hash, depth) + if err != nil { + return nil, err + } + // Re-encode into slim-RLP form for the byte-oriented callers (e.g. the + // binary iterator and the public AccountRLP). The hot read path uses + // accountObject directly and avoids this encode. + return encodeSlimAccount(account), nil +} + +// accountObject directly retrieves the decoded slim account associated with a +// particular hash. A nil account is returned if the account does not exist or +// was deleted in this hierarchy. +// +// Note the returned account is not a copy, please don't modify it. +func (dl *diffLayer) accountObject(hash common.Hash, depth int) (*types.SlimAccount, error) { // Hold the lock, ensure the parent won't be changed during the // state accessing. dl.lock.RLock() defer dl.lock.RUnlock() - if blob, found := dl.states.account(hash); found { + if account, found := dl.states.account(hash); found { dirtyStateHitMeter.Mark(1) dirtyStateHitDepthHist.Update(int64(depth)) - dirtyStateReadMeter.Mark(int64(len(blob))) + dirtyStateReadMeter.Mark(int64(slimAccountSize(account))) - if len(blob) == 0 { + if account == nil { stateAccountInexMeter.Mark(1) } else { stateAccountExistMeter.Mark(1) } - return blob, nil + return account, nil } // Account is unknown to this layer, resolve from parent - return dl.parent.account(hash, depth+1) + return dl.parent.accountObject(hash, depth+1) } // storage directly retrieves the storage data associated with a particular hash, diff --git a/triedb/pathdb/disklayer.go b/triedb/pathdb/disklayer.go index 8c0a751932..27a73af53b 100644 --- a/triedb/pathdb/disklayer.go +++ b/triedb/pathdb/disklayer.go @@ -25,6 +25,7 @@ import ( "github.com/VictoriaMetrics/fastcache" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/core/rawdb" + "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/crypto" "github.com/ethereum/go-ethereum/ethdb" "github.com/ethereum/go-ethereum/log" @@ -169,29 +170,42 @@ func (dl *diskLayer) node(owner common.Hash, path []byte, depth int) ([]byte, co // // Note the returned account is not a copy, please don't modify it. func (dl *diskLayer) account(hash common.Hash, depth int) ([]byte, error) { + account, err := dl.accountObject(hash, depth) + if err != nil { + return nil, err + } + return encodeSlimAccount(account), nil +} + +// accountObject directly retrieves the decoded slim account associated with a +// particular hash. A nil account is returned if the account does not exist or +// was deleted. +func (dl *diskLayer) accountObject(hash common.Hash, depth int) (*types.SlimAccount, error) { dl.lock.RLock() defer dl.lock.RUnlock() if dl.stale { return nil, errSnapshotStale } - // Try to retrieve the trie node from the not-yet-written node buffer first - // (both the live one and the frozen one). Note the buffer is lock free since - // it's impossible to mutate the buffer before tagging the layer as stale. + // Try to retrieve the account from the not-yet-written state buffer first + // (both the live one and the frozen one). The buffer holds accounts in + // decoded form, so no decoding is required here. Note the buffer is lock + // free since it's impossible to mutate the buffer before tagging the layer + // as stale. for _, buffer := range []*buffer{dl.buffer, dl.frozen} { if buffer != nil { - blob, found := buffer.account(hash) + account, found := buffer.account(hash) if found { dirtyStateHitMeter.Mark(1) - dirtyStateReadMeter.Mark(int64(len(blob))) + dirtyStateReadMeter.Mark(int64(slimAccountSize(account))) dirtyStateHitDepthHist.Update(int64(depth)) - if len(blob) == 0 { + if account == nil { stateAccountInexMeter.Mark(1) } else { stateAccountExistMeter.Mark(1) } - return blob, nil + return account, nil } } } @@ -214,7 +228,7 @@ func (dl *diskLayer) account(hash common.Hash, depth int) ([]byte, error) { } else { stateAccountExistMeter.Mark(1) } - return blob, nil + return decodeAccountBlob(hash, blob), nil } cleanStateMissMeter.Mark(1) } @@ -237,7 +251,7 @@ func (dl *diskLayer) account(hash common.Hash, depth int) ([]byte, error) { stateAccountExistMeter.Mark(1) stateAccountExistDiskMeter.Mark(1) } - return blob, nil + return decodeAccountBlob(hash, blob), nil } // storage directly retrieves the storage data associated with a particular hash, @@ -598,8 +612,12 @@ func (dl *diskLayer) revert(h *stateHistory) (*diskLayer, error) { batch := dl.db.diskdb.NewBatch() writeNodes(batch, nodes, dl.nodes) - // Provide the original values of modified accounts and storages for revert - writeStates(batch, progress, accounts, storages, dl.states) + // Provide the original values of modified accounts and storages for revert. + // The history-derived accounts are in slim-RLP form; decode them so they + // share the single encode point in writeStates. This path is only hit on a + // deep reorg that rolls back the persistent layer, so the round-trip cost + // is irrelevant. + writeStates(batch, progress, decodeAccounts(accounts), storages, dl.states) rawdb.WritePersistentStateID(batch, dl.id-1) rawdb.WriteSnapshotRoot(batch, h.meta.parent) if err := batch.Write(); err != nil { diff --git a/triedb/pathdb/flush.go b/triedb/pathdb/flush.go index 4f816cf6a6..8d7f791e3b 100644 --- a/triedb/pathdb/flush.go +++ b/triedb/pathdb/flush.go @@ -22,6 +22,7 @@ import ( "github.com/VictoriaMetrics/fastcache" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/core/rawdb" + "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/ethdb" "github.com/ethereum/go-ethereum/trie/trienode" ) @@ -74,12 +75,12 @@ func writeNodes(batch ethdb.Batch, nodes map[common.Hash]map[string]*trienode.No // TODO(rjl493456442) do we really need this generation marker? The state updates // after the marker can also be written and will be fixed by generator later if // it's outdated. -func writeStates(batch ethdb.Batch, genMarker []byte, accountData map[common.Hash][]byte, storageData map[common.Hash]map[common.Hash][]byte, clean *fastcache.Cache) (int, int) { +func writeStates(batch ethdb.Batch, genMarker []byte, accountData map[common.Hash]*types.SlimAccount, storageData map[common.Hash]map[common.Hash][]byte, clean *fastcache.Cache) (int, int) { var ( accounts int slots int ) - for addrHash, blob := range accountData { + for addrHash, account := range accountData { // Skip any account not yet covered by the snapshot. The account // at the generation marker position (addrHash == genMarker[:common.HashLength]) // should still be updated, as it would be skipped in the next @@ -88,12 +89,16 @@ func writeStates(batch ethdb.Batch, genMarker []byte, accountData map[common.Has continue } accounts += 1 - if len(blob) == 0 { + if account == nil { rawdb.DeleteAccountSnapshot(batch, addrHash) if clean != nil { clean.Set(addrHash[:], nil) } } else { + // Encode the decoded account back into slim-RLP form for storage. + // This is the single point where the in-memory decoded accounts + // are serialized for the on-disk snapshot. + blob := encodeSlimAccount(account) rawdb.WriteAccountSnapshot(batch, addrHash, blob) if clean != nil { clean.Set(addrHash[:], blob) diff --git a/triedb/pathdb/generate_test.go b/triedb/pathdb/generate_test.go index f38a1ed7c4..a3c13f7c42 100644 --- a/triedb/pathdb/generate_test.go +++ b/triedb/pathdb/generate_test.go @@ -69,7 +69,7 @@ func (t *genTester) addTrieAccount(acckey string, acc *types.StateAccount) { ) t.acctTrie.MustUpdate(key.Bytes(), val) - t.states.accountData[key] = val + t.states.accountData[key] = decodeAccountBlob(key, types.SlimAccountRLP(*acc)) t.states.accountOrigin[addr] = nil } diff --git a/triedb/pathdb/iterator_fast.go b/triedb/pathdb/iterator_fast.go index 04bf89e874..0f8b931a1f 100644 --- a/triedb/pathdb/iterator_fast.go +++ b/triedb/pathdb/iterator_fast.go @@ -98,7 +98,11 @@ func newFastIterator(db *Database, root common.Hash, account common.Hash, seek c if dl.stale { return nil, errSnapshotStale } - return dl.buffer.states.mustAccount(hash) + account, err := dl.buffer.states.mustAccount(hash) + if err != nil { + return nil, err + } + return encodeSlimAccount(account), nil }), priority: depth, }) @@ -110,8 +114,15 @@ func newFastIterator(db *Database, root common.Hash, account common.Hash, seek c // The state set in diff layer is immutable and will never be stale, // so the read lock protection is unnecessary. accountList := dl.states.accountList() + states := dl.states fi.iterators = append(fi.iterators, &weightedIterator{ - it: newDiffAccountIterator(seek, accountList, dl.states.mustAccount), + it: newDiffAccountIterator(seek, accountList, func(hash common.Hash) ([]byte, error) { + account, err := states.mustAccount(hash) + if err != nil { + return nil, err + } + return encodeSlimAccount(account), nil + }), priority: depth, }) } diff --git a/triedb/pathdb/iterator_test.go b/triedb/pathdb/iterator_test.go index 191c2fadf5..3bce1989ea 100644 --- a/triedb/pathdb/iterator_test.go +++ b/triedb/pathdb/iterator_test.go @@ -122,8 +122,7 @@ func TestAccountIteratorBasics(t *testing.T) { // Fill up a parent for i := 0; i < 100; i++ { hash := testrand.Hash() - data := testrand.Bytes(32) - accounts[hash] = data + accounts[hash] = randomAccount() if rand.Intn(2) == 0 { accStorage := make(map[common.Hash][]byte) @@ -153,7 +152,7 @@ func TestStorageIteratorBasics(t *testing.T) { // Fill some random data for i := 0; i < 10; i++ { hash := testrand.Hash() - accounts[hash] = testrand.Bytes(32) + accounts[hash] = randomAccount() accStorage := make(map[common.Hash][]byte) var nilstorage int @@ -344,6 +343,18 @@ func TestAccountIteratorTraversalValues(t *testing.T) { } db := New(rawdb.NewMemoryDatabase(), config, false) + // layerAccount returns a valid slim-RLP account whose nonce uniquely encodes + // the (layer, key) pair, so that values are distinct per layer and the + // iterator's "newest layer wins" behavior is exercised. + layerAccount := func(layer, key byte) []byte { + return types.SlimAccountRLP(types.StateAccount{ + Nonce: uint64(layer)<<8 | uint64(key), + Balance: uint256.NewInt(uint64(key)), + Root: types.EmptyRootHash, + CodeHash: types.EmptyCodeHash[:], + }) + } + // Create a batch of account sets to seed subsequent layers with var ( a = make(map[common.Hash][]byte) @@ -356,27 +367,27 @@ func TestAccountIteratorTraversalValues(t *testing.T) { h = make(map[common.Hash][]byte) ) for i := byte(2); i < 0xff; i++ { - a[common.Hash{i}] = fmt.Appendf(nil, "layer-%d, key %d", 0, i) + a[common.Hash{i}] = layerAccount(0, i) if i > 20 && i%2 == 0 { - b[common.Hash{i}] = fmt.Appendf(nil, "layer-%d, key %d", 1, i) + b[common.Hash{i}] = layerAccount(1, i) } if i%4 == 0 { - c[common.Hash{i}] = fmt.Appendf(nil, "layer-%d, key %d", 2, i) + c[common.Hash{i}] = layerAccount(2, i) } if i%7 == 0 { - d[common.Hash{i}] = fmt.Appendf(nil, "layer-%d, key %d", 3, i) + d[common.Hash{i}] = layerAccount(3, i) } if i%8 == 0 { - e[common.Hash{i}] = fmt.Appendf(nil, "layer-%d, key %d", 4, i) + e[common.Hash{i}] = layerAccount(4, i) } if i > 50 && i < 85 { - f[common.Hash{i}] = fmt.Appendf(nil, "layer-%d, key %d", 5, i) + f[common.Hash{i}] = layerAccount(5, i) } if i%64 == 0 { - g[common.Hash{i}] = fmt.Appendf(nil, "layer-%d, key %d", 6, i) + g[common.Hash{i}] = layerAccount(6, i) } if i%128 == 0 { - h[common.Hash{i}] = fmt.Appendf(nil, "layer-%d, key %d", 7, i) + h[common.Hash{i}] = layerAccount(7, i) } } // Assemble a stack of snapshots from the account layers diff --git a/triedb/pathdb/reader.go b/triedb/pathdb/reader.go index e087ef26ed..9a006a2dbd 100644 --- a/triedb/pathdb/reader.go +++ b/triedb/pathdb/reader.go @@ -127,18 +127,27 @@ func (r *reader) AccountRLP(hash common.Hash) ([]byte, error) { // - the returned account object is safe to modify // - no error will be returned if the requested account is not found in database func (r *reader) Account(hash common.Hash) (*types.SlimAccount, error) { - blob, err := r.AccountRLP(hash) + l, err := r.db.tree.lookupAccount(hash, r.state) if err != nil { return nil, err } - if len(blob) == 0 { + // If the located layer is stale, fall back to the slow path to retrieve + // the account. See AccountRLP for the rationale of this fallback. + account, err := l.accountObject(hash, 0) + if errors.Is(err, errSnapshotStale) { + account, err = r.layer.accountObject(hash, 0) + } + if err != nil { + return nil, err + } + if account == nil { return nil, nil } - account := new(types.SlimAccount) - if err := rlp.DecodeBytes(blob, account); err != nil { - panic(err) - } - return account, nil + // The diff layers retain the account in decoded form and the returned + // object is documented as safe to modify, so hand back a shallow copy to + // protect the cached instance from caller mutation. + cpy := *account + return &cpy, nil } // Storage directly retrieves the storage data associated with a particular hash, diff --git a/triedb/pathdb/states.go b/triedb/pathdb/states.go index 27a6c1d422..6c79e62bfb 100644 --- a/triedb/pathdb/states.go +++ b/triedb/pathdb/states.go @@ -26,6 +26,7 @@ import ( "github.com/VictoriaMetrics/fastcache" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/core/rawdb" + "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/ethdb" "github.com/ethereum/go-ethereum/log" "github.com/ethereum/go-ethereum/metrics" @@ -60,7 +61,7 @@ func (c *counter) report(count, size *metrics.Meter) { // subsequent state access will be denied due to the stale flag. Therefore, // state access and mutation won't happen at the same time with guarantee. type stateSet struct { - accountData map[common.Hash][]byte // Keyed accounts for direct retrieval (nil means deleted) + accountData map[common.Hash]*types.SlimAccount // Keyed accounts for direct retrieval (nil means deleted) storageData map[common.Hash]map[common.Hash][]byte // Keyed storage slots for direct retrieval. one per account (nil means deleted) size uint64 // Memory size of the state data (accountData and storageData) @@ -75,17 +76,81 @@ type stateSet struct { listLock sync.RWMutex } +// slimAccountSize returns the approximate encoded size of a slim account, used +// for memory accounting. It estimates the slim-RLP byte length without actually +// encoding the account. A nil account (deleted marker) contributes zero. +func slimAccountSize(account *types.SlimAccount) int { + if account == nil { + return 0 + } + // 9 bytes covers the RLP list header plus the nonce; the balance, root and + // code hash contribute their own byte lengths. This intentionally mirrors + // the previous behavior of accounting for the encoded blob length. + size := 9 + if account.Balance != nil { + size += account.Balance.ByteLen() + } + size += len(account.Root) + size += len(account.CodeHash) + return size +} + +// decodeAccountBlob decodes a single slim-RLP-encoded account blob. A nil/empty +// blob denotes a non-existent or deleted account and decodes to a nil account. +func decodeAccountBlob(hash common.Hash, blob []byte) *types.SlimAccount { + if len(blob) == 0 { + return nil + } + account := new(types.SlimAccount) + if err := rlp.DecodeBytes(blob, account); err != nil { + panic(fmt.Sprintf("failed to decode account %x: %v", hash, err)) + } + return account +} + +// decodeAccounts converts a map of slim-RLP-encoded accounts into decoded slim +// account objects. A nil/empty blob denotes a deleted account and is preserved +// as a nil entry in the resulting map. +func decodeAccounts(accounts map[common.Hash][]byte) map[common.Hash]*types.SlimAccount { + decoded := make(map[common.Hash]*types.SlimAccount, len(accounts)) + for hash, blob := range accounts { + if len(blob) == 0 { + decoded[hash] = nil // deleted account + continue + } + account := new(types.SlimAccount) + if err := rlp.DecodeBytes(blob, account); err != nil { + panic(fmt.Sprintf("failed to decode account %x: %v", hash, err)) + } + decoded[hash] = account + } + return decoded +} + +// encodeSlimAccount serializes a decoded slim account back into its slim-RLP +// byte form. A nil account (deleted marker) encodes to a nil blob. +func encodeSlimAccount(account *types.SlimAccount) []byte { + if account == nil { + return nil + } + blob, err := rlp.EncodeToBytes(account) + if err != nil { + panic(fmt.Sprintf("failed to encode account: %v", err)) + } + return blob +} + // newStates constructs the state set with the provided account and storage data. +// The accounts are supplied in slim-RLP form and are decoded once on ingest; +// they are kept in decoded form for the lifetime of the set and only re-encoded +// at the serialization boundaries (journal and disk flush). func newStates(accounts map[common.Hash][]byte, storages map[common.Hash]map[common.Hash][]byte, rawStorageKey bool) *stateSet { // Don't panic for the lazy callers, initialize the nil maps instead. - if accounts == nil { - accounts = make(map[common.Hash][]byte) - } if storages == nil { storages = make(map[common.Hash]map[common.Hash][]byte) } s := &stateSet{ - accountData: accounts, + accountData: decodeAccounts(accounts), storageData: storages, rawStorageKey: rawStorageKey, storageListSorted: make(map[common.Hash][]common.Hash), @@ -95,7 +160,9 @@ func newStates(accounts map[common.Hash][]byte, storages map[common.Hash]map[com } // account returns the account data associated with the specified address hash. -func (s *stateSet) account(hash common.Hash) ([]byte, bool) { +// A nil account with found=true denotes a known-deleted account, whereas +// found=false indicates the account is unknown in this set. +func (s *stateSet) account(hash common.Hash) (*types.SlimAccount, bool) { // If the account is known locally, return it if data, ok := s.accountData[hash]; ok { return data, true @@ -106,7 +173,7 @@ func (s *stateSet) account(hash common.Hash) ([]byte, bool) { // mustAccount returns the account data associated with the specified address // hash. The difference is this function will return an error if the account // is not found. -func (s *stateSet) mustAccount(hash common.Hash) ([]byte, error) { +func (s *stateSet) mustAccount(hash common.Hash) (*types.SlimAccount, error) { // If the account is known locally, return it if data, ok := s.accountData[hash]; ok { return data, nil @@ -143,8 +210,8 @@ func (s *stateSet) mustStorage(accountHash, storageHash common.Hash) ([]byte, er // Additionally, it computes the total memory size occupied by the maps. func (s *stateSet) check() uint64 { var size int - for _, blob := range s.accountData { - size += common.HashLength + len(blob) + for _, account := range s.accountData { + size += common.HashLength + slimAccountSize(account) } for accountHash, slots := range s.storageData { if slots == nil { @@ -235,11 +302,13 @@ func (s *stateSet) merge(other *stateSet) { ) // Apply the updated account data for accountHash, data := range other.accountData { + dataSize := slimAccountSize(data) if origin, ok := s.accountData[accountHash]; ok { - delta += len(data) - len(origin) - accountOverwrites.add(common.HashLength + len(origin)) + originSize := slimAccountSize(origin) + delta += dataSize - originSize + accountOverwrites.add(common.HashLength + originSize) } else { - delta += common.HashLength + len(data) + delta += common.HashLength + dataSize } s.accountData[accountHash] = data } @@ -293,11 +362,20 @@ func (s *stateSet) revertTo(accountOrigin map[common.Hash][]byte, storageOrigin if !ok { panic(fmt.Sprintf("non-existent account for reverting, %x", addrHash)) } - if len(data) == 0 && len(blob) == 0 { + // Decode the original slim-RLP value; an empty blob denotes a deleted + // account, represented as a nil entry. + var origin *types.SlimAccount + if len(blob) != 0 { + origin = new(types.SlimAccount) + if err := rlp.DecodeBytes(blob, origin); err != nil { + panic(fmt.Sprintf("failed to decode account %x: %v", addrHash, err)) + } + } + if data == nil && origin == nil { panic(fmt.Sprintf("invalid account mutation (null to null), %x", addrHash)) } - delta += len(blob) - len(data) - s.accountData[addrHash] = blob + delta += slimAccountSize(origin) - slimAccountSize(data) + s.accountData[addrHash] = origin } // Overwrite the storage data with original value blindly for addrHash, storage := range storageOrigin { @@ -346,9 +424,9 @@ func (s *stateSet) encode(w io.Writer) error { AddrHashes: make([]common.Hash, 0, len(s.accountData)), Accounts: make([][]byte, 0, len(s.accountData)), } - for addrHash, blob := range s.accountData { + for addrHash, account := range s.accountData { enc.AddrHashes = append(enc.AddrHashes, addrHash) - enc.Accounts = append(enc.Accounts, blob) + enc.Accounts = append(enc.Accounts, encodeSlimAccount(account)) } if err := rlp.Encode(w, enc); err != nil { return err @@ -395,7 +473,7 @@ func (s *stateSet) decode(r *rlp.Stream) error { for i := range dec.AddrHashes { accountSet[dec.AddrHashes[i]] = empty2nil(dec.Accounts[i]) } - s.accountData = accountSet + s.accountData = decodeAccounts(accountSet) // Decode storages type storage struct { @@ -431,7 +509,7 @@ func (s *stateSet) write(batch ethdb.Batch, genMarker []byte, clean *fastcache.C // reset clears all cached state data, including any optional sorted lists that // may have been generated. func (s *stateSet) reset() { - s.accountData = make(map[common.Hash][]byte) + s.accountData = make(map[common.Hash]*types.SlimAccount) s.storageData = make(map[common.Hash]map[common.Hash][]byte) s.size = 0 s.accountListSorted = nil diff --git a/triedb/pathdb/states_test.go b/triedb/pathdb/states_test.go index 4d181cc914..ff1a1078e0 100644 --- a/triedb/pathdb/states_test.go +++ b/triedb/pathdb/states_test.go @@ -22,15 +22,39 @@ import ( "testing" "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/internal/testrand" "github.com/ethereum/go-ethereum/rlp" + "github.com/holiman/uint256" ) +// testAccountRLP returns the slim-RLP encoding of a deterministic account whose +// nonce is derived from the provided tag. It lets the state-set tests use real, +// decodable accounts while keeping the tag values readable. +func testAccountRLP(tag byte) []byte { + return types.SlimAccountRLP(types.StateAccount{ + Nonce: uint64(tag), + Balance: uint256.NewInt(uint64(tag)), + Root: types.EmptyRootHash, + CodeHash: types.EmptyCodeHash[:], + }) +} + +// accountEqualsTag reports whether the decoded account matches the account +// produced by testAccountRLP for the given tag. +func accountEqualsTag(account *types.SlimAccount, tag byte) bool { + if account == nil { + return false + } + return bytes.Equal(encodeSlimAccount(account), testAccountRLP(tag)) +} + func TestStatesMerge(t *testing.T) { a := newStates( map[common.Hash][]byte{ - {0xa}: {0xa0}, - {0xb}: {0xb0}, - {0xc}: {0xc0}, + {0xa}: testAccountRLP(0xa0), + {0xb}: testAccountRLP(0xb0), + {0xc}: testAccountRLP(0xc0), }, map[common.Hash]map[common.Hash][]byte{ {0xa}: { @@ -48,8 +72,8 @@ func TestStatesMerge(t *testing.T) { ) b := newStates( map[common.Hash][]byte{ - {0xa}: {0xa1}, - {0xb}: {0xb1}, + {0xa}: testAccountRLP(0xa1), + {0xb}: testAccountRLP(0xb1), {0xc}: nil, // delete account }, map[common.Hash]map[common.Hash][]byte{ @@ -69,25 +93,25 @@ func TestStatesMerge(t *testing.T) { ) a.merge(b) - blob, exist := a.account(common.Hash{0xa}) - if !exist || !bytes.Equal(blob, []byte{0xa1}) { + account, exist := a.account(common.Hash{0xa}) + if !exist || !accountEqualsTag(account, 0xa1) { t.Error("Unexpected value for account a") } - blob, exist = a.account(common.Hash{0xb}) - if !exist || !bytes.Equal(blob, []byte{0xb1}) { + account, exist = a.account(common.Hash{0xb}) + if !exist || !accountEqualsTag(account, 0xb1) { t.Error("Unexpected value for account b") } - blob, exist = a.account(common.Hash{0xc}) - if !exist || len(blob) != 0 { + account, exist = a.account(common.Hash{0xc}) + if !exist || account != nil { t.Error("Unexpected value for account c") } // unknown account - blob, exist = a.account(common.Hash{0xd}) - if exist || len(blob) != 0 { + account, exist = a.account(common.Hash{0xd}) + if exist || account != nil { t.Error("Unexpected value for account d") } - blob, exist = a.storage(common.Hash{0xa}, common.Hash{0x1}) + blob, exist := a.storage(common.Hash{0xa}, common.Hash{0x1}) if !exist || !bytes.Equal(blob, []byte{0x11}) { t.Error("Unexpected value for a's storage") } @@ -118,9 +142,9 @@ func TestStatesMerge(t *testing.T) { func TestStatesRevert(t *testing.T) { a := newStates( map[common.Hash][]byte{ - {0xa}: {0xa0}, - {0xb}: {0xb0}, - {0xc}: {0xc0}, + {0xa}: testAccountRLP(0xa0), + {0xb}: testAccountRLP(0xb0), + {0xc}: testAccountRLP(0xc0), }, map[common.Hash]map[common.Hash][]byte{ {0xa}: { @@ -138,8 +162,8 @@ func TestStatesRevert(t *testing.T) { ) b := newStates( map[common.Hash][]byte{ - {0xa}: {0xa1}, - {0xb}: {0xb1}, + {0xa}: testAccountRLP(0xa1), + {0xb}: testAccountRLP(0xb1), {0xc}: nil, }, map[common.Hash]map[common.Hash][]byte{ @@ -160,9 +184,9 @@ func TestStatesRevert(t *testing.T) { a.merge(b) a.revertTo( map[common.Hash][]byte{ - {0xa}: {0xa0}, - {0xb}: {0xb0}, - {0xc}: {0xc0}, + {0xa}: testAccountRLP(0xa0), + {0xb}: testAccountRLP(0xb0), + {0xc}: testAccountRLP(0xc0), }, map[common.Hash]map[common.Hash][]byte{ {0xa}: { @@ -179,25 +203,25 @@ func TestStatesRevert(t *testing.T) { }, ) - blob, exist := a.account(common.Hash{0xa}) - if !exist || !bytes.Equal(blob, []byte{0xa0}) { + account, exist := a.account(common.Hash{0xa}) + if !exist || !accountEqualsTag(account, 0xa0) { t.Error("Unexpected value for account a") } - blob, exist = a.account(common.Hash{0xb}) - if !exist || !bytes.Equal(blob, []byte{0xb0}) { + account, exist = a.account(common.Hash{0xb}) + if !exist || !accountEqualsTag(account, 0xb0) { t.Error("Unexpected value for account b") } - blob, exist = a.account(common.Hash{0xc}) - if !exist || !bytes.Equal(blob, []byte{0xc0}) { + account, exist = a.account(common.Hash{0xc}) + if !exist || !accountEqualsTag(account, 0xc0) { t.Error("Unexpected value for account c") } // unknown account - blob, exist = a.account(common.Hash{0xd}) - if exist || len(blob) != 0 { + account, exist = a.account(common.Hash{0xd}) + if exist || account != nil { t.Error("Unexpected value for account d") } - blob, exist = a.storage(common.Hash{0xa}, common.Hash{0x1}) + blob, exist := a.storage(common.Hash{0xa}, common.Hash{0x1}) if !exist || !bytes.Equal(blob, []byte{0x10}) { t.Error("Unexpected value for a's storage") } @@ -231,7 +255,7 @@ func TestStateRevertAccountNullMarker(t *testing.T) { a := newStates(nil, nil, false) // empty initial state b := newStates( map[common.Hash][]byte{ - {0xa}: {0xa}, + {0xa}: testAccountRLP(0xa), }, nil, false, @@ -244,12 +268,12 @@ func TestStateRevertAccountNullMarker(t *testing.T) { nil, ) // revert the transition b - blob, exist := a.account(common.Hash{0xa}) + account, exist := a.account(common.Hash{0xa}) if !exist { t.Fatal("null marker is not found") } - if len(blob) != 0 { - t.Fatalf("Unexpected value for account, %v", blob) + if account != nil { + t.Fatalf("Unexpected value for account, %v", account) } } @@ -258,7 +282,7 @@ func TestStateRevertAccountNullMarker(t *testing.T) { // entry in the set. func TestStateRevertStorageNullMarker(t *testing.T) { a := newStates(map[common.Hash][]byte{ - {0xa}: {0xa}, + {0xa}: testAccountRLP(0xa), }, nil, false) // initial state with account 0xa b := newStates( @@ -297,7 +321,7 @@ func TestStatesEncode(t *testing.T) { func testStatesEncode(t *testing.T, rawStorageKey bool) { s := newStates( map[common.Hash][]byte{ - {0x1}: {0x1}, + {0x1}: testAccountRLP(0x1), }, map[common.Hash]map[common.Hash][]byte{ {0x1}: { @@ -333,7 +357,7 @@ func TestStateWithOriginEncode(t *testing.T) { func testStateWithOriginEncode(t *testing.T, rawStorageKey bool) { s := NewStateSetWithOrigin( map[common.Hash][]byte{ - {0x1}: {0x1}, + {0x1}: testAccountRLP(0x1), }, map[common.Hash]map[common.Hash][]byte{ {0x1}: { @@ -375,17 +399,53 @@ func testStateWithOriginEncode(t *testing.T, rawStorageKey bool) { } } +// TestSlimAccountSize verifies the estimated memory size of a slim account for +// the empty/deleted, EOA and contract cases. The accountData map keys are +// accounted for separately (common.HashLength per entry), so this only covers +// the value contribution. +func TestSlimAccountSize(t *testing.T) { + // A nil account (deletion marker) contributes nothing. + if got := slimAccountSize(nil); got != 0 { + t.Fatalf("nil account size, want: 0, got: %d", got) + } + // An EOA with no code and empty storage root carries nil Root/CodeHash in + // slim form, so only the 9-byte list/nonce overhead plus the balance length + // is counted. + eoa := &types.SlimAccount{Nonce: 1, Balance: uint256.NewInt(0x100)} + if got, want := slimAccountSize(eoa), 9+eoa.Balance.ByteLen(); got != want { + t.Fatalf("EOA account size, want: %d, got: %d", want, got) + } + // A contract carries a 32-byte root and 32-byte code hash in addition. + contract := &types.SlimAccount{ + Nonce: 2, + Balance: uint256.NewInt(0x10000), + Root: testrand.Bytes(32), + CodeHash: testrand.Bytes(32), + } + if got, want := slimAccountSize(contract), 9+contract.Balance.ByteLen()+64; got != want { + t.Fatalf("contract account size, want: %d, got: %d", want, got) + } +} + +// TestStateSizeTracking verifies the storage-slot memory accounting through the +// merge and revert transitions. Accounts are all identical real accounts, so +// their size contribution is a constant (acctSize per entry) and the assertions +// focus on the storage deltas. func TestStateSizeTracking(t *testing.T) { - expSizeA := 3*(common.HashLength+1) + /* account data */ + // All accounts are identical, so each contributes the same estimated size. + acct := decodeAccountBlob(common.Hash{}, testAccountRLP(0x1)) + acctSize := common.HashLength + slimAccountSize(acct) + + expSizeA := 3*acctSize + /* account data: a, b, c */ 2*(2*common.HashLength+1) + /* storage data of 0xa */ 2*common.HashLength + 3 + /* storage data of 0xb */ 2*common.HashLength + 1 /* storage data of 0xc */ a := newStates( map[common.Hash][]byte{ - {0xa}: {0xa0}, // common.HashLength+1 - {0xb}: {0xb0}, // common.HashLength+1 - {0xc}: {0xc0}, // common.HashLength+1 + {0xa}: testAccountRLP(0x1), + {0xb}: testAccountRLP(0x1), + {0xc}: testAccountRLP(0x1), }, map[common.Hash]map[common.Hash][]byte{ {0xa}: { @@ -405,15 +465,15 @@ func TestStateSizeTracking(t *testing.T) { t.Fatalf("Unexpected size, want: %d, got: %d", expSizeA, a.size) } - expSizeB := common.HashLength + 2 + common.HashLength + 3 + common.HashLength + /* account data */ + expSizeB := 2*acctSize + common.HashLength + /* account data: a, b updated; c deleted (key only) */ 2*common.HashLength + 3 + 2*common.HashLength + 2 + /* storage data of 0xa */ 2*common.HashLength + 2 + 2*common.HashLength + 2 + /* storage data of 0xb */ 3*2*common.HashLength /* storage data of 0xc */ b := newStates( map[common.Hash][]byte{ - {0xa}: {0xa1, 0xa1}, // common.HashLength+2 - {0xb}: {0xb1, 0xb1, 0xb1}, // common.HashLength+3 - {0xc}: nil, // common.HashLength, account deletion + {0xa}: testAccountRLP(0x1), // account update (same size) + {0xb}: testAccountRLP(0x1), // account update (same size) + {0xc}: nil, // common.HashLength, account deletion }, map[common.Hash]map[common.Hash][]byte{ {0xa}: { @@ -438,10 +498,13 @@ func TestStateSizeTracking(t *testing.T) { } a.merge(b) - mergeSize := expSizeA + 1 /* account a data change */ + 2 /* account b data change */ - 1 /* account c data change */ - mergeSize += 2*common.HashLength + 2 + 2 /* storage a change */ - mergeSize += 2*common.HashLength + 2 - 1 /* storage b change */ - mergeSize += 2*2*common.HashLength - 1 /* storage data removal of 0xc */ + // Accounts a and b are overwritten with same-size accounts (no delta), but + // account c becomes a deletion marker, dropping its value contribution while + // retaining the key. + mergeSize := expSizeA - slimAccountSize(acct) /* account c value removed */ + mergeSize += 2*common.HashLength + 2 + 2 /* storage a change */ + mergeSize += 2*common.HashLength + 2 - 1 /* storage b change */ + mergeSize += 2*2*common.HashLength - 1 /* storage data removal of 0xc */ if a.size != uint64(mergeSize) { t.Fatalf("Unexpected size, want: %d, got: %d", mergeSize, a.size) @@ -450,9 +513,9 @@ func TestStateSizeTracking(t *testing.T) { // Revert the set to original status a.revertTo( map[common.Hash][]byte{ - {0xa}: {0xa0}, - {0xb}: {0xb0}, - {0xc}: {0xc0}, + {0xa}: testAccountRLP(0x1), + {0xb}: testAccountRLP(0x1), + {0xc}: testAccountRLP(0x1), }, map[common.Hash]map[common.Hash][]byte{ {0xa}: {