triedb, triedb/pathdb: add ordinal access and lookup primitives for history index

Read-side primitives over the per-account state-history index, needed by
the upcoming eth_getTransactionBySenderAndNonce RPC (see execution-apis
spec discussion):

  - HistoryIndexReader with ordinal Count/At access, backed by
    indexReader.count/at and blockReader.readAt which walk the
    restart-encoded index blocks by position.
  - AccountHistoryIndex, HistoricAccount, BlockNumberAt and
    LastIndexedBlockNumber on *pathdb.Database, with
    ErrStateHistoryNotIndexed and ErrStateHistoryPruned sentinels.
  - PathDB accessor on *triedb.Database for typed backend access.

Adapted to the grouped freezer API (Tail now takes a history group).
This commit is contained in:
Sina Mahmoodi 2026-06-09 20:51:16 +00:00
parent edb398a102
commit ee67a19018
6 changed files with 387 additions and 0 deletions

View file

@ -142,6 +142,13 @@ func (db *Database) HistoricStateReader(root common.Hash) (*pathdb.HistoricalSta
return pdb.HistoricReader(root)
}
// PathDB returns the underlying pathdb-backed database if this database is
// using the path-based scheme. Returns nil for hashdb or other backends.
func (db *Database) PathDB() *pathdb.Database {
pdb, _ := db.backend.(*pathdb.Database)
return pdb
}
// HistoricNodeReader constructs a reader for accessing the historical trie node.
func (db *Database) HistoricNodeReader(root common.Hash) (*pathdb.HistoricalNodeReader, error) {
pdb, ok := db.backend.(*pathdb.Database)

View file

@ -124,6 +124,37 @@ func (r *indexReader) refresh() error {
return nil
}
// count returns the total number of indexed entries across all descriptor
// blocks.
func (r *indexReader) count() int {
var n int
for _, d := range r.descList {
n += int(d.entries)
}
return n
}
// at returns the i-th indexed history id in ascending order. The caller must
// guarantee 0 <= i < r.count().
func (r *indexReader) at(i int) (uint64, error) {
if i < 0 {
return 0, fmt.Errorf("negative ordinal %d", i)
}
remaining := i
for _, desc := range r.descList {
n := int(desc.entries)
if remaining < n {
br, err := r.blockReader(desc.id)
if err != nil {
return 0, err
}
return br.readAt(remaining, n)
}
remaining -= n
}
return 0, fmt.Errorf("ordinal out of range, i: %d, total: %d", i, r.count())
}
// blockReader returns a cached block reader for the given block id, loading it
// from disk on first access.
func (r *indexReader) blockReader(id uint32) (*blockReader, error) {

View file

@ -194,6 +194,53 @@ func newBlockReader(blob []byte, hasExt bool) (*blockReader, error) {
}, nil
}
// readAt returns the element at the given block-local ordinal position.
// The caller must guarantee 0 <= i < entries; entries is the number of
// elements stored in the block as recorded in its descriptor.
func (br *blockReader) readAt(i int, entries int) (uint64, error) {
if i < 0 || i >= entries {
return 0, fmt.Errorf("ordinal out of range, i: %d, entries: %d", i, entries)
}
section := i / indexBlockRestartLen
if section >= len(br.restarts) {
return 0, fmt.Errorf("invalid restart section %d, restarts: %d", section, len(br.restarts))
}
var (
pos = int(br.restarts[section])
limit int
skip = i % indexBlockRestartLen
val uint64
)
if section == len(br.restarts)-1 {
limit = len(br.data)
} else {
limit = int(br.restarts[section+1])
}
for step := 0; step <= skip; step++ {
if pos >= limit {
return 0, fmt.Errorf("ordinal walk past section end, pos: %d, limit: %d, step: %d", pos, limit, step)
}
x, n := binary.Uvarint(br.data[pos:])
if n <= 0 {
return 0, fmt.Errorf("failed to decode element at pos %d", pos)
}
if step == 0 {
val = x
} else {
val += x
}
pos += n
if br.hasExt {
length, ln := binary.Uvarint(br.data[pos:])
if ln <= 0 {
return 0, fmt.Errorf("failed to decode extension length at pos %d", pos)
}
pos += ln + int(length)
}
}
return val, nil
}
// readGreaterThan locates the first element in the block that is greater than
// the specified value. If no such element is found, MaxUint64 is returned.
func (br *blockReader) readGreaterThan(id uint64) (uint64, error) {

View file

@ -208,6 +208,52 @@ func testIndexWriterWithLimit(t *testing.T, bitmapSize int) {
}
}
func TestIndexReaderOrdinalAccess(t *testing.T) {
testIndexReaderOrdinalAccess(t, 0)
testIndexReaderOrdinalAccess(t, 2)
testIndexReaderOrdinalAccess(t, 34)
}
func testIndexReaderOrdinalAccess(t *testing.T, bitmapSize int) {
// Build a sequence long enough to span multiple restart sections and
// index blocks, with varying gaps to exercise the delta decoding.
var (
elements []uint64
id uint64
)
for i := 0; i < 10_000; i++ {
id += uint64(i%7) + 1
elements = append(elements, id)
}
db := rawdb.NewMemoryDatabase()
iw, _ := newIndexWriter(db, newAccountIdent(common.Hash{0xa}), 0, bitmapSize)
for _, v := range elements {
if err := iw.append(v, randomExt(bitmapSize, 5)); err != nil {
t.Fatalf("Failed to append %d: %v", v, err)
}
}
batch := db.NewBatch()
iw.finish(batch)
batch.Write()
r, err := newIndexReader(db, newAccountIdent(common.Hash{0xa}), bitmapSize)
if err != nil {
t.Fatalf("Failed to construct index reader: %v", err)
}
if got, want := r.count(), len(elements); got != want {
t.Fatalf("count mismatch: got %d, want %d", got, want)
}
for i, want := range elements {
got, err := r.at(i)
if err != nil {
t.Fatalf("at(%d) returned error: %v", i, err)
}
if got != want {
t.Fatalf("at(%d) = %d, want %d", i, got, want)
}
}
}
func TestIndexDeleterBasic(t *testing.T) {
testIndexDeleterBasic(t, 0)
testIndexDeleterBasic(t, 2)

View file

@ -0,0 +1,156 @@
// 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 <http://www.gnu.org/licenses/>.
package pathdb
import (
"errors"
"fmt"
"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/rlp"
)
// ErrStateHistoryNotIndexed is returned when the state history index is not
// available on this database, either because the indexer is disabled, the
// freezer is not configured, or the initial build has not yet finished.
var ErrStateHistoryNotIndexed = errors.New("state history is not indexed")
// ErrStateHistoryPruned is returned when the requested state history id has
// been pruned from the freezer tail.
var ErrStateHistoryPruned = errors.New("state history has been pruned")
// HistoryIndexReader provides random-access reads over the sorted list of
// state-history ids at which a single state element was modified. The
// sequence is strictly increasing.
type HistoryIndexReader interface {
// Count returns the total number of indexed modifications.
Count() int
// At returns the i-th history id. The caller must ensure 0 <= i < Count().
At(i int) (uint64, error)
}
// accountHistoryIndexReader exposes ordinal access over an account's history
// index. Not safe for concurrent use.
type accountHistoryIndexReader struct {
reader *indexReader
}
// Count implements HistoryIndexReader.
func (r *accountHistoryIndexReader) Count() int {
return r.reader.count()
}
// At implements HistoryIndexReader.
func (r *accountHistoryIndexReader) At(i int) (uint64, error) {
return r.reader.at(i)
}
// AccountHistoryIndex returns a random-access reader over the state-history
// ids at which the given account was modified, or ErrStateHistoryNotIndexed
// if the index is unavailable. The returned reader is not safe for
// concurrent use.
func (db *Database) AccountHistoryIndex(addr common.Address) (HistoryIndexReader, error) {
if err := db.checkStateIndexerReady(); err != nil {
return nil, err
}
ident := newAccountIdent(crypto.Keccak256Hash(addr.Bytes()))
r, err := newIndexReader(db.diskdb, ident, 0)
if err != nil {
return nil, err
}
return &accountHistoryIndexReader{reader: r}, nil
}
// LastIndexedBlockNumber returns the block number of the most recently
// indexed state history; blocks above it are not yet covered by the index.
// Returns 0 with nil error if the indexer is ready but has produced no
// entries yet.
func (db *Database) LastIndexedBlockNumber() (uint64, error) {
if err := db.checkStateIndexerReady(); err != nil {
return 0, err
}
metadata := loadIndexMetadata(db.diskdb, typeStateHistory)
if metadata == nil || metadata.Last == 0 {
return 0, nil
}
m, err := readStateHistoryMeta(db.stateFreezer, metadata.Last)
if err != nil {
return 0, err
}
return m.block, nil
}
// BlockNumberAt returns the block number associated with the given
// state-history id. Returns ErrStateHistoryPruned if the id falls at or below
// the current freezer tail.
func (db *Database) BlockNumberAt(historyID uint64) (uint64, error) {
if db.stateFreezer == nil {
return 0, ErrStateHistoryNotIndexed
}
tail, err := db.stateFreezer.Tail(rawdb.DefaultHistoryGroup)
if err != nil {
return 0, err
}
if historyID <= tail {
return 0, ErrStateHistoryPruned
}
m, err := readStateHistoryMeta(db.stateFreezer, historyID)
if err != nil {
return 0, err
}
return m.block, nil
}
// HistoricAccount returns the pre-state of addr at the given history id,
// i.e., the account state at the start of the block recorded by that
// history. Returns nil if the account did not exist at that point. The id
// must correspond to an entry where addr was actually modified (typically
// one drawn from AccountHistoryIndex(addr)).
func (db *Database) HistoricAccount(addr common.Address, historyID uint64) (*types.SlimAccount, error) {
if err := db.checkStateIndexerReady(); err != nil {
return nil, err
}
r := newStateHistoryReader(db.diskdb, db.stateFreezer)
blob, err := r.readAccount(addr, historyID)
if err != nil {
return nil, err
}
if len(blob) == 0 {
return nil, nil
}
account := new(types.SlimAccount)
if err := rlp.DecodeBytes(blob, account); err != nil {
return nil, fmt.Errorf("failed to decode account: %w", err)
}
return account, nil
}
// checkStateIndexerReady returns nil if the state-history indexer is
// available and the initial build has completed.
func (db *Database) checkStateIndexerReady() error {
if db.stateIndexer == nil || db.stateFreezer == nil {
return ErrStateHistoryNotIndexed
}
if !db.stateIndexer.inited() {
return ErrStateHistoryNotIndexed
}
return nil
}

View file

@ -0,0 +1,100 @@
// 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 <http://www.gnu.org/licenses/>.
package pathdb
import (
"errors"
"testing"
"github.com/ethereum/go-ethereum/common"
)
func TestAccountHistoryIndex_NotIndexed(t *testing.T) {
// Indexing disabled: every accessor should bail with ErrStateHistoryNotIndexed.
env := newTester(t, &testerConfig{layers: 4, enableIndex: false})
defer env.release()
var addr common.Address
for h := range env.accounts {
addr = env.accountPreimage(h)
break
}
if _, err := env.db.AccountHistoryIndex(addr); !errors.Is(err, ErrStateHistoryNotIndexed) {
t.Fatalf("AccountHistoryIndex: want ErrStateHistoryNotIndexed, got %v", err)
}
if _, err := env.db.HistoricAccount(addr, 1); !errors.Is(err, ErrStateHistoryNotIndexed) {
t.Fatalf("HistoricAccount: want ErrStateHistoryNotIndexed, got %v", err)
}
}
func TestAccountHistoryIndex_Indexed(t *testing.T) {
// Force diff-layer flushes so state history is actually written.
maxDiffLayers = 4
defer func() { maxDiffLayers = 128 }()
env := newTester(t, &testerConfig{layers: 32, enableIndex: true})
defer env.release()
waitIndexing(env.db)
// Pick any account with at least one indexed entry.
var (
addr common.Address
idx HistoryIndexReader
)
for h := range env.accounts {
a := env.accountPreimage(h)
r, err := env.db.AccountHistoryIndex(a)
if err != nil {
t.Fatalf("AccountHistoryIndex(%x): %v", a, err)
}
if r.Count() > 0 {
addr = a
idx = r
break
}
}
if idx == nil {
t.Fatal("no indexed account found across all current-state accounts")
}
// Every id reported by the index must yield a HistoricAccount read.
for i := 0; i < idx.Count(); i++ {
hid, err := idx.At(i)
if err != nil {
t.Fatalf("idx.At(%d): %v", i, err)
}
if _, err := env.db.HistoricAccount(addr, hid); err != nil {
t.Fatalf("HistoricAccount(addr, %d): %v", hid, err)
}
blockNum, err := env.db.BlockNumberAt(hid)
if err != nil {
t.Fatalf("BlockNumberAt(%d): %v", hid, err)
}
if blockNum >= uint64(len(env.roots)) {
t.Fatalf("BlockNumberAt(%d) = %d, beyond generated range %d", hid, blockNum, len(env.roots))
}
}
last, err := env.db.LastIndexedBlockNumber()
if err != nil {
t.Fatalf("LastIndexedBlockNumber: %v", err)
}
if last == 0 {
t.Fatal("LastIndexedBlockNumber returned 0 after waitIndexing")
}
}