From 07b0cae78174f5408c3d323d9dddc82afd1443cd Mon Sep 17 00:00:00 2001 From: Jeremy Wei Date: Mon, 30 Mar 2026 13:54:04 -0400 Subject: [PATCH 01/28] hit ledger cache first then duckdb --- .../ledger_db/receipt/cached_receipt_store.go | 26 ++-- .../receipt/cached_receipt_store_test.go | 125 ++++++++++++++---- sei-db/ledger_db/receipt/receipt_cache.go | 29 +++- .../ledger_db/receipt/receipt_store_test.go | 8 +- 4 files changed, 149 insertions(+), 39 deletions(-) diff --git a/sei-db/ledger_db/receipt/cached_receipt_store.go b/sei-db/ledger_db/receipt/cached_receipt_store.go index 64a2994aa5..ed524e9127 100644 --- a/sei-db/ledger_db/receipt/cached_receipt_store.go +++ b/sei-db/ledger_db/receipt/cached_receipt_store.go @@ -86,18 +86,28 @@ func (s *cachedReceiptStore) SetReceipts(ctx sdk.Context, receipts []ReceiptReco } // FilterLogs queries logs across a range of blocks. -// Checks the cache first for recent blocks, then delegates to the backend. +// When the cache fully covers the requested range the backend is skipped +// entirely, avoiding an unnecessary DuckDB/parquet query for recent blocks. func (s *cachedReceiptStore) FilterLogs(ctx sdk.Context, fromBlock, toBlock uint64, crit filters.FilterCriteria) ([]*ethtypes.Log, error) { - // First get logs from backend (parquet closed files) - backendLogs, err := s.backend.FilterLogs(ctx, fromBlock, toBlock, crit) + cacheLogs := s.cache.FilterLogs(fromBlock, toBlock, crit) + + cacheMin := s.cache.LogMinBlock() + if cacheMin > 0 && fromBlock >= cacheMin { + sortLogs(cacheLogs) + return cacheLogs, nil + } + + // Narrow the backend query to only the block range not covered by cache. + backendTo := toBlock + if cacheMin > 0 && cacheMin <= toBlock && cacheMin > fromBlock { + backendTo = cacheMin - 1 + } + + backendLogs, err := s.backend.FilterLogs(ctx, fromBlock, backendTo, crit) if err != nil { return nil, err } - // Then check cache for blocks that might not be in closed files yet - cacheLogs := s.cache.FilterLogs(fromBlock, toBlock, crit) - - // Merge results, avoiding duplicates by tracking seen (blockNum, txIndex, logIndex) if len(cacheLogs) == 0 { return backendLogs, nil } @@ -106,7 +116,6 @@ func (s *cachedReceiptStore) FilterLogs(ctx sdk.Context, fromBlock, toBlock uint return cacheLogs, nil } - // Build set of backend log keys to deduplicate type logKey struct { blockNum uint64 txIndex uint @@ -117,7 +126,6 @@ func (s *cachedReceiptStore) FilterLogs(ctx sdk.Context, fromBlock, toBlock uint seen[logKey{lg.BlockNumber, lg.TxIndex, lg.Index}] = struct{}{} } - // Add cache logs that aren't already in backend results result := backendLogs for _, lg := range cacheLogs { key := logKey{lg.BlockNumber, lg.TxIndex, lg.Index} diff --git a/sei-db/ledger_db/receipt/cached_receipt_store_test.go b/sei-db/ledger_db/receipt/cached_receipt_store_test.go index aed0e89f78..df8d527bfb 100644 --- a/sei-db/ledger_db/receipt/cached_receipt_store_test.go +++ b/sei-db/ledger_db/receipt/cached_receipt_store_test.go @@ -1,6 +1,7 @@ package receipt import ( + "math/big" "testing" "github.com/ethereum/go-ethereum/common" @@ -12,10 +13,12 @@ import ( ) type fakeReceiptBackend struct { - receipts map[common.Hash]*types.Receipt - logs []*ethtypes.Log - getReceiptCalls int - filterLogCalls int + receipts map[common.Hash]*types.Receipt + logs []*ethtypes.Log + getReceiptCalls int + filterLogCalls int + lastFilterFromBlock uint64 + lastFilterToBlock uint64 } func newFakeReceiptBackend() *fakeReceiptBackend { @@ -58,9 +61,17 @@ func (f *fakeReceiptBackend) SetReceipts(_ sdk.Context, receipts []ReceiptRecord return nil } -func (f *fakeReceiptBackend) FilterLogs(_ sdk.Context, _, _ uint64, _ filters.FilterCriteria) ([]*ethtypes.Log, error) { +func (f *fakeReceiptBackend) FilterLogs(_ sdk.Context, from, to uint64, _ filters.FilterCriteria) ([]*ethtypes.Log, error) { f.filterLogCalls++ - return append([]*ethtypes.Log(nil), f.logs...), nil + f.lastFilterFromBlock = from + f.lastFilterToBlock = to + var result []*ethtypes.Log + for _, lg := range f.logs { + if lg.BlockNumber >= from && lg.BlockNumber <= to { + result = append(result, lg) + } + } + return result, nil } func (f *fakeReceiptBackend) Close() error { @@ -86,7 +97,7 @@ func TestCachedReceiptStoreUsesCacheForReceipt(t *testing.T) { require.Equal(t, 0, backend.getReceiptCalls) } -func TestCachedReceiptStoreFilterLogsDelegates(t *testing.T) { +func TestCachedReceiptStoreFilterLogsSkipsBackendWhenCacheCovers(t *testing.T) { ctx, _ := newTestContext() backend := newFakeReceiptBackend() store := newCachedReceiptStore(backend) @@ -99,33 +110,26 @@ func TestCachedReceiptStoreFilterLogsDelegates(t *testing.T) { require.NoError(t, store.SetReceipts(ctx, []ReceiptRecord{{TxHash: txHash, Receipt: receipt}})) backend.filterLogCalls = 0 - // FilterLogs checks both backend and cache logs, err := store.FilterLogs(ctx, 9, 9, filters.FilterCriteria{ Addresses: []common.Address{addr}, Topics: [][]common.Hash{{topic}}, }) require.NoError(t, err) - require.Len(t, logs, 1) // cache has the log (backend returns empty) - require.Equal(t, 1, backend.filterLogCalls) // still delegates to backend + require.Len(t, logs, 1) + require.Equal(t, 0, backend.filterLogCalls, "backend should not be called when cache covers the range") } func TestCachedReceiptStoreFilterLogsReturnsSortedLogs(t *testing.T) { ctx, _ := newTestContext() backend := newFakeReceiptBackend() + // Backend holds older blocks that predate the cache window. backend.logs = []*ethtypes.Log{ - { - BlockNumber: 12, - TxIndex: 1, - Index: 2, - }, - { - BlockNumber: 10, - TxIndex: 0, - Index: 0, - }, + {BlockNumber: 8, TxIndex: 1, Index: 2}, + {BlockNumber: 5, TxIndex: 0, Index: 0}, } store := newCachedReceiptStore(backend) + // Cache holds recent blocks written through SetReceipts. receiptA := makeTestReceipt(common.HexToHash("0xa"), 11, 1, common.HexToAddress("0x210"), []common.Hash{common.HexToHash("0x1")}) receiptB := makeTestReceipt(common.HexToHash("0xb"), 11, 0, common.HexToAddress("0x220"), []common.Hash{common.HexToHash("0x2")}) require.NoError(t, store.SetReceipts(ctx, []ReceiptRecord{ @@ -133,13 +137,82 @@ func TestCachedReceiptStoreFilterLogsReturnsSortedLogs(t *testing.T) { {TxHash: common.HexToHash("0xb"), Receipt: receiptB}, })) - logs, err := store.FilterLogs(ctx, 10, 12, filters.FilterCriteria{}) + logs, err := store.FilterLogs(ctx, 5, 12, filters.FilterCriteria{}) require.NoError(t, err) require.Len(t, logs, 4) - require.Equal(t, uint64(10), logs[0].BlockNumber) - require.Equal(t, uint64(11), logs[1].BlockNumber) - require.Equal(t, uint(0), logs[1].TxIndex) + require.Equal(t, uint64(5), logs[0].BlockNumber) + require.Equal(t, uint64(8), logs[1].BlockNumber) require.Equal(t, uint64(11), logs[2].BlockNumber) - require.Equal(t, uint(1), logs[2].TxIndex) - require.Equal(t, uint64(12), logs[3].BlockNumber) + require.Equal(t, uint(0), logs[2].TxIndex) + require.Equal(t, uint64(11), logs[3].BlockNumber) + require.Equal(t, uint(1), logs[3].TxIndex) +} + +func TestFilterLogsPartialCacheNarrowsBackendRange(t *testing.T) { + ctx, _ := newTestContext() + backend := newFakeReceiptBackend() + backend.logs = []*ethtypes.Log{ + {BlockNumber: 5, TxIndex: 0, Index: 0}, + {BlockNumber: 8, TxIndex: 0, Index: 0}, + } + store := newCachedReceiptStore(backend) + + receipt10 := makeTestReceipt(common.HexToHash("0xa"), 10, 0, common.HexToAddress("0x1"), nil) + receipt11 := makeTestReceipt(common.HexToHash("0xb"), 11, 0, common.HexToAddress("0x2"), nil) + require.NoError(t, store.SetReceipts(ctx, []ReceiptRecord{ + {TxHash: common.HexToHash("0xa"), Receipt: receipt10}, + {TxHash: common.HexToHash("0xb"), Receipt: receipt11}, + })) + + backend.filterLogCalls = 0 + logs, err := store.FilterLogs(ctx, 5, 11, filters.FilterCriteria{}) + require.NoError(t, err) + require.Equal(t, 1, backend.filterLogCalls) + require.Equal(t, uint64(5), backend.lastFilterFromBlock) + require.Equal(t, uint64(9), backend.lastFilterToBlock, "backend toBlock should be narrowed to cacheMin-1") + + require.Len(t, logs, 4) + require.Equal(t, uint64(5), logs[0].BlockNumber) + require.Equal(t, uint64(8), logs[1].BlockNumber) + require.Equal(t, uint64(10), logs[2].BlockNumber) + require.Equal(t, uint64(11), logs[3].BlockNumber) +} + +func TestFilterLogsFallsBackToBackendWhenCacheEmpty(t *testing.T) { + ctx, _ := newTestContext() + backend := newFakeReceiptBackend() + backend.logs = []*ethtypes.Log{ + {BlockNumber: 1, TxIndex: 0, Index: 0}, + {BlockNumber: 2, TxIndex: 0, Index: 0}, + } + store := newCachedReceiptStore(backend) + + backend.filterLogCalls = 0 + logs, err := store.FilterLogs(ctx, 1, 5, filters.FilterCriteria{}) + require.NoError(t, err) + require.Equal(t, 1, backend.filterLogCalls) + require.Equal(t, uint64(1), backend.lastFilterFromBlock) + require.Equal(t, uint64(5), backend.lastFilterToBlock, "full range passed when cache is empty") + require.Len(t, logs, 2) +} + +func TestFilterLogsMultipleBlocksCacheOnly(t *testing.T) { + ctx, _ := newTestContext() + backend := newFakeReceiptBackend() + store := newCachedReceiptStore(backend) + + for block := uint64(100); block <= 105; block++ { + txHash := common.BigToHash(new(big.Int).SetUint64(block)) + r := makeTestReceipt(txHash, block, 0, common.HexToAddress("0x1"), nil) + require.NoError(t, store.SetReceipts(ctx, []ReceiptRecord{{TxHash: txHash, Receipt: r}})) + } + + backend.filterLogCalls = 0 + logs, err := store.FilterLogs(ctx, 101, 104, filters.FilterCriteria{}) + require.NoError(t, err) + require.Equal(t, 0, backend.filterLogCalls, "backend not called when entire range in cache") + require.Len(t, logs, 4) + for i, blockNum := range []uint64{101, 102, 103, 104} { + require.Equal(t, blockNum, logs[i].BlockNumber) + } } diff --git a/sei-db/ledger_db/receipt/receipt_cache.go b/sei-db/ledger_db/receipt/receipt_cache.go index 0c94ca564d..8ec0a5c5c1 100644 --- a/sei-db/ledger_db/receipt/receipt_cache.go +++ b/sei-db/ledger_db/receipt/receipt_cache.go @@ -19,7 +19,9 @@ type receiptCacheEntry struct { } type logChunk struct { - logs map[uint64][]*ethtypes.Log // blockNum -> logs + logs map[uint64][]*ethtypes.Log // blockNum -> logs + minBlock uint64 + maxBlock uint64 } type receiptChunk struct { @@ -198,6 +200,12 @@ func (c *ledgerCache) AddLogsForBlock(blockNumber uint64, logs []*ethtypes.Log) c.logChunks[slot].Store(chunk) } chunk.logs[blockNumber] = logsCopy + if chunk.minBlock == 0 || blockNumber < chunk.minBlock { + chunk.minBlock = blockNumber + } + if blockNumber > chunk.maxBlock { + chunk.maxBlock = blockNumber + } } // FilterLogs returns cached logs matching the filter criteria. @@ -226,6 +234,25 @@ func (c *ledgerCache) FilterLogs(fromBlock, toBlock uint64, crit filters.FilterC return result } +// LogMinBlock returns the lowest block number present across all non-nil log +// chunks. Returns 0 when the cache contains no log data. +func (c *ledgerCache) LogMinBlock() uint64 { + c.logMu.RLock() + defer c.logMu.RUnlock() + + var min uint64 + for i := 0; i < numCacheChunks; i++ { + chunk := c.logChunks[i].Load() + if chunk == nil || chunk.minBlock == 0 { + continue + } + if min == 0 || chunk.minBlock < min { + min = chunk.minBlock + } + } + return min +} + // matchLog checks if a log matches the filter criteria. func matchLog(lg *ethtypes.Log, crit filters.FilterCriteria) bool { // Check address filter diff --git a/sei-db/ledger_db/receipt/receipt_store_test.go b/sei-db/ledger_db/receipt/receipt_store_test.go index 7ce696e350..7b84774847 100644 --- a/sei-db/ledger_db/receipt/receipt_store_test.go +++ b/sei-db/ledger_db/receipt/receipt_store_test.go @@ -165,12 +165,14 @@ func TestReceiptStorePebbleBackendBasic(t *testing.T) { require.NoError(t, err) require.Equal(t, r.TxHashHex, got.TxHashHex) - // Pebble backend does not support range queries - _, err = store.FilterLogs(ctx, 1, 1, filters.FilterCriteria{ + // The cache covers block 1 (just written via SetReceipts), so FilterLogs + // returns the cached log without hitting the pebble backend. + logs, err := store.FilterLogs(ctx, 1, 1, filters.FilterCriteria{ Addresses: []common.Address{addr}, Topics: [][]common.Hash{{topic}}, }) - require.ErrorIs(t, err, receipt.ErrRangeQueryNotSupported) + require.NoError(t, err) + require.Len(t, logs, 1) } func TestFilterLogsRangeQueryNotSupported(t *testing.T) { From 8820251e23156efe4a6cfb64a3f7ce9fd38fbdb9 Mon Sep 17 00:00:00 2001 From: Jeremy Wei Date: Tue, 31 Mar 2026 10:30:01 -0400 Subject: [PATCH 02/28] parquet: add pebble-backed tx_hash -> block_number index Add a lightweight pebble-backed index mapping tx_hash -> block_number that narrows GetReceiptByTxHash to the single parquet file containing the target block, instead of scanning all files. Falls back to full scan for tx hashes not yet in the index. - New TxIndex type with SetBatch, GetBlockNumber, PruneBefore, Close - Store wires tx index into WriteReceipts, GetReceiptByTxHash, Close, SimulateCrash, and periodic pruning - Reader gains GetReceiptByTxHashInBlock for targeted single-file query - Unit tests for TxIndex ops and integration tests for Store/Reader --- sei-db/ledger_db/parquet/reader.go | 41 +++++++ .../ledger_db/parquet/reader_filter_test.go | 107 ++++++++++++++++++ sei-db/ledger_db/parquet/store.go | 33 ++++++ sei-db/ledger_db/parquet/tx_index.go | 103 +++++++++++++++++ sei-db/ledger_db/parquet/tx_index_test.go | 85 ++++++++++++++ 5 files changed, 369 insertions(+) create mode 100644 sei-db/ledger_db/parquet/reader_filter_test.go create mode 100644 sei-db/ledger_db/parquet/tx_index.go create mode 100644 sei-db/ledger_db/parquet/tx_index_test.go diff --git a/sei-db/ledger_db/parquet/reader.go b/sei-db/ledger_db/parquet/reader.go index 11539190a3..589c545cad 100644 --- a/sei-db/ledger_db/parquet/reader.go +++ b/sei-db/ledger_db/parquet/reader.go @@ -363,6 +363,47 @@ func (r *Reader) GetReceiptByTxHash(ctx context.Context, txHash common.Hash) (*R return &rec, nil } +// GetReceiptByTxHashInBlock queries for a receipt narrowed to the file covering blockNumber. +func (r *Reader) GetReceiptByTxHashInBlock(ctx context.Context, txHash common.Hash, blockNumber uint64) (*ReceiptResult, error) { + r.pruneMu.RLock() + defer r.pruneMu.RUnlock() + + r.mu.RLock() + closedFiles := make([]string, len(r.closedReceiptFiles)) + copy(closedFiles, r.closedReceiptFiles) + r.mu.RUnlock() + + var targetFile string + for _, f := range closedFiles { + startBlock := ExtractBlockNumber(f) + if blockNumber >= startBlock && blockNumber < startBlock+r.maxBlocksPerFile { + targetFile = f + break + } + } + if targetFile == "" { + return nil, nil + } + + // #nosec G201 -- targetFile derived from local file paths + query := fmt.Sprintf(` + SELECT tx_hash, block_number, receipt_bytes + FROM read_parquet(%s, union_by_name=true) + WHERE tx_hash = $1 + LIMIT 1 + `, quoteSQLString(targetFile)) + + row := r.db.QueryRowContext(ctx, query, txHash[:]) + var rec ReceiptResult + if err := row.Scan(&rec.TxHash, &rec.BlockNumber, &rec.ReceiptBytes); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return nil, fmt.Errorf("failed to query receipt: %w", err) + } + return &rec, nil +} + // GetLogs queries logs matching the given filter. func (r *Reader) GetLogs(ctx context.Context, filter LogFilter) ([]LogResult, error) { // Hold pruneMu first to prevent file deletion, then snapshot the list. diff --git a/sei-db/ledger_db/parquet/reader_filter_test.go b/sei-db/ledger_db/parquet/reader_filter_test.go new file mode 100644 index 0000000000..5556195c33 --- /dev/null +++ b/sei-db/ledger_db/parquet/reader_filter_test.go @@ -0,0 +1,107 @@ +package parquet + +import ( + "context" + "math/big" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +func TestGetReceiptByTxHashInBlock(t *testing.T) { + dir := t.TempDir() + + for _, start := range []uint64{0, 500, 1000} { + require.NoError(t, createTestReceiptFile(dir, start, 500)) + } + + reader, err := NewReaderWithMaxBlocksPerFile(dir, 500) + require.NoError(t, err) + defer func() { _ = reader.Close() }() + + ctx := context.Background() + + txHash := common.BigToHash(new(big.Int).SetUint64(750)) + result, err := reader.GetReceiptByTxHashInBlock(ctx, txHash, 750) + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, uint64(750), result.BlockNumber) + + // Query with wrong block number should return nil (file doesn't contain it). + result, err = reader.GetReceiptByTxHashInBlock(ctx, txHash, 200) + require.NoError(t, err) + require.Nil(t, result, "should not find receipt in wrong file") +} + +func TestGetReceiptByTxHashInBlockMissingFile(t *testing.T) { + dir := t.TempDir() + + require.NoError(t, createTestReceiptFile(dir, 0, 500)) + + reader, err := NewReaderWithMaxBlocksPerFile(dir, 500) + require.NoError(t, err) + defer func() { _ = reader.Close() }() + + ctx := context.Background() + + // Block 999 is in file range 500-999, but that file doesn't exist. + txHash := common.BigToHash(new(big.Int).SetUint64(999)) + result, err := reader.GetReceiptByTxHashInBlock(ctx, txHash, 999) + require.NoError(t, err) + require.Nil(t, result) +} + +func TestStoreGetReceiptByTxHashUsesIndex(t *testing.T) { + dir := t.TempDir() + + for _, start := range []uint64{0, 500, 1000} { + require.NoError(t, createTestReceiptFile(dir, start, 500)) + } + + store, err := NewStore(StoreConfig{ + DBDirectory: dir, + MaxBlocksPerFile: 500, + }) + require.NoError(t, err) + defer func() { _ = store.Close() }() + + txHash := common.BigToHash(new(big.Int).SetUint64(750)) + + // Populate the index manually for block 750. + require.NoError(t, store.txIndex.SetBatch([]TxIndexEntry{ + {TxHash: txHash, BlockNumber: 750}, + })) + + ctx := context.Background() + result, err := store.GetReceiptByTxHash(ctx, txHash) + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, uint64(750), result.BlockNumber) +} + +func TestStoreWriteReceiptsPopulatesIndex(t *testing.T) { + dir := t.TempDir() + + store, err := NewStore(StoreConfig{ + DBDirectory: dir, + MaxBlocksPerFile: 500, + }) + require.NoError(t, err) + defer func() { _ = store.Close() }() + + txHash := common.HexToHash("0xdeadbeef") + require.NoError(t, store.WriteReceipts([]ReceiptInput{{ + BlockNumber: 42, + Receipt: ReceiptRecord{ + TxHash: txHash[:], + BlockNumber: 42, + ReceiptBytes: []byte{0x1}, + }, + ReceiptBytes: []byte{0x1}, + }})) + + blockNum, ok := store.txIndex.GetBlockNumber(txHash) + require.True(t, ok, "tx hash should be in the index after WriteReceipts") + require.Equal(t, uint64(42), blockNum) +} diff --git a/sei-db/ledger_db/parquet/store.go b/sei-db/ledger_db/parquet/store.go index 5dcfe48950..2e25da3b18 100644 --- a/sei-db/ledger_db/parquet/store.go +++ b/sei-db/ledger_db/parquet/store.go @@ -90,6 +90,7 @@ type Store struct { closeOnce sync.Once pruneStop chan struct{} + txIndex *TxIndex // WarmupRecords holds receipts recovered from WAL for cache warming. WarmupRecords []ReceiptRecord @@ -118,6 +119,11 @@ func NewStore(cfg StoreConfig) (*Store, error) { return nil, err } + txIndex, err := OpenTxIndex(cfg.DBDirectory) + if err != nil { + return nil, err + } + store := &Store{ basePath: cfg.DBDirectory, receiptsBuffer: make([]ReceiptRecord, 0, 1000), @@ -126,6 +132,7 @@ func NewStore(cfg StoreConfig) (*Store, error) { Reader: reader, wal: receiptWAL, pruneStop: make(chan struct{}), + txIndex: txIndex, } if maxBlock, ok, err := reader.MaxReceiptBlockNumber(context.Background()); err != nil { @@ -189,7 +196,12 @@ func (s *Store) SetBlockFlushInterval(interval uint64) { } // GetReceiptByTxHash retrieves a receipt by transaction hash. +// When the tx index contains the block number, the search is narrowed to the +// single parquet file covering that block instead of scanning all files. func (s *Store) GetReceiptByTxHash(ctx context.Context, txHash common.Hash) (*ReceiptResult, error) { + if blockNum, ok := s.txIndex.GetBlockNumber(txHash); ok { + return s.Reader.GetReceiptByTxHashInBlock(ctx, txHash, blockNum) + } return s.Reader.GetReceiptByTxHash(ctx, txHash) } @@ -246,10 +258,23 @@ func (s *Store) WriteReceipts(inputs []ReceiptInput) error { s.mu.Lock() defer s.mu.Unlock() + indexEntries := make([]TxIndexEntry, 0, len(inputs)) for i := range inputs { if err := s.applyReceiptLocked(inputs[i]); err != nil { return err } + if len(inputs[i].Receipt.TxHash) > 0 { + indexEntries = append(indexEntries, TxIndexEntry{ + TxHash: common.BytesToHash(inputs[i].Receipt.TxHash), + BlockNumber: inputs[i].BlockNumber, + }) + } + } + + if len(indexEntries) > 0 { + if err := s.txIndex.SetBatch(indexEntries); err != nil { + return fmt.Errorf("failed to update tx index: %w", err) + } } return nil @@ -293,6 +318,7 @@ func (s *Store) SimulateCrash() { _ = s.wal.Close() _ = s.Reader.Close() + _ = s.txIndex.Close() } // Close closes the store. @@ -320,6 +346,10 @@ func (s *Store) Close() error { } if closeErr := s.Reader.Close(); closeErr != nil { err = closeErr + return + } + if closeErr := s.txIndex.Close(); closeErr != nil { + err = closeErr } }) @@ -378,6 +408,9 @@ func (s *Store) startPruning(pruneIntervalSeconds int64) { if pruned > 0 { logger.Info("Pruned parquet file pairs older than block", "pruned-count", pruned, "block", pruneBeforeBlock) } + if err := s.txIndex.PruneBefore(uint64(pruneBeforeBlock)); err != nil { + logger.Error("failed to prune tx index", "err", err) + } } // Add random jitter (up to 50% of base interval) to avoid thundering herd diff --git a/sei-db/ledger_db/parquet/tx_index.go b/sei-db/ledger_db/parquet/tx_index.go new file mode 100644 index 0000000000..973a5126cd --- /dev/null +++ b/sei-db/ledger_db/parquet/tx_index.go @@ -0,0 +1,103 @@ +package parquet + +import ( + "encoding/binary" + "fmt" + "path/filepath" + + "github.com/cockroachdb/pebble/v2" + "github.com/ethereum/go-ethereum/common" +) + +// TxIndex is a lightweight pebble-backed index mapping tx_hash -> block_number. +// It allows GetReceiptByTxHash to narrow the parquet file search to a single file +// instead of scanning all files. +type TxIndex struct { + db *pebble.DB +} + +func OpenTxIndex(baseDir string) (*TxIndex, error) { + dir := filepath.Join(baseDir, "tx-index") + db, err := pebble.Open(dir, &pebble.Options{}) + if err != nil { + return nil, fmt.Errorf("failed to open tx index: %w", err) + } + return &TxIndex{db: db}, nil +} + +func (idx *TxIndex) Close() error { + if idx == nil || idx.db == nil { + return nil + } + return idx.db.Close() +} + +// SetBatch writes a batch of tx_hash -> block_number mappings. +func (idx *TxIndex) SetBatch(entries []TxIndexEntry) error { + if idx == nil { + return nil + } + batch := idx.db.NewBatch() + defer func() { _ = batch.Close() }() + + val := make([]byte, 8) + for _, e := range entries { + binary.BigEndian.PutUint64(val, e.BlockNumber) + if err := batch.Set(e.TxHash[:], val, pebble.NoSync); err != nil { + return err + } + } + return batch.Commit(pebble.NoSync) +} + +// GetBlockNumber returns the block number for a tx hash, or 0, false if not found. +func (idx *TxIndex) GetBlockNumber(txHash common.Hash) (uint64, bool) { + if idx == nil { + return 0, false + } + val, closer, err := idx.db.Get(txHash[:]) + if err != nil { + return 0, false + } + defer func() { _ = closer.Close() }() + if len(val) < 8 { + return 0, false + } + return binary.BigEndian.Uint64(val), true +} + +// PruneBefore deletes all entries with block_number < pruneBlock. +// This is a full scan since pebble keys are tx hashes (not ordered by block). +// Intended to run infrequently on the prune interval. +func (idx *TxIndex) PruneBefore(pruneBlock uint64) error { + if idx == nil { + return nil + } + batch := idx.db.NewBatch() + defer func() { _ = batch.Close() }() + + iter, err := idx.db.NewIter(nil) + if err != nil { + return err + } + defer func() { _ = iter.Close() }() + + for valid := iter.First(); valid; valid = iter.Next() { + val := iter.Value() + if len(val) < 8 { + continue + } + blockNum := binary.BigEndian.Uint64(val) + if blockNum < pruneBlock { + if err := batch.Delete(iter.Key(), pebble.NoSync); err != nil { + return err + } + } + } + return batch.Commit(pebble.NoSync) +} + +type TxIndexEntry struct { + TxHash common.Hash + BlockNumber uint64 +} diff --git a/sei-db/ledger_db/parquet/tx_index_test.go b/sei-db/ledger_db/parquet/tx_index_test.go new file mode 100644 index 0000000000..6876d95f5c --- /dev/null +++ b/sei-db/ledger_db/parquet/tx_index_test.go @@ -0,0 +1,85 @@ +package parquet + +import ( + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +func TestTxIndexSetAndGet(t *testing.T) { + idx, err := OpenTxIndex(t.TempDir()) + require.NoError(t, err) + defer func() { _ = idx.Close() }() + + txHash := common.HexToHash("0xabc123") + blockNum, ok := idx.GetBlockNumber(txHash) + require.False(t, ok, "empty index should return not found") + require.Equal(t, uint64(0), blockNum) + + require.NoError(t, idx.SetBatch([]TxIndexEntry{ + {TxHash: txHash, BlockNumber: 42}, + })) + + blockNum, ok = idx.GetBlockNumber(txHash) + require.True(t, ok) + require.Equal(t, uint64(42), blockNum) +} + +func TestTxIndexBatchWrite(t *testing.T) { + idx, err := OpenTxIndex(t.TempDir()) + require.NoError(t, err) + defer func() { _ = idx.Close() }() + + entries := make([]TxIndexEntry, 100) + for i := range entries { + var h common.Hash + h[0] = byte(i) + entries[i] = TxIndexEntry{TxHash: h, BlockNumber: uint64(1000 + i)} + } + + require.NoError(t, idx.SetBatch(entries)) + + for _, e := range entries { + blockNum, ok := idx.GetBlockNumber(e.TxHash) + require.True(t, ok) + require.Equal(t, e.BlockNumber, blockNum) + } +} + +func TestTxIndexPruneBefore(t *testing.T) { + idx, err := OpenTxIndex(t.TempDir()) + require.NoError(t, err) + defer func() { _ = idx.Close() }() + + require.NoError(t, idx.SetBatch([]TxIndexEntry{ + {TxHash: common.HexToHash("0x01"), BlockNumber: 100}, + {TxHash: common.HexToHash("0x02"), BlockNumber: 200}, + {TxHash: common.HexToHash("0x03"), BlockNumber: 300}, + {TxHash: common.HexToHash("0x04"), BlockNumber: 400}, + })) + + require.NoError(t, idx.PruneBefore(250)) + + _, ok := idx.GetBlockNumber(common.HexToHash("0x01")) + require.False(t, ok, "block 100 should be pruned") + _, ok = idx.GetBlockNumber(common.HexToHash("0x02")) + require.False(t, ok, "block 200 should be pruned") + + blockNum, ok := idx.GetBlockNumber(common.HexToHash("0x03")) + require.True(t, ok, "block 300 should survive pruning") + require.Equal(t, uint64(300), blockNum) + + blockNum, ok = idx.GetBlockNumber(common.HexToHash("0x04")) + require.True(t, ok, "block 400 should survive pruning") + require.Equal(t, uint64(400), blockNum) +} + +func TestTxIndexNilSafety(t *testing.T) { + var idx *TxIndex + require.NoError(t, idx.Close()) + require.NoError(t, idx.SetBatch([]TxIndexEntry{{BlockNumber: 1}})) + require.NoError(t, idx.PruneBefore(100)) + _, ok := idx.GetBlockNumber(common.Hash{}) + require.False(t, ok) +} From f1ac18c55cdc302db7b419414d1a3a80b8a83b04 Mon Sep 17 00:00:00 2001 From: Jeremy Wei Date: Mon, 30 Mar 2026 15:24:42 -0400 Subject: [PATCH 03/28] add receipt/log reads to cryptosim Extend cryptosim from write-only to mixed read/write workload: add concurrent receipt-by-hash and eth_getLogs-style log filter readers, deterministic synthetic tx hashes for stateless lookups, recency-biased block selection, read metrics/dashboard panels, receipt read observer for cache hit/miss tracking, and clean ErrNotFound from parquet store. Made-with: Cursor --- .../dashboards/cryptosim-dashboard.json | 806 ++++++++++++++++++ .../ledger_db/receipt/cached_receipt_store.go | 54 +- .../receipt/cached_receipt_store_test.go | 62 +- sei-db/ledger_db/receipt/parquet_store.go | 3 + sei-db/ledger_db/receipt/receipt_store.go | 21 +- .../state_db/bench/cryptosim/canned_random.go | 3 + .../bench/cryptosim/config/reciept-store.json | 16 +- sei-db/state_db/bench/cryptosim/cryptosim.go | 2 +- .../bench/cryptosim/cryptosim_config.go | 68 +- .../bench/cryptosim/cryptosim_metrics.go | 202 ++++- sei-db/state_db/bench/cryptosim/receipt.go | 28 +- .../state_db/bench/cryptosim/receipt_test.go | 41 + .../cryptosim/reciept_store_simulator.go | 338 +++++++- .../cryptosim/reciept_store_simulator_test.go | 63 ++ 14 files changed, 1654 insertions(+), 53 deletions(-) create mode 100644 sei-db/state_db/bench/cryptosim/reciept_store_simulator_test.go diff --git a/docker/monitornode/dashboards/cryptosim-dashboard.json b/docker/monitornode/dashboards/cryptosim-dashboard.json index 288035db07..5a00b5cd02 100644 --- a/docker/monitornode/dashboards/cryptosim-dashboard.json +++ b/docker/monitornode/dashboards/cryptosim-dashboard.json @@ -3010,6 +3010,812 @@ ], "title": "Receipt Errors", "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 16 + }, + "id": 282, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.4.0", + "targets": [ + { + "editorMode": "code", + "expr": "histogram_quantile(0.99, rate(cryptosim_receipt_read_duration_seconds_bucket[$__rate_interval]))", + "legendFormat": "p99", + "range": true, + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "editorMode": "code", + "expr": "histogram_quantile(0.95, rate(cryptosim_receipt_read_duration_seconds_bucket[$__rate_interval]))", + "instant": false, + "legendFormat": "p95", + "range": true, + "refId": "B" + }, + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "editorMode": "code", + "expr": "histogram_quantile(0.50, rate(cryptosim_receipt_read_duration_seconds_bucket[$__rate_interval]))", + "instant": false, + "legendFormat": "p50", + "range": true, + "refId": "C" + }, + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "editorMode": "code", + "expr": "rate(cryptosim_receipt_read_duration_seconds_sum[$__rate_interval]) / rate(cryptosim_receipt_read_duration_seconds_count[$__rate_interval])", + "instant": false, + "legendFormat": "average", + "range": true, + "refId": "D" + } + ], + "title": "Receipt Read Latency", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 16 + }, + "id": 283, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": false + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.4.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "editorMode": "code", + "expr": "rate(cryptosim_receipt_reads_total[$__rate_interval])", + "legendFormat": "__auto", + "range": true, + "refId": "A" + } + ], + "title": "Receipt Reads/sec", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "max": 1, + "min": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "percentunit" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 24 + }, + "id": 284, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.4.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "editorMode": "code", + "expr": "rate(cryptosim_receipt_cache_hits_total[$__rate_interval]) / (rate(cryptosim_receipt_cache_hits_total[$__rate_interval]) + rate(cryptosim_receipt_cache_misses_total[$__rate_interval]))", + "legendFormat": "cache hit %", + "range": true, + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "editorMode": "code", + "expr": "rate(cryptosim_receipt_cache_misses_total[$__rate_interval]) / (rate(cryptosim_receipt_cache_hits_total[$__rate_interval]) + rate(cryptosim_receipt_cache_misses_total[$__rate_interval]))", + "instant": false, + "legendFormat": "cache miss %", + "range": true, + "refId": "B" + } + ], + "title": "Receipt Cache Hit/Miss %", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "max": 1, + "min": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "percentunit" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 24 + }, + "id": 288, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.4.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "editorMode": "code", + "expr": "rate(cryptosim_receipt_log_filter_cache_hits_total[$__rate_interval]) / (rate(cryptosim_receipt_log_filter_cache_hits_total[$__rate_interval]) + rate(cryptosim_receipt_log_filter_cache_miss_total[$__rate_interval]))", + "legendFormat": "cache hit %", + "range": true, + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "editorMode": "code", + "expr": "rate(cryptosim_receipt_log_filter_cache_miss_total[$__rate_interval]) / (rate(cryptosim_receipt_log_filter_cache_hits_total[$__rate_interval]) + rate(cryptosim_receipt_log_filter_cache_miss_total[$__rate_interval]))", + "instant": false, + "legendFormat": "cache miss %", + "range": true, + "refId": "B" + } + ], + "title": "Log Filter Cache Hit/Miss %", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 32 + }, + "id": 286, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.4.0", + "targets": [ + { + "editorMode": "code", + "expr": "histogram_quantile(0.99, rate(cryptosim_receipt_log_filter_duration_seconds_bucket[$__rate_interval]))", + "legendFormat": "p99", + "range": true, + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "editorMode": "code", + "expr": "histogram_quantile(0.95, rate(cryptosim_receipt_log_filter_duration_seconds_bucket[$__rate_interval]))", + "instant": false, + "legendFormat": "p95", + "range": true, + "refId": "B" + }, + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "editorMode": "code", + "expr": "histogram_quantile(0.50, rate(cryptosim_receipt_log_filter_duration_seconds_bucket[$__rate_interval]))", + "instant": false, + "legendFormat": "p50", + "range": true, + "refId": "C" + }, + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "editorMode": "code", + "expr": "rate(cryptosim_receipt_log_filter_duration_seconds_sum[$__rate_interval]) / rate(cryptosim_receipt_log_filter_duration_seconds_count[$__rate_interval])", + "instant": false, + "legendFormat": "average", + "range": true, + "refId": "D" + } + ], + "title": "Log Filter Latency", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 32 + }, + "id": 287, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": false + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.4.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "editorMode": "code", + "expr": "rate(cryptosim_receipt_log_filter_duration_seconds_count[$__rate_interval])", + "legendFormat": "__auto", + "range": true, + "refId": "A" + } + ], + "title": "Log Reads/sec", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 40 + }, + "id": 289, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.4.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "editorMode": "code", + "expr": "histogram_quantile(0.99, rate(cryptosim_receipt_log_filter_logs_returned_bucket[$__rate_interval]))", + "legendFormat": "p99", + "range": true, + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "editorMode": "code", + "expr": "histogram_quantile(0.50, rate(cryptosim_receipt_log_filter_logs_returned_bucket[$__rate_interval]))", + "instant": false, + "legendFormat": "p50", + "range": true, + "refId": "B" + }, + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "editorMode": "code", + "expr": "rate(cryptosim_receipt_log_filter_logs_returned_sum[$__rate_interval]) / rate(cryptosim_receipt_log_filter_logs_returned_count[$__rate_interval])", + "instant": false, + "legendFormat": "average", + "range": true, + "refId": "C" + } + ], + "title": "Logs Returned Per Query", + "type": "timeseries" } ], "title": "Receipts", diff --git a/sei-db/ledger_db/receipt/cached_receipt_store.go b/sei-db/ledger_db/receipt/cached_receipt_store.go index ed524e9127..d7aeb70be5 100644 --- a/sei-db/ledger_db/receipt/cached_receipt_store.go +++ b/sei-db/ledger_db/receipt/cached_receipt_store.go @@ -28,9 +28,10 @@ type cachedReceiptStore struct { cacheRotateInterval uint64 cacheNextRotate uint64 cacheMu sync.Mutex + readObserver ReceiptReadObserver } -func newCachedReceiptStore(backend ReceiptStore) ReceiptStore { +func newCachedReceiptStore(backend ReceiptStore, observer ReceiptReadObserver) ReceiptStore { if backend == nil { return nil } @@ -44,6 +45,7 @@ func newCachedReceiptStore(backend ReceiptStore) ReceiptStore { backend: backend, cache: newLedgerCache(), cacheRotateInterval: interval, + readObserver: observer, } if provider, ok := backend.(cacheWarmupProvider); ok { store.cacheReceipts(provider.warmupReceipts()) @@ -51,6 +53,26 @@ func newCachedReceiptStore(backend ReceiptStore) ReceiptStore { return store } +// StableReceiptCacheWindowBlocks returns the near-tip block window that is +// guaranteed to stay in the active write chunk until the next rotation. +func StableReceiptCacheWindowBlocks(store ReceiptStore) uint64 { + cached, ok := store.(*cachedReceiptStore) + if !ok || cached.cacheRotateInterval == 0 { + return 0 + } + return cached.cacheRotateInterval +} + +// EstimatedReceiptCacheWindowBlocks returns the approximate recent block window +// normally served by the in-memory receipt cache (current chunk + previous one). +func EstimatedReceiptCacheWindowBlocks(store ReceiptStore) uint64 { + hotWindow := StableReceiptCacheWindowBlocks(store) + if hotWindow == 0 { + return 0 + } + return hotWindow * uint64(numCacheChunks-1) +} + func (s *cachedReceiptStore) LatestVersion() int64 { return s.backend.LatestVersion() } @@ -65,15 +87,19 @@ func (s *cachedReceiptStore) SetEarliestVersion(version int64) error { func (s *cachedReceiptStore) GetReceipt(ctx sdk.Context, txHash common.Hash) (*types.Receipt, error) { if receipt, ok := s.cache.GetReceipt(txHash); ok { + s.reportCacheHit() return receipt, nil } + s.reportCacheMiss() return s.backend.GetReceipt(ctx, txHash) } func (s *cachedReceiptStore) GetReceiptFromStore(ctx sdk.Context, txHash common.Hash) (*types.Receipt, error) { if receipt, ok := s.cache.GetReceipt(txHash); ok { + s.reportCacheHit() return receipt, nil } + s.reportCacheMiss() return s.backend.GetReceiptFromStore(ctx, txHash) } @@ -109,8 +135,10 @@ func (s *cachedReceiptStore) FilterLogs(ctx sdk.Context, fromBlock, toBlock uint } if len(cacheLogs) == 0 { + s.reportLogFilterCacheMiss() return backendLogs, nil } + s.reportLogFilterCacheHit() if len(backendLogs) == 0 { sortLogs(cacheLogs) return cacheLogs, nil @@ -228,3 +256,27 @@ func (s *cachedReceiptStore) maybeRotateCacheLocked(blockNumber uint64) { s.cacheNextRotate += s.cacheRotateInterval } } + +func (s *cachedReceiptStore) reportCacheHit() { + if s.readObserver != nil { + s.readObserver.ReportReceiptCacheHit() + } +} + +func (s *cachedReceiptStore) reportCacheMiss() { + if s.readObserver != nil { + s.readObserver.ReportReceiptCacheMiss() + } +} + +func (s *cachedReceiptStore) reportLogFilterCacheHit() { + if s.readObserver != nil { + s.readObserver.ReportLogFilterCacheHit() + } +} + +func (s *cachedReceiptStore) reportLogFilterCacheMiss() { + if s.readObserver != nil { + s.readObserver.ReportLogFilterCacheMiss() + } +} diff --git a/sei-db/ledger_db/receipt/cached_receipt_store_test.go b/sei-db/ledger_db/receipt/cached_receipt_store_test.go index df8d527bfb..bc5cabd29b 100644 --- a/sei-db/ledger_db/receipt/cached_receipt_store_test.go +++ b/sei-db/ledger_db/receipt/cached_receipt_store_test.go @@ -21,6 +21,29 @@ type fakeReceiptBackend struct { lastFilterToBlock uint64 } +type fakeReceiptReadObserver struct { + cacheHits int + cacheMisses int + logFilterCacheHits int + logFilterCacheMisses int +} + +func (f *fakeReceiptReadObserver) ReportReceiptCacheHit() { + f.cacheHits++ +} + +func (f *fakeReceiptReadObserver) ReportReceiptCacheMiss() { + f.cacheMisses++ +} + +func (f *fakeReceiptReadObserver) ReportLogFilterCacheHit() { + f.logFilterCacheHits++ +} + +func (f *fakeReceiptReadObserver) ReportLogFilterCacheMiss() { + f.logFilterCacheMisses++ +} + func newFakeReceiptBackend() *fakeReceiptBackend { return &fakeReceiptBackend{ receipts: make(map[common.Hash]*types.Receipt), @@ -81,7 +104,7 @@ func (f *fakeReceiptBackend) Close() error { func TestCachedReceiptStoreUsesCacheForReceipt(t *testing.T) { ctx, _ := newTestContext() backend := newFakeReceiptBackend() - store := newCachedReceiptStore(backend) + store := newCachedReceiptStore(backend, nil) txHash := common.HexToHash("0x1") addr := common.HexToAddress("0x100") @@ -100,7 +123,7 @@ func TestCachedReceiptStoreUsesCacheForReceipt(t *testing.T) { func TestCachedReceiptStoreFilterLogsSkipsBackendWhenCacheCovers(t *testing.T) { ctx, _ := newTestContext() backend := newFakeReceiptBackend() - store := newCachedReceiptStore(backend) + store := newCachedReceiptStore(backend, nil) txHash := common.HexToHash("0x2") addr := common.HexToAddress("0x200") @@ -127,7 +150,7 @@ func TestCachedReceiptStoreFilterLogsReturnsSortedLogs(t *testing.T) { {BlockNumber: 8, TxIndex: 1, Index: 2}, {BlockNumber: 5, TxIndex: 0, Index: 0}, } - store := newCachedReceiptStore(backend) + store := newCachedReceiptStore(backend, nil) // Cache holds recent blocks written through SetReceipts. receiptA := makeTestReceipt(common.HexToHash("0xa"), 11, 1, common.HexToAddress("0x210"), []common.Hash{common.HexToHash("0x1")}) @@ -216,3 +239,36 @@ func TestFilterLogsMultipleBlocksCacheOnly(t *testing.T) { require.Equal(t, blockNum, logs[i].BlockNumber) } } + +func TestCachedReceiptStoreReportsCacheHit(t *testing.T) { + ctx, _ := newTestContext() + backend := newFakeReceiptBackend() + observer := &fakeReceiptReadObserver{} + store := newCachedReceiptStore(backend, observer) + + txHash := common.HexToHash("0x10") + receipt := makeTestReceipt(txHash, 7, 1, common.HexToAddress("0x100"), nil) + + require.NoError(t, store.SetReceipts(ctx, []ReceiptRecord{{TxHash: txHash, Receipt: receipt}})) + + backend.getReceiptCalls = 0 + got, err := store.GetReceipt(ctx, txHash) + require.NoError(t, err) + require.Equal(t, receipt.TxHashHex, got.TxHashHex) + require.Equal(t, 0, backend.getReceiptCalls) + require.Equal(t, 1, observer.cacheHits) + require.Equal(t, 0, observer.cacheMisses) +} + +func TestCachedReceiptStoreReportsCacheMiss(t *testing.T) { + ctx, _ := newTestContext() + backend := newFakeReceiptBackend() + observer := &fakeReceiptReadObserver{} + store := newCachedReceiptStore(backend, observer) + + _, err := store.GetReceipt(ctx, common.HexToHash("0x404")) + require.ErrorIs(t, err, ErrNotFound) + require.Equal(t, 1, backend.getReceiptCalls) + require.Equal(t, 0, observer.cacheHits) + require.Equal(t, 1, observer.cacheMisses) +} diff --git a/sei-db/ledger_db/receipt/parquet_store.go b/sei-db/ledger_db/receipt/parquet_store.go index bdf88b2fd0..1822c46432 100644 --- a/sei-db/ledger_db/receipt/parquet_store.go +++ b/sei-db/ledger_db/receipt/parquet_store.go @@ -90,6 +90,9 @@ func (s *parquetReceiptStore) GetReceipt(ctx sdk.Context, txHash common.Hash) (* return receipt, nil } + if s.storeKey == nil { + return nil, ErrNotFound + } store := ctx.KVStore(s.storeKey) bz := store.Get(types.ReceiptKey(txHash)) if bz == nil { diff --git a/sei-db/ledger_db/receipt/receipt_store.go b/sei-db/ledger_db/receipt/receipt_store.go index 37276b0113..33f568bb2c 100644 --- a/sei-db/ledger_db/receipt/receipt_store.go +++ b/sei-db/ledger_db/receipt/receipt_store.go @@ -53,6 +53,15 @@ type ReceiptRecord struct { ReceiptBytes []byte // Optional pre-marshaled receipt (must match Receipt if set) } +// ReceiptReadObserver receives callbacks when cached receipt lookups either hit +// the in-memory ledger cache or fall through to the backend store. +type ReceiptReadObserver interface { + ReportReceiptCacheHit() + ReportReceiptCacheMiss() + ReportLogFilterCacheHit() + ReportLogFilterCacheMiss() +} + type receiptStore struct { db seidbtypes.StateStore storeKey sdk.StoreKey @@ -78,11 +87,21 @@ func normalizeReceiptBackend(backend string) string { } func NewReceiptStore(config dbconfig.ReceiptStoreConfig, storeKey sdk.StoreKey) (ReceiptStore, error) { + return NewReceiptStoreWithReadObserver(config, storeKey, nil) +} + +// NewReceiptStoreWithReadObserver constructs a receipt store and optionally +// reports cache hits/misses for receipt-by-hash reads via observer callbacks. +func NewReceiptStoreWithReadObserver( + config dbconfig.ReceiptStoreConfig, + storeKey sdk.StoreKey, + observer ReceiptReadObserver, +) (ReceiptStore, error) { backend, err := newReceiptBackend(config, storeKey) if err != nil { return nil, err } - return newCachedReceiptStore(backend), nil + return newCachedReceiptStore(backend, observer), nil } // BackendTypeName returns the backend implementation name ("parquet" or "pebble") for testing. diff --git a/sei-db/state_db/bench/cryptosim/canned_random.go b/sei-db/state_db/bench/cryptosim/canned_random.go index 6e95db15e4..7e0e37cba5 100644 --- a/sei-db/state_db/bench/cryptosim/canned_random.go +++ b/sei-db/state_db/bench/cryptosim/canned_random.go @@ -91,6 +91,9 @@ func (cr *CannedRandom) Bytes(count int) []byte { // Returns a slice of random bytes from a given seed. Bytes are deterministic given the same seed. // +// Unlike most CannedRandom methods, SeededBytes is safe for concurrent use: it only reads +// from the immutable buffer and does not advance the internal index. +// // Returned slice is NOT safe to modify. If modification is required, the caller should make a copy of the slice. func (cr *CannedRandom) SeededBytes(count int, seed int64) []byte { if count < 0 { diff --git a/sei-db/state_db/bench/cryptosim/config/reciept-store.json b/sei-db/state_db/bench/cryptosim/config/reciept-store.json index dbb621e8ae..f433ebcc91 100644 --- a/sei-db/state_db/bench/cryptosim/config/reciept-store.json +++ b/sei-db/state_db/bench/cryptosim/config/reciept-store.json @@ -1,8 +1,18 @@ { - "Comment": "For testing with the state store and reciept store both enabled.", + "Comment": "For testing with the receipt store only (state store disabled), with concurrent reads, pruning, and cache.", + "DisableTransactionExecution": true, "DataDir": "data", + "LogDir": "logs", + "LogLevel": "info", "MinimumNumberOfColdAccounts": 1000000, "MinimumNumberOfDormantAccounts": 1000000, - "GenerateReceipts": true + "GenerateReceipts": true, + "ReceiptReadConcurrency": 4, + "ReceiptReadsPerSecond": 1000, + "ReceiptReadMode": "duckdb", + "ReceiptLogFilterReadConcurrency": 4, + "ReceiptLogFilterReadsPerSecond": 200, + "ReceiptLogFilterReadMode": "duckdb", + "ReceiptLogFilterMinBlockRange": 1, + "ReceiptLogFilterMaxBlockRange": 10 } - diff --git a/sei-db/state_db/bench/cryptosim/cryptosim.go b/sei-db/state_db/bench/cryptosim/cryptosim.go index 2b1b593250..59385eb68c 100644 --- a/sei-db/state_db/bench/cryptosim/cryptosim.go +++ b/sei-db/state_db/bench/cryptosim/cryptosim.go @@ -171,7 +171,7 @@ func NewCryptoSim( var recieptsChan chan *block if config.GenerateReceipts { recieptsChan = make(chan *block, config.RecieptChannelCapacity) - _, err := NewRecieptStoreSimulator(ctx, config, recieptsChan, metrics) + _, err := NewRecieptStoreSimulator(ctx, config, recieptsChan, metrics, rand.Clone(false)) if err != nil { cancel() return nil, fmt.Errorf("failed to create receipt store simulator: %w", err) diff --git a/sei-db/state_db/bench/cryptosim/cryptosim_config.go b/sei-db/state_db/bench/cryptosim/cryptosim_config.go index 53b5f28e65..1954643b14 100644 --- a/sei-db/state_db/bench/cryptosim/cryptosim_config.go +++ b/sei-db/state_db/bench/cryptosim/cryptosim_config.go @@ -158,6 +158,38 @@ type CryptoSimConfig struct { // If greater than 0, the benchmark will throttle the transaction rate to this value, in hertz. MaxTPS float64 + // Number of concurrent reader goroutines issuing receipt lookups. 0 disables reads. + ReceiptReadConcurrency int + + // Target total receipt reads per second across all reader goroutines. + // Reads are distributed evenly across readers. + ReceiptReadsPerSecond int + + // Controls which block range receipt-by-hash reads target. + // "cache" = only read receipts in the cache window (guaranteed cache hit). + // "duckdb" = only read receipts older than the cache window (guaranteed cache miss, DuckDB fallback). + // Required when ReceiptReadConcurrency > 0. + ReceiptReadMode string + + // Number of concurrent goroutines issuing log filter (eth_getLogs) queries. 0 disables log filter reads. + // These goroutines are independent from the receipt reader goroutines. + ReceiptLogFilterReadConcurrency int + + // Target total log filter reads per second across all log filter goroutines. + ReceiptLogFilterReadsPerSecond int + + // Controls which block range log filter reads target. + // "cache" = only query blocks in the cache window (DuckDB skipped). + // "duckdb" = only query blocks older than the cache window (cache returns nothing). + // Required when ReceiptLogFilterReadConcurrency > 0. + ReceiptLogFilterReadMode string + + // Minimum number of blocks in a log filter query range. Default 1. + ReceiptLogFilterMinBlockRange int + + // Maximum number of blocks in a log filter query range. Default 10. + ReceiptLogFilterMaxBlockRange int + // Number of recent blocks to keep before pruning parquet files. 0 disables pruning. ReceiptKeepRecent int64 @@ -215,6 +247,14 @@ func DefaultCryptoSimConfig() *CryptoSimConfig { RecieptChannelCapacity: 32, DisableTransactionExecution: false, MaxTPS: 0, + ReceiptReadConcurrency: 0, + ReceiptReadsPerSecond: 100, + ReceiptReadMode: "cache", + ReceiptLogFilterReadConcurrency: 0, + ReceiptLogFilterReadsPerSecond: 100, + ReceiptLogFilterReadMode: "cache", + ReceiptLogFilterMinBlockRange: 1, + ReceiptLogFilterMaxBlockRange: 10, ReceiptKeepRecent: 100_000, ReceiptPruneIntervalSeconds: 600, LogLevel: "info", @@ -302,12 +342,38 @@ func (c *CryptoSimConfig) Validate() error { if c.MaxTPS < 0 { return fmt.Errorf("MaxTPS must be non-negative (got %f)", c.MaxTPS) } + if c.ReceiptReadConcurrency < 0 { + return fmt.Errorf("ReceiptReadConcurrency must be non-negative (got %d)", c.ReceiptReadConcurrency) + } + if c.ReceiptReadConcurrency > 0 { + switch c.ReceiptReadMode { + case "cache", "duckdb": + default: + return fmt.Errorf("ReceiptReadMode must be \"cache\" or \"duckdb\" (got %q)", c.ReceiptReadMode) + } + } + if c.ReceiptLogFilterReadConcurrency < 0 { + return fmt.Errorf("ReceiptLogFilterReadConcurrency must be non-negative (got %d)", c.ReceiptLogFilterReadConcurrency) + } + if c.ReceiptLogFilterReadConcurrency > 0 { + switch c.ReceiptLogFilterReadMode { + case "cache", "duckdb": + default: + return fmt.Errorf("ReceiptLogFilterReadMode must be \"cache\" or \"duckdb\" (got %q)", c.ReceiptLogFilterReadMode) + } + } + if c.ReceiptLogFilterMinBlockRange < 1 { + return fmt.Errorf("ReceiptLogFilterMinBlockRange must be at least 1 (got %d)", c.ReceiptLogFilterMinBlockRange) + } + if c.ReceiptLogFilterMaxBlockRange < c.ReceiptLogFilterMinBlockRange { + return fmt.Errorf("ReceiptLogFilterMaxBlockRange must be >= ReceiptLogFilterMinBlockRange (got %d < %d)", + c.ReceiptLogFilterMaxBlockRange, c.ReceiptLogFilterMinBlockRange) + } switch strings.ToLower(c.LogLevel) { case "debug", "info", "warn", "error": default: return fmt.Errorf("LogLevel must be one of debug, info, warn, error (got %q)", c.LogLevel) } - return nil } diff --git a/sei-db/state_db/bench/cryptosim/cryptosim_metrics.go b/sei-db/state_db/bench/cryptosim/cryptosim_metrics.go index 050a8f6772..7d8a2531b4 100644 --- a/sei-db/state_db/bench/cryptosim/cryptosim_metrics.go +++ b/sei-db/state_db/bench/cryptosim/cryptosim_metrics.go @@ -25,6 +25,12 @@ var receiptWriteLatencyBuckets = []float64{ 0.1, 0.25, 0.5, 0.75, 1, 2.5, 5, } +var receiptReadLatencyBuckets = []float64{ + 0.00001, 0.00005, 0.0001, 0.00025, 0.0005, + 0.001, 0.0025, 0.005, 0.01, 0.025, + 0.05, 0.1, 0.25, 0.5, 1, +} + // CryptosimMetrics holds OpenTelemetry metrics for the cryptosim benchmark. // Metrics are exported via whatever exporter is configured on the global OTel // MeterProvider (e.g., Prometheus, OTLP). This package does not import Prometheus. @@ -49,10 +55,20 @@ type CryptosimMetrics struct { uptimeSeconds metric.Float64Gauge // Receipt metrics - receiptBlockWriteDuration metric.Float64Histogram - receiptChannelDepth metric.Int64Gauge - receiptsWrittenTotal metric.Int64Counter - receiptErrorsTotal metric.Int64Counter + receiptBlockWriteDuration metric.Float64Histogram + receiptChannelDepth metric.Int64Gauge + receiptsWrittenTotal metric.Int64Counter + receiptErrorsTotal metric.Int64Counter + receiptReadDuration metric.Float64Histogram + receiptReadsTotal metric.Int64Counter + receiptCacheHitsTotal metric.Int64Counter + receiptCacheMissesTotal metric.Int64Counter + receiptReadsFoundTotal metric.Int64Counter + receiptReadsNotFoundTotal metric.Int64Counter + receiptLogFilterDuration metric.Float64Histogram + receiptLogFilterCacheHitsTotal metric.Int64Counter + receiptLogFilterCacheMissTotal metric.Int64Counter + receiptLogFilterLogsReturned metric.Int64Histogram mainThreadPhase *metrics.PhaseTimer transactionPhaseTimerFactory *metrics.PhaseTimerFactory @@ -179,6 +195,58 @@ func NewCryptosimMetrics( metric.WithDescription("Total receipt processing errors (marshal or write failures)"), metric.WithUnit("{count}"), ) + receiptReadDuration, _ := meter.Float64Histogram( + "cryptosim_receipt_read_duration_seconds", + metric.WithDescription("End-to-end receipt read latency (includes cache layer)"), + metric.WithExplicitBucketBoundaries(receiptReadLatencyBuckets...), + metric.WithUnit("s"), + ) + receiptReadsTotal, _ := meter.Int64Counter( + "cryptosim_receipt_reads_total", + metric.WithDescription("Total receipt read attempts"), + metric.WithUnit("{count}"), + ) + receiptCacheHitsTotal, _ := meter.Int64Counter( + "cryptosim_receipt_cache_hits_total", + metric.WithDescription("Receipt reads served from the ledger cache"), + metric.WithUnit("{count}"), + ) + receiptCacheMissesTotal, _ := meter.Int64Counter( + "cryptosim_receipt_cache_misses_total", + metric.WithDescription("Receipt reads that missed the in-memory ledger cache and fell through to the backend"), + metric.WithUnit("{count}"), + ) + receiptReadsFoundTotal, _ := meter.Int64Counter( + "cryptosim_receipt_reads_found_total", + metric.WithDescription("Receipt reads that returned a receipt"), + metric.WithUnit("{count}"), + ) + receiptReadsNotFoundTotal, _ := meter.Int64Counter( + "cryptosim_receipt_reads_not_found_total", + metric.WithDescription("Receipt reads that returned no receipt because the hash was absent or pruned"), + metric.WithUnit("{count}"), + ) + receiptLogFilterDuration, _ := meter.Float64Histogram( + "cryptosim_receipt_log_filter_duration_seconds", + metric.WithDescription("DuckDB eth_getLogs filter query latency"), + metric.WithUnit("s"), + ) + receiptLogFilterCacheHitsTotal, _ := meter.Int64Counter( + "cryptosim_receipt_log_filter_cache_hits_total", + metric.WithDescription("Log filter queries where the in-memory cache contributed results"), + metric.WithUnit("{count}"), + ) + receiptLogFilterCacheMissTotal, _ := meter.Int64Counter( + "cryptosim_receipt_log_filter_cache_miss_total", + metric.WithDescription("Log filter queries served entirely from the backend (cache contributed nothing)"), + metric.WithUnit("{count}"), + ) + receiptLogFilterLogsReturned, _ := meter.Int64Histogram( + "cryptosim_receipt_log_filter_logs_returned", + metric.WithDescription("Number of log entries returned per FilterLogs query"), + metric.WithExplicitBucketBoundaries(0, 1, 5, 10, 25, 50, 100, 250, 500, 1000, 5000), + metric.WithUnit("{count}"), + ) mainThreadPhase := dbPhaseTimer if mainThreadPhase == nil { @@ -188,29 +256,39 @@ func NewCryptosimMetrics( transactionPhaseTimerFactory := metrics.NewPhaseTimerFactory(meter, "transaction") m := &CryptosimMetrics{ - ctx: ctx, - blocksFinalizedTotal: blocksFinalizedTotal, - transactionsProcessedTotal: transactionsProcessedTotal, - totalAccounts: totalAccounts, - hotAccounts: hotAccounts, - coldAccounts: coldAccounts, - dormantAccounts: dormantAccounts, - totalErc20Contracts: totalErc20Contracts, - dbCommitsTotal: dbCommitsTotal, - dataDirSizeBytes: dataDirSizeBytes, - dataDirAvailableBytes: dataDirAvailableBytes, - logDirSizeBytes: logDirSizeBytes, - processReadBytesTotal: processReadBytesTotal, - processWriteBytesTotal: processWriteBytesTotal, - processReadCountTotal: processReadCountTotal, - processWriteCountTotal: processWriteCountTotal, - uptimeSeconds: uptimeSeconds, - receiptBlockWriteDuration: receiptBlockWriteDuration, - receiptChannelDepth: receiptChannelDepth, - receiptsWrittenTotal: receiptsWrittenTotal, - receiptErrorsTotal: receiptErrorsTotal, - mainThreadPhase: mainThreadPhase, - transactionPhaseTimerFactory: transactionPhaseTimerFactory, + ctx: ctx, + blocksFinalizedTotal: blocksFinalizedTotal, + transactionsProcessedTotal: transactionsProcessedTotal, + totalAccounts: totalAccounts, + hotAccounts: hotAccounts, + coldAccounts: coldAccounts, + dormantAccounts: dormantAccounts, + totalErc20Contracts: totalErc20Contracts, + dbCommitsTotal: dbCommitsTotal, + dataDirSizeBytes: dataDirSizeBytes, + dataDirAvailableBytes: dataDirAvailableBytes, + logDirSizeBytes: logDirSizeBytes, + processReadBytesTotal: processReadBytesTotal, + processWriteBytesTotal: processWriteBytesTotal, + processReadCountTotal: processReadCountTotal, + processWriteCountTotal: processWriteCountTotal, + uptimeSeconds: uptimeSeconds, + receiptBlockWriteDuration: receiptBlockWriteDuration, + receiptChannelDepth: receiptChannelDepth, + receiptsWrittenTotal: receiptsWrittenTotal, + receiptErrorsTotal: receiptErrorsTotal, + receiptReadDuration: receiptReadDuration, + receiptReadsTotal: receiptReadsTotal, + receiptCacheHitsTotal: receiptCacheHitsTotal, + receiptCacheMissesTotal: receiptCacheMissesTotal, + receiptReadsFoundTotal: receiptReadsFoundTotal, + receiptReadsNotFoundTotal: receiptReadsNotFoundTotal, + receiptLogFilterDuration: receiptLogFilterDuration, + receiptLogFilterCacheHitsTotal: receiptLogFilterCacheHitsTotal, + receiptLogFilterCacheMissTotal: receiptLogFilterCacheMissTotal, + receiptLogFilterLogsReturned: receiptLogFilterLogsReturned, + mainThreadPhase: mainThreadPhase, + transactionPhaseTimerFactory: transactionPhaseTimerFactory, } if config.BackgroundMetricsScrapeInterval > 0 { @@ -484,6 +562,76 @@ func (m *CryptosimMetrics) ReportReceiptError() { m.receiptErrorsTotal.Add(context.Background(), 1) } +func (m *CryptosimMetrics) RecordReceiptReadDuration(seconds float64) { + if m == nil || m.receiptReadDuration == nil { + return + } + m.receiptReadDuration.Record(context.Background(), seconds) +} + +func (m *CryptosimMetrics) ReportReceiptRead() { + if m == nil || m.receiptReadsTotal == nil { + return + } + m.receiptReadsTotal.Add(context.Background(), 1) +} + +func (m *CryptosimMetrics) ReportReceiptCacheHit() { + if m == nil || m.receiptCacheHitsTotal == nil { + return + } + m.receiptCacheHitsTotal.Add(context.Background(), 1) +} + +func (m *CryptosimMetrics) ReportReceiptCacheMiss() { + if m == nil || m.receiptCacheMissesTotal == nil { + return + } + m.receiptCacheMissesTotal.Add(context.Background(), 1) +} + +func (m *CryptosimMetrics) ReportReceiptReadFound() { + if m == nil || m.receiptReadsFoundTotal == nil { + return + } + m.receiptReadsFoundTotal.Add(context.Background(), 1) +} + +func (m *CryptosimMetrics) ReportReceiptReadNotFound() { + if m == nil || m.receiptReadsNotFoundTotal == nil { + return + } + m.receiptReadsNotFoundTotal.Add(context.Background(), 1) +} + +func (m *CryptosimMetrics) RecordReceiptLogFilterDuration(seconds float64) { + if m == nil || m.receiptLogFilterDuration == nil { + return + } + m.receiptLogFilterDuration.Record(context.Background(), seconds) +} + +func (m *CryptosimMetrics) ReportLogFilterCacheHit() { + if m == nil || m.receiptLogFilterCacheHitsTotal == nil { + return + } + m.receiptLogFilterCacheHitsTotal.Add(context.Background(), 1) +} + +func (m *CryptosimMetrics) ReportLogFilterCacheMiss() { + if m == nil || m.receiptLogFilterCacheMissTotal == nil { + return + } + m.receiptLogFilterCacheMissTotal.Add(context.Background(), 1) +} + +func (m *CryptosimMetrics) RecordLogFilterLogsReturned(count int64) { + if m == nil || m.receiptLogFilterLogsReturned == nil { + return + } + m.receiptLogFilterLogsReturned.Record(context.Background(), count) +} + // startReceiptChannelDepthSampling periodically records the depth of the receipt channel. func (m *CryptosimMetrics) startReceiptChannelDepthSampling(ch <-chan *block, intervalSeconds int) { if m == nil || m.receiptChannelDepth == nil || intervalSeconds <= 0 || ch == nil { diff --git a/sei-db/state_db/bench/cryptosim/receipt.go b/sei-db/state_db/bench/cryptosim/receipt.go index 4ab348aa2c..60394b1086 100644 --- a/sei-db/state_db/bench/cryptosim/receipt.go +++ b/sei-db/state_db/bench/cryptosim/receipt.go @@ -32,6 +32,11 @@ const ( syntheticReceiptGasPriceSpan uint64 = 9_000_000_000 syntheticReceiptTransferBase uint64 = 1_000_000 syntheticReceiptTransferSpan uint64 = 10_000_000_000 + + // Multiplied by blockNumber then added to txIndex to produce a unique seed per + // transaction. Supports up to 1M txs per block before collisions. With int64, + // block numbers up to ~9.2 trillion are safe before overflow (~290k years at 1 block/sec). + syntheticTxIDBlockStride int64 = 1_000_000 ) var erc20TransferEventSignatureBytes = [hashLen]byte{ @@ -41,6 +46,27 @@ var erc20TransferEventSignatureBytes = [hashLen]byte{ 0x28, 0xf5, 0x5a, 0x4d, 0xf5, 0x23, 0xb3, 0xef, } +// SyntheticTxHash returns a deterministic 32-byte tx hash for a given (blockNumber, txIndex) pair. +// +// It uses CannedRandom.SeededBytes, which is a pure read from the pre-generated buffer — no +// internal state is advanced, and the result depends only on the CannedRandom's seed/buffer +// and the inputs. This means any goroutine with a CannedRandom created from the same +// (seed, bufferSize) can reconstruct any tx hash from just the block number and tx index, +// without storing the hashes. Readers use this to compute query targets on the fly: +// +// validRange = [max(1, latestBlock - keepRecent + 1), latestBlock] +// randomBlock = pick from validRange +// randomTxIdx = pick from [0, txsPerBlock) +// txHash = SyntheticTxHash(crand, randomBlock, randomTxIdx) +// +// The hash automatically becomes invalid (returns no result) once the corresponding +// parquet file is pruned, so readers never need to track which hashes are live. +func SyntheticTxHash(crand *CannedRandom, blockNumber uint64, txIndex uint32) []byte { + //nolint:gosec // block numbers and tx indices won't exceed int64 in benchmarks + txID := int64(blockNumber)*syntheticTxIDBlockStride + int64(txIndex) + return crand.SeededBytes(hashLen, txID) +} + // BuildERC20TransferReceiptFromTxn produces a plausible successful ERC20 transfer receipt from a transaction. func BuildERC20TransferReceiptFromTxn( crand *CannedRandom, @@ -137,7 +163,7 @@ func BuildERC20TransferReceipt( TxType: txType, CumulativeGasUsed: cumulativeGasUsed, ContractAddress: contractAddressHex, - TxHashHex: BytesToHex(crand.Bytes(hashLen)), + TxHashHex: BytesToHex(SyntheticTxHash(crand, blockNumber, txIndex)), GasUsed: gasUsed, EffectiveGasPrice: effectiveGasPrice, BlockNumber: blockNumber, diff --git a/sei-db/state_db/bench/cryptosim/receipt_test.go b/sei-db/state_db/bench/cryptosim/receipt_test.go index f1e61109fb..367264c094 100644 --- a/sei-db/state_db/bench/cryptosim/receipt_test.go +++ b/sei-db/state_db/bench/cryptosim/receipt_test.go @@ -82,6 +82,47 @@ func TestBuildERC20TransferReceipt_InvalidInputs(t *testing.T) { } } +func TestSyntheticTxHashDeterminism(t *testing.T) { + crand1 := NewCannedRandom(1<<20, 42) + crand2 := NewCannedRandom(1<<20, 42) + + block := uint64(500_000) + txIdx := uint32(7) + + hash1 := SyntheticTxHash(crand1, block, txIdx) + hash2 := SyntheticTxHash(crand2, block, txIdx) + + if len(hash1) != 32 { + t.Fatalf("expected 32 bytes, got %d", len(hash1)) + } + for i := range hash1 { + if hash1[i] != hash2[i] { + t.Fatal("same (seed, bufferSize, block, txIdx) must produce identical hashes") + } + } + + // Same call again on the same instance must be stable (SeededBytes is stateless). + hash3 := SyntheticTxHash(crand1, block, txIdx) + for i := range hash1 { + if hash1[i] != hash3[i] { + t.Fatal("repeated calls with same inputs must return identical hashes") + } + } + + // Different (block, txIdx) must produce a different hash. + other := SyntheticTxHash(crand1, block, txIdx+1) + same := true + for i := range hash1 { + if hash1[i] != other[i] { + same = false + break + } + } + if same { + t.Fatal("different (block, txIdx) should produce different hashes") + } +} + // Regression test: account keys with EVMKeyCode prefix must still be accepted. func TestBuildERC20TransferReceipt_EVMKeyCodeAccounts(t *testing.T) { crand := NewCannedRandom(1<<20, 42) diff --git a/sei-db/state_db/bench/cryptosim/reciept_store_simulator.go b/sei-db/state_db/bench/cryptosim/reciept_store_simulator.go index 309c03667e..b291b611bf 100644 --- a/sei-db/state_db/bench/cryptosim/reciept_store_simulator.go +++ b/sei-db/state_db/bench/cryptosim/reciept_store_simulator.go @@ -4,10 +4,12 @@ import ( "context" "fmt" "path/filepath" + "sync" "time" "github.com/ethereum/go-ethereum/common" ethtypes "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/eth/filters" sdk "github.com/sei-protocol/sei-chain/sei-cosmos/types" dbconfig "github.com/sei-protocol/sei-chain/sei-db/config" "github.com/sei-protocol/sei-chain/sei-db/ledger_db/receipt" @@ -15,8 +17,91 @@ import ( evmtypes "github.com/sei-protocol/sei-chain/x/evm/types" ) -// A simulated receipt store using the real production receipt.ReceiptStore -// (cached parquet backend with WAL, flush, rotation, and pruning). +const ( + // Must be larger than cacheWindow * TransactionsPerBlock so that the + // oldest ring entries have aged past the cache window, enabling duckdb- + // only reads to find targets. With the default cache window of ~1000 + // blocks and 1024 txns/block the minimum is ~1.02M; 3M gives comfortable + // headroom for both cache and duckdb read modes. + defaultTxHashRingSize = 3_000_000 +) + +// txHashEntry stores a written tx hash along with its block and contract address, +// used by reader goroutines to generate realistic log filter queries. +type txHashEntry struct { + txHash common.Hash + blockNumber uint64 + contractAddress common.Address +} + +// txHashRing is a fixed-size ring buffer of recently written tx hashes. +// Writers call Push from the main loop; readers call RandomEntry from goroutines. +type txHashRing struct { + mu sync.RWMutex + entries []txHashEntry + size int + head int + count int +} + +func newTxHashRing(size int) *txHashRing { + return &txHashRing{ + entries: make([]txHashEntry, size), + size: size, + } +} + +// Push appends a tx hash entry to the ring, overwriting the oldest entry when full. +func (r *txHashRing) Push(txHash common.Hash, blockNumber uint64, contractAddress common.Address) { + r.mu.Lock() + defer r.mu.Unlock() + r.entries[r.head] = txHashEntry{ + txHash: txHash, + blockNumber: blockNumber, + contractAddress: contractAddress, + } + r.head = (r.head + 1) % r.size + if r.count < r.size { + r.count++ + } +} + +// RandomEntry returns a random entry from the ring, using CannedRandom to +// avoid potential rand.Rand hotspots under high-concurrency benchmarks. +func (r *txHashRing) RandomEntry(crand *CannedRandom) *txHashEntry { + r.mu.RLock() + defer r.mu.RUnlock() + if r.count == 0 { + return nil + } + idx := int(crand.Int64Range(0, int64(r.count))) + entry := r.entries[idx] + return &entry +} + +const maxRingSampleAttempts = 100 + +// RandomEntryInBlockRange samples a random entry whose blockNumber falls within +// [minBlock, maxBlock]. Returns nil if no matching entry is found after a +// bounded number of attempts. +func (r *txHashRing) RandomEntryInBlockRange(crand *CannedRandom, minBlock, maxBlock uint64) *txHashEntry { + r.mu.RLock() + defer r.mu.RUnlock() + if r.count == 0 { + return nil + } + for range maxRingSampleAttempts { + idx := int(crand.Int64Range(0, int64(r.count))) + entry := r.entries[idx] + if entry.blockNumber >= minBlock && entry.blockNumber <= maxBlock { + return &entry + } + } + return nil +} + +// A simulated receipt store with concurrent reads, writes, and pruning +// backed by the production receipt.ReceiptStore (parquet + ledger cache). type RecieptStoreSimulator struct { ctx context.Context cancel context.CancelFunc @@ -25,17 +110,29 @@ type RecieptStoreSimulator struct { recieptsChan chan *block - store receipt.ReceiptStore - metrics *CryptosimMetrics + store receipt.ReceiptStore + crand *CannedRandom + txRing *txHashRing + metrics *CryptosimMetrics + receiptCacheWindowBlocks uint64 } // Creates a new receipt store simulator backed by the production ReceiptStore -// (parquet backend + ledger cache), matching the real node write path. +// (parquet backend + ledger cache), with optional concurrent reader goroutines. +// +// The caller must supply a CannedRandom instance (typically via Clone) that +// shares the same (seed, bufferSize) as the block builder so that +// SyntheticTxHash reproduces the hashes the write path stored. +// +// Receipt-by-hash reads reconstruct tx hashes on the fly via SyntheticTxHash +// (no storage needed). Log filter reads use the ring buffer to sample contract +// addresses written by the write path. See SyntheticTxHash in receipt.go for details. func NewRecieptStoreSimulator( ctx context.Context, config *CryptoSimConfig, recieptsChan chan *block, metrics *CryptosimMetrics, + crand *CannedRandom, ) (*RecieptStoreSimulator, error) { derivedCtx, cancel := context.WithCancel(ctx) @@ -47,21 +144,36 @@ func NewRecieptStoreSimulator( } // nil StoreKey is safe: the parquet write path never touches the legacy KV store. - store, err := receipt.NewReceiptStore(storeCfg, nil) + // Cryptosim passes its metrics as a read observer so cache hits/misses are measured + // at the cache wrapper, which is the only layer that can distinguish them reliably. + store, err := receipt.NewReceiptStoreWithReadObserver(storeCfg, nil, metrics) if err != nil { cancel() return nil, fmt.Errorf("failed to create receipt store: %w", err) } + txRing := newTxHashRing(defaultTxHashRingSize) + r := &RecieptStoreSimulator{ - ctx: derivedCtx, - cancel: cancel, - config: config, - recieptsChan: recieptsChan, - store: store, - metrics: metrics, + ctx: derivedCtx, + cancel: cancel, + config: config, + recieptsChan: recieptsChan, + store: store, + crand: crand, + txRing: txRing, + metrics: metrics, + receiptCacheWindowBlocks: receipt.EstimatedReceiptCacheWindowBlocks(store), } go r.mainLoop() + + if config.ReceiptReadConcurrency > 0 && config.ReceiptReadsPerSecond > 0 { + r.startReceiptReaders() + } + if config.ReceiptLogFilterReadConcurrency > 0 && config.ReceiptLogFilterReadsPerSecond > 0 { + r.startLogFilterReaders() + } + return r, nil } @@ -82,13 +194,19 @@ func (r *RecieptStoreSimulator) mainLoop() { } // Processes a block of receipts using the production ReceiptStore.SetReceipts path, -// which writes to parquet (WAL + buffer + rotation) and populates the ledger cache. +// then populates the ring buffer with contract addresses for log filter reads. func (r *RecieptStoreSimulator) processBlock(blk *block) { blockNumber := uint64(blk.BlockNumber()) //nolint:gosec records := make([]receipt.ReceiptRecord, 0, len(blk.reciepts)) var marshalErrors int64 + type ringEntry struct { + txHash common.Hash + contractAddress common.Address + } + ringEntries := make([]ringEntry, 0, len(blk.reciepts)) + for _, rcpt := range blk.reciepts { if rcpt == nil { continue @@ -107,6 +225,11 @@ func (r *RecieptStoreSimulator) processBlock(blk *block) { Receipt: rcpt, ReceiptBytes: receiptBytes, }) + + ringEntries = append(ringEntries, ringEntry{ + txHash: txHash, + contractAddress: common.HexToAddress(rcpt.ContractAddress), + }) } for range marshalErrors { @@ -114,8 +237,6 @@ func (r *RecieptStoreSimulator) processBlock(blk *block) { } if len(records) > 0 { - // Build a minimal sdk.Context with the block height set. - // The parquet write path only uses ctx.BlockHeight() and ctx.Context(). sdkCtx := sdk.NewContext(nil, tmproto.Header{Height: int64(blockNumber)}, false) //nolint:gosec start := time.Now() @@ -128,11 +249,198 @@ func (r *RecieptStoreSimulator) processBlock(blk *block) { r.metrics.ReportReceiptsWritten(int64(len(records))) } + for _, entry := range ringEntries { + r.txRing.Push(entry.txHash, blockNumber, entry.contractAddress) + } + if err := r.store.SetLatestVersion(int64(blockNumber)); err != nil { //nolint:gosec fmt.Printf("failed to update latest version for block %d: %v\n", blockNumber, err) } } +// startReceiptReaders launches dedicated goroutines for receipt-by-hash lookups. +func (r *RecieptStoreSimulator) startReceiptReaders() { + readerCount := r.config.ReceiptReadConcurrency + totalReadsPerSec := r.config.ReceiptReadsPerSecond + if totalReadsPerSec <= 0 { + totalReadsPerSec = 1000 + } + + readsPerReader := totalReadsPerSec / readerCount + if readsPerReader < 1 { + readsPerReader = 1 + } + + for i := 0; i < readerCount; i++ { + readerCrand := r.crand.Clone(true) + go r.tickerLoop(readsPerReader, readerCrand, r.executeReceiptRead) + } + + fmt.Printf("Started %d receipt reader goroutines (%d reads/sec each)\n", + readerCount, readsPerReader) +} + +// startLogFilterReaders launches dedicated goroutines for log filter (eth_getLogs) queries. +func (r *RecieptStoreSimulator) startLogFilterReaders() { + readerCount := r.config.ReceiptLogFilterReadConcurrency + totalReadsPerSec := r.config.ReceiptLogFilterReadsPerSecond + if totalReadsPerSec <= 0 { + totalReadsPerSec = 100 + } + + readsPerReader := totalReadsPerSec / readerCount + if readsPerReader < 1 { + readsPerReader = 1 + } + + for i := 0; i < readerCount; i++ { + readerCrand := r.crand.Clone(true) + go r.tickerLoop(readsPerReader, readerCrand, r.executeLogFilterRead) + } + + fmt.Printf("Started %d log filter reader goroutines (%d reads/sec each)\n", + readerCount, readsPerReader) +} + +func (r *RecieptStoreSimulator) tickerLoop(readsPerSecond int, crand *CannedRandom, fn func(*CannedRandom)) { + interval := time.Second / time.Duration(readsPerSecond) + ticker := time.NewTicker(interval) + defer ticker.Stop() + + for { + select { + case <-r.ctx.Done(): + return + case <-ticker.C: + fn(crand) + } + } +} + +// executeReceiptRead samples a tx hash from the ring and queries GetReceipt. +// +// ReceiptReadMode controls which blocks are targeted: +// - "cache": only blocks within the cache window (guaranteed cache hit). +// - "duckdb": only blocks older than the cache window (guaranteed cache miss). +func (r *RecieptStoreSimulator) executeReceiptRead(crand *CannedRandom) { + latestBlock := r.store.LatestVersion() + if latestBlock <= 0 { + return + } + latest := uint64(latestBlock) //nolint:gosec + cacheWindow := r.receiptCacheWindowBlocks + + var entry *txHashEntry + switch r.config.ReceiptReadMode { + case "cache": + minBlock := uint64(0) + if latest > cacheWindow { + minBlock = latest - cacheWindow + } + entry = r.txRing.RandomEntryInBlockRange(crand, minBlock, latest) + case "duckdb": + maxBlock := uint64(0) + if latest > cacheWindow { + maxBlock = latest - cacheWindow - 1 + } + entry = r.txRing.RandomEntryInBlockRange(crand, 0, maxBlock) + } + if entry == nil { + return + } + + r.metrics.ReportReceiptRead() + + sdkCtx := sdk.NewContext(nil, tmproto.Header{}, false) + start := time.Now() + rcpt, err := r.store.GetReceipt(sdkCtx, entry.txHash) + r.metrics.RecordReceiptReadDuration(time.Since(start).Seconds()) + + if err != nil { + r.metrics.ReportReceiptError() + return + } + if rcpt != nil { + r.metrics.ReportReceiptReadFound() + return + } + r.metrics.ReportReceiptReadNotFound() +} + +// executeLogFilterRead simulates an eth_getLogs query filtering by contract address +// over a configurable block range. Contract addresses come from the ring buffer. +// +// ReceiptLogFilterReadMode controls which blocks are targeted: +// - "cache": block range falls entirely within the cache window (DuckDB skipped). +// - "duckdb": block range falls entirely before the cache window (cache miss). +func (r *RecieptStoreSimulator) executeLogFilterRead(crand *CannedRandom) { + entry := r.txRing.RandomEntry(crand) + if entry == nil { + return + } + + latestVersion := r.store.LatestVersion() + if latestVersion <= 0 { + return + } + + rangeSize := uint64(crand.Int64Range( + int64(r.config.ReceiptLogFilterMinBlockRange), + int64(r.config.ReceiptLogFilterMaxBlockRange)+1, + )) //nolint:gosec + latest := uint64(latestVersion) //nolint:gosec + cacheWindow := r.receiptCacheWindowBlocks + + var fromBlock, toBlock uint64 + + switch r.config.ReceiptLogFilterReadMode { + case "cache": + cacheMin := uint64(0) + if latest > cacheWindow { + cacheMin = latest - cacheWindow + } + if latest <= cacheMin { + return + } + fromBlock = uint64(crand.Int64Range(int64(cacheMin), int64(latest)+1)) //nolint:gosec + toBlock = fromBlock + rangeSize + if toBlock > latest { + toBlock = latest + } + case "duckdb": + if latest <= cacheWindow { + return + } + coldMax := latest - cacheWindow - 1 + earliestBlock := uint64(1) + if r.config.ReceiptKeepRecent > 0 && latest > uint64(r.config.ReceiptKeepRecent) { //nolint:gosec + earliestBlock = latest - uint64(r.config.ReceiptKeepRecent) + 1 //nolint:gosec + } + if coldMax < earliestBlock { + return + } + fromBlock = uint64(crand.Int64Range(int64(earliestBlock), int64(coldMax)+1)) //nolint:gosec + toBlock = fromBlock + rangeSize + if toBlock > coldMax { + toBlock = coldMax + } + } + + crit := filters.FilterCriteria{ + Addresses: []common.Address{entry.contractAddress}, + } + + sdkCtx := sdk.NewContext(nil, tmproto.Header{}, false) + start := time.Now() + logs, err := r.store.FilterLogs(sdkCtx, fromBlock, toBlock, crit) + r.metrics.RecordReceiptLogFilterDuration(time.Since(start).Seconds()) + r.metrics.RecordLogFilterLogsReturned(int64(len(logs))) + + if err != nil { + r.metrics.ReportReceiptError() + } +} + // convertLogsForTx converts evmtypes.Log entries to ethtypes.Log entries. // Mirrors receipt.getLogsForTx. func convertLogsForTx(rcpt *evmtypes.Receipt, logStartIndex uint) []*ethtypes.Log { diff --git a/sei-db/state_db/bench/cryptosim/reciept_store_simulator_test.go b/sei-db/state_db/bench/cryptosim/reciept_store_simulator_test.go new file mode 100644 index 0000000000..3695c6a810 --- /dev/null +++ b/sei-db/state_db/bench/cryptosim/reciept_store_simulator_test.go @@ -0,0 +1,63 @@ +package cryptosim + +import ( + "testing" + + "github.com/ethereum/go-ethereum/common" +) + +func TestRandomEntryInBlockRange(t *testing.T) { + ring := newTxHashRing(100) + crand := NewCannedRandom(42, 1024*1024) + + for i := uint64(1); i <= 50; i++ { + ring.Push(common.BigToHash(common.Big0), i, common.Address{}) + } + + tests := []struct { + name string + minBlock uint64 + maxBlock uint64 + wantNil bool + }{ + { + name: "range covers all entries", + minBlock: 1, + maxBlock: 50, + wantNil: false, + }, + { + name: "range covers recent entries only", + minBlock: 40, + maxBlock: 50, + wantNil: false, + }, + { + name: "range covers old entries only", + minBlock: 1, + maxBlock: 10, + wantNil: false, + }, + { + name: "range outside all entries", + minBlock: 100, + maxBlock: 200, + wantNil: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + entry := ring.RandomEntryInBlockRange(crand, tt.minBlock, tt.maxBlock) + if tt.wantNil && entry != nil { + t.Fatalf("expected nil, got block %d", entry.blockNumber) + } + if !tt.wantNil && entry == nil { + t.Fatalf("expected entry in [%d,%d], got nil", tt.minBlock, tt.maxBlock) + } + if entry != nil && (entry.blockNumber < tt.minBlock || entry.blockNumber > tt.maxBlock) { + t.Fatalf("expected block in [%d,%d], got %d", tt.minBlock, tt.maxBlock, entry.blockNumber) + } + }) + } +} From 43c8c0950d9d4e0e8878de7f7fd71b128908f735 Mon Sep 17 00:00:00 2001 From: Jeremy Wei Date: Mon, 30 Mar 2026 15:51:48 -0400 Subject: [PATCH 04/28] switch cryptosim receipt reads to cache mode, fix log filter cache hit reporting Made-with: Cursor --- sei-db/ledger_db/receipt/cached_receipt_store.go | 1 + sei-db/state_db/bench/cryptosim/config/reciept-store.json | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/sei-db/ledger_db/receipt/cached_receipt_store.go b/sei-db/ledger_db/receipt/cached_receipt_store.go index d7aeb70be5..cce380c2b2 100644 --- a/sei-db/ledger_db/receipt/cached_receipt_store.go +++ b/sei-db/ledger_db/receipt/cached_receipt_store.go @@ -119,6 +119,7 @@ func (s *cachedReceiptStore) FilterLogs(ctx sdk.Context, fromBlock, toBlock uint cacheMin := s.cache.LogMinBlock() if cacheMin > 0 && fromBlock >= cacheMin { + s.reportLogFilterCacheHit() sortLogs(cacheLogs) return cacheLogs, nil } diff --git a/sei-db/state_db/bench/cryptosim/config/reciept-store.json b/sei-db/state_db/bench/cryptosim/config/reciept-store.json index f433ebcc91..748a0d4157 100644 --- a/sei-db/state_db/bench/cryptosim/config/reciept-store.json +++ b/sei-db/state_db/bench/cryptosim/config/reciept-store.json @@ -9,10 +9,10 @@ "GenerateReceipts": true, "ReceiptReadConcurrency": 4, "ReceiptReadsPerSecond": 1000, - "ReceiptReadMode": "duckdb", + "ReceiptReadMode": "cache", "ReceiptLogFilterReadConcurrency": 4, "ReceiptLogFilterReadsPerSecond": 200, - "ReceiptLogFilterReadMode": "duckdb", + "ReceiptLogFilterReadMode": "cache", "ReceiptLogFilterMinBlockRange": 1, "ReceiptLogFilterMaxBlockRange": 10 } From fc84e9d08f58eb786413be1cc0ca41f03bef08ca Mon Sep 17 00:00:00 2001 From: Jeremy Wei Date: Mon, 30 Mar 2026 16:04:51 -0400 Subject: [PATCH 05/28] add cache scan duration and size metrics to diagnose read throughput degradation Instruments the ledger cache layer to measure: - FilterLogs cache scan duration (separate from backend) - GetReceipt cache lookup duration (includes clone cost) - Total log and receipt counts across cache chunks Adds corresponding Grafana dashboard panels. Made-with: Cursor --- .../dashboards/cryptosim-dashboard.json | 156 ++++++++++++++++++ .../ledger_db/receipt/cached_receipt_store.go | 28 +++- sei-db/ledger_db/receipt/receipt_cache.go | 32 ++++ sei-db/ledger_db/receipt/receipt_store.go | 4 + .../bench/cryptosim/cryptosim_metrics.go | 59 +++++++ 5 files changed, 278 insertions(+), 1 deletion(-) diff --git a/docker/monitornode/dashboards/cryptosim-dashboard.json b/docker/monitornode/dashboards/cryptosim-dashboard.json index 5a00b5cd02..272f7d507e 100644 --- a/docker/monitornode/dashboards/cryptosim-dashboard.json +++ b/docker/monitornode/dashboards/cryptosim-dashboard.json @@ -3816,6 +3816,162 @@ ], "title": "Logs Returned Per Query", "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "axisBorderShow": false, "axisCenteredZero": false, "axisColorMode": "text", + "axisLabel": "", "axisPlacement": "auto", "barAlignment": 0, "barWidthFactor": 0.6, + "drawStyle": "line", "fillOpacity": 0, "gradientMode": "none", + "hideFrom": { "legend": false, "tooltip": false, "viz": false }, + "insertNulls": false, "lineInterpolation": "linear", "lineWidth": 1, + "pointSize": 5, "scaleDistribution": { "type": "linear" }, + "showPoints": "auto", "showValues": false, "spanNulls": false, + "stacking": { "group": "A", "mode": "none" }, + "thresholdsStyle": { "mode": "off" } + }, + "mappings": [], + "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": 0 }, { "color": "red", "value": 80 }] }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 48 }, + "id": 290, + "options": { + "legend": { "calcs": [], "displayMode": "list", "placement": "bottom", "showLegend": true }, + "tooltip": { "hideZeros": false, "mode": "single", "sort": "none" } + }, + "pluginVersion": "12.4.0", + "targets": [ + { "editorMode": "code", "expr": "histogram_quantile(0.99, rate(cryptosim_receipt_cache_filter_scan_duration_seconds_bucket[$__rate_interval]))", "legendFormat": "p99", "range": true, "refId": "A" }, + { "editorMode": "code", "expr": "histogram_quantile(0.95, rate(cryptosim_receipt_cache_filter_scan_duration_seconds_bucket[$__rate_interval]))", "legendFormat": "p95", "range": true, "refId": "B" }, + { "editorMode": "code", "expr": "histogram_quantile(0.50, rate(cryptosim_receipt_cache_filter_scan_duration_seconds_bucket[$__rate_interval]))", "legendFormat": "p50", "range": true, "refId": "C" }, + { "editorMode": "code", "expr": "rate(cryptosim_receipt_cache_filter_scan_duration_seconds_sum[$__rate_interval]) / rate(cryptosim_receipt_cache_filter_scan_duration_seconds_count[$__rate_interval])", "legendFormat": "average", "range": true, "refId": "D" } + ], + "title": "Cache Filter Scan Duration", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "axisBorderShow": false, "axisCenteredZero": false, "axisColorMode": "text", + "axisLabel": "", "axisPlacement": "auto", "barAlignment": 0, "barWidthFactor": 0.6, + "drawStyle": "line", "fillOpacity": 0, "gradientMode": "none", + "hideFrom": { "legend": false, "tooltip": false, "viz": false }, + "insertNulls": false, "lineInterpolation": "linear", "lineWidth": 1, + "pointSize": 5, "scaleDistribution": { "type": "linear" }, + "showPoints": "auto", "showValues": false, "spanNulls": false, + "stacking": { "group": "A", "mode": "none" }, + "thresholdsStyle": { "mode": "off" } + }, + "mappings": [], + "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": 0 }, { "color": "red", "value": 80 }] }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 48 }, + "id": 291, + "options": { + "legend": { "calcs": [], "displayMode": "list", "placement": "bottom", "showLegend": true }, + "tooltip": { "hideZeros": false, "mode": "single", "sort": "none" } + }, + "pluginVersion": "12.4.0", + "targets": [ + { "editorMode": "code", "expr": "histogram_quantile(0.99, rate(cryptosim_receipt_cache_get_duration_seconds_bucket[$__rate_interval]))", "legendFormat": "p99", "range": true, "refId": "A" }, + { "editorMode": "code", "expr": "histogram_quantile(0.95, rate(cryptosim_receipt_cache_get_duration_seconds_bucket[$__rate_interval]))", "legendFormat": "p95", "range": true, "refId": "B" }, + { "editorMode": "code", "expr": "histogram_quantile(0.50, rate(cryptosim_receipt_cache_get_duration_seconds_bucket[$__rate_interval]))", "legendFormat": "p50", "range": true, "refId": "C" }, + { "editorMode": "code", "expr": "rate(cryptosim_receipt_cache_get_duration_seconds_sum[$__rate_interval]) / rate(cryptosim_receipt_cache_get_duration_seconds_count[$__rate_interval])", "legendFormat": "average", "range": true, "refId": "D" } + ], + "title": "Cache Get Duration", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "axisBorderShow": false, "axisCenteredZero": false, "axisColorMode": "text", + "axisLabel": "", "axisPlacement": "auto", "barAlignment": 0, "barWidthFactor": 0.6, + "drawStyle": "line", "fillOpacity": 0, "gradientMode": "none", + "hideFrom": { "legend": false, "tooltip": false, "viz": false }, + "insertNulls": false, "lineInterpolation": "linear", "lineWidth": 1, + "pointSize": 5, "scaleDistribution": { "type": "linear" }, + "showPoints": "auto", "showValues": false, "spanNulls": false, + "stacking": { "group": "A", "mode": "none" }, + "thresholdsStyle": { "mode": "off" } + }, + "mappings": [], + "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": 0 }, { "color": "red", "value": 80 }] } + }, + "overrides": [] + }, + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 56 }, + "id": 292, + "options": { + "legend": { "calcs": [], "displayMode": "list", "placement": "bottom", "showLegend": true }, + "tooltip": { "hideZeros": false, "mode": "single", "sort": "none" } + }, + "pluginVersion": "12.4.0", + "targets": [ + { "editorMode": "code", "expr": "cryptosim_receipt_cache_log_count", "legendFormat": "log entries", "range": true, "refId": "A" } + ], + "title": "Cache Log Count", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "axisBorderShow": false, "axisCenteredZero": false, "axisColorMode": "text", + "axisLabel": "", "axisPlacement": "auto", "barAlignment": 0, "barWidthFactor": 0.6, + "drawStyle": "line", "fillOpacity": 0, "gradientMode": "none", + "hideFrom": { "legend": false, "tooltip": false, "viz": false }, + "insertNulls": false, "lineInterpolation": "linear", "lineWidth": 1, + "pointSize": 5, "scaleDistribution": { "type": "linear" }, + "showPoints": "auto", "showValues": false, "spanNulls": false, + "stacking": { "group": "A", "mode": "none" }, + "thresholdsStyle": { "mode": "off" } + }, + "mappings": [], + "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": 0 }, { "color": "red", "value": 80 }] } + }, + "overrides": [] + }, + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 56 }, + "id": 293, + "options": { + "legend": { "calcs": [], "displayMode": "list", "placement": "bottom", "showLegend": true }, + "tooltip": { "hideZeros": false, "mode": "single", "sort": "none" } + }, + "pluginVersion": "12.4.0", + "targets": [ + { "editorMode": "code", "expr": "cryptosim_receipt_cache_receipt_count", "legendFormat": "receipts", "range": true, "refId": "A" } + ], + "title": "Cache Receipt Count", + "type": "timeseries" } ], "title": "Receipts", diff --git a/sei-db/ledger_db/receipt/cached_receipt_store.go b/sei-db/ledger_db/receipt/cached_receipt_store.go index cce380c2b2..7af5a6d0c4 100644 --- a/sei-db/ledger_db/receipt/cached_receipt_store.go +++ b/sei-db/ledger_db/receipt/cached_receipt_store.go @@ -3,6 +3,7 @@ package receipt import ( "sort" "sync" + "time" "github.com/ethereum/go-ethereum/common" ethtypes "github.com/ethereum/go-ethereum/core/types" @@ -86,7 +87,10 @@ func (s *cachedReceiptStore) SetEarliestVersion(version int64) error { } func (s *cachedReceiptStore) GetReceipt(ctx sdk.Context, txHash common.Hash) (*types.Receipt, error) { - if receipt, ok := s.cache.GetReceipt(txHash); ok { + start := time.Now() + receipt, ok := s.cache.GetReceipt(txHash) + s.reportCacheGetDuration(time.Since(start).Seconds()) + if ok { s.reportCacheHit() return receipt, nil } @@ -108,6 +112,7 @@ func (s *cachedReceiptStore) SetReceipts(ctx sdk.Context, receipts []ReceiptReco return err } s.cacheReceipts(receipts) + s.reportCacheCounts() return nil } @@ -115,7 +120,9 @@ func (s *cachedReceiptStore) SetReceipts(ctx sdk.Context, receipts []ReceiptReco // When the cache fully covers the requested range the backend is skipped // entirely, avoiding an unnecessary DuckDB/parquet query for recent blocks. func (s *cachedReceiptStore) FilterLogs(ctx sdk.Context, fromBlock, toBlock uint64, crit filters.FilterCriteria) ([]*ethtypes.Log, error) { + scanStart := time.Now() cacheLogs := s.cache.FilterLogs(fromBlock, toBlock, crit) + s.reportCacheFilterScanDuration(time.Since(scanStart).Seconds()) cacheMin := s.cache.LogMinBlock() if cacheMin > 0 && fromBlock >= cacheMin { @@ -281,3 +288,22 @@ func (s *cachedReceiptStore) reportLogFilterCacheMiss() { s.readObserver.ReportLogFilterCacheMiss() } } + +func (s *cachedReceiptStore) reportCacheFilterScanDuration(seconds float64) { + if s.readObserver != nil { + s.readObserver.RecordCacheFilterScanDuration(seconds) + } +} + +func (s *cachedReceiptStore) reportCacheGetDuration(seconds float64) { + if s.readObserver != nil { + s.readObserver.RecordCacheGetDuration(seconds) + } +} + +func (s *cachedReceiptStore) reportCacheCounts() { + if s.readObserver != nil { + s.readObserver.RecordCacheLogCount(s.cache.LogCount()) + s.readObserver.RecordCacheReceiptCount(s.cache.ReceiptCount()) + } +} diff --git a/sei-db/ledger_db/receipt/receipt_cache.go b/sei-db/ledger_db/receipt/receipt_cache.go index 8ec0a5c5c1..fe33ee5273 100644 --- a/sei-db/ledger_db/receipt/receipt_cache.go +++ b/sei-db/ledger_db/receipt/receipt_cache.go @@ -253,6 +253,38 @@ func (c *ledgerCache) LogMinBlock() uint64 { return min } +// LogCount returns the total number of individual log entries across all cache chunks. +func (c *ledgerCache) LogCount() int64 { + c.logMu.RLock() + defer c.logMu.RUnlock() + var total int64 + for i := 0; i < numCacheChunks; i++ { + chunk := c.logChunks[i].Load() + if chunk == nil { + continue + } + for _, logs := range chunk.logs { + total += int64(len(logs)) + } + } + return total +} + +// ReceiptCount returns the total number of receipts across all cache chunks. +func (c *ledgerCache) ReceiptCount() int64 { + c.receiptMu.RLock() + defer c.receiptMu.RUnlock() + var total int64 + for i := int32(0); i < numCacheChunks; i++ { + chunk := c.receiptChunks[i].Load() + if chunk == nil { + continue + } + total += int64(len(chunk.receiptIndex)) + } + return total +} + // matchLog checks if a log matches the filter criteria. func matchLog(lg *ethtypes.Log, crit filters.FilterCriteria) bool { // Check address filter diff --git a/sei-db/ledger_db/receipt/receipt_store.go b/sei-db/ledger_db/receipt/receipt_store.go index 33f568bb2c..02c06bcc2f 100644 --- a/sei-db/ledger_db/receipt/receipt_store.go +++ b/sei-db/ledger_db/receipt/receipt_store.go @@ -60,6 +60,10 @@ type ReceiptReadObserver interface { ReportReceiptCacheMiss() ReportLogFilterCacheHit() ReportLogFilterCacheMiss() + RecordCacheFilterScanDuration(seconds float64) + RecordCacheGetDuration(seconds float64) + RecordCacheLogCount(count int64) + RecordCacheReceiptCount(count int64) } type receiptStore struct { diff --git a/sei-db/state_db/bench/cryptosim/cryptosim_metrics.go b/sei-db/state_db/bench/cryptosim/cryptosim_metrics.go index 7d8a2531b4..3867fe9293 100644 --- a/sei-db/state_db/bench/cryptosim/cryptosim_metrics.go +++ b/sei-db/state_db/bench/cryptosim/cryptosim_metrics.go @@ -69,6 +69,10 @@ type CryptosimMetrics struct { receiptLogFilterCacheHitsTotal metric.Int64Counter receiptLogFilterCacheMissTotal metric.Int64Counter receiptLogFilterLogsReturned metric.Int64Histogram + cacheFilterScanDuration metric.Float64Histogram + cacheGetDuration metric.Float64Histogram + cacheLogCount metric.Int64Gauge + cacheReceiptCount metric.Int64Gauge mainThreadPhase *metrics.PhaseTimer transactionPhaseTimerFactory *metrics.PhaseTimerFactory @@ -248,6 +252,29 @@ func NewCryptosimMetrics( metric.WithUnit("{count}"), ) + cacheFilterScanDuration, _ := meter.Float64Histogram( + "cryptosim_receipt_cache_filter_scan_duration_seconds", + metric.WithDescription("Time spent scanning the in-memory log cache during FilterLogs (excludes backend)"), + metric.WithExplicitBucketBoundaries(receiptReadLatencyBuckets...), + metric.WithUnit("s"), + ) + cacheGetDuration, _ := meter.Float64Histogram( + "cryptosim_receipt_cache_get_duration_seconds", + metric.WithDescription("Time spent in cache.GetReceipt (includes clone cost, excludes backend)"), + metric.WithExplicitBucketBoundaries(receiptReadLatencyBuckets...), + metric.WithUnit("s"), + ) + cacheLogCount, _ := meter.Int64Gauge( + "cryptosim_receipt_cache_log_count", + metric.WithDescription("Total number of log entries across all ledger cache chunks"), + metric.WithUnit("{count}"), + ) + cacheReceiptCount, _ := meter.Int64Gauge( + "cryptosim_receipt_cache_receipt_count", + metric.WithDescription("Total number of receipts across all ledger cache chunks"), + metric.WithUnit("{count}"), + ) + mainThreadPhase := dbPhaseTimer if mainThreadPhase == nil { mainThreadPhase = metrics.NewPhaseTimer(meter, "seidb_main_thread") @@ -287,6 +314,10 @@ func NewCryptosimMetrics( receiptLogFilterCacheHitsTotal: receiptLogFilterCacheHitsTotal, receiptLogFilterCacheMissTotal: receiptLogFilterCacheMissTotal, receiptLogFilterLogsReturned: receiptLogFilterLogsReturned, + cacheFilterScanDuration: cacheFilterScanDuration, + cacheGetDuration: cacheGetDuration, + cacheLogCount: cacheLogCount, + cacheReceiptCount: cacheReceiptCount, mainThreadPhase: mainThreadPhase, transactionPhaseTimerFactory: transactionPhaseTimerFactory, } @@ -632,6 +663,34 @@ func (m *CryptosimMetrics) RecordLogFilterLogsReturned(count int64) { m.receiptLogFilterLogsReturned.Record(context.Background(), count) } +func (m *CryptosimMetrics) RecordCacheFilterScanDuration(seconds float64) { + if m == nil || m.cacheFilterScanDuration == nil { + return + } + m.cacheFilterScanDuration.Record(context.Background(), seconds) +} + +func (m *CryptosimMetrics) RecordCacheGetDuration(seconds float64) { + if m == nil || m.cacheGetDuration == nil { + return + } + m.cacheGetDuration.Record(context.Background(), seconds) +} + +func (m *CryptosimMetrics) RecordCacheLogCount(count int64) { + if m == nil || m.cacheLogCount == nil { + return + } + m.cacheLogCount.Record(context.Background(), count) +} + +func (m *CryptosimMetrics) RecordCacheReceiptCount(count int64) { + if m == nil || m.cacheReceiptCount == nil { + return + } + m.cacheReceiptCount.Record(context.Background(), count) +} + // startReceiptChannelDepthSampling periodically records the depth of the receipt channel. func (m *CryptosimMetrics) startReceiptChannelDepthSampling(ch <-chan *block, intervalSeconds int) { if m == nil || m.receiptChannelDepth == nil || intervalSeconds <= 0 || ch == nil { From cb5a881a20976d623f1f08f81a84b8437bc8b1b7 Mon Sep 17 00:00:00 2001 From: Jeremy Wei Date: Mon, 30 Mar 2026 16:21:14 -0400 Subject: [PATCH 06/28] use StableReceiptCacheWindowBlocks for cache-mode reads EstimatedReceiptCacheWindowBlocks (1000 blocks) overshoots the actual cache coverage after rotation (~500 blocks), causing ~70% of cache-mode log filter reads to miss and fall through to DuckDB. Made-with: Cursor --- sei-db/state_db/bench/cryptosim/reciept_store_simulator.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sei-db/state_db/bench/cryptosim/reciept_store_simulator.go b/sei-db/state_db/bench/cryptosim/reciept_store_simulator.go index b291b611bf..b869130744 100644 --- a/sei-db/state_db/bench/cryptosim/reciept_store_simulator.go +++ b/sei-db/state_db/bench/cryptosim/reciept_store_simulator.go @@ -163,7 +163,7 @@ func NewRecieptStoreSimulator( crand: crand, txRing: txRing, metrics: metrics, - receiptCacheWindowBlocks: receipt.EstimatedReceiptCacheWindowBlocks(store), + receiptCacheWindowBlocks: receipt.StableReceiptCacheWindowBlocks(store), } go r.mainLoop() From 02b9ce81d83f12e2b1442c882d7e75cfe3e8ac7b Mon Sep 17 00:00:00 2001 From: Jeremy Wei Date: Mon, 30 Mar 2026 16:23:37 -0400 Subject: [PATCH 07/28] add 10% safety buffer to cache-mode reads to avoid rotation boundary races Made-with: Cursor --- .../bench/cryptosim/reciept_store_simulator.go | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/sei-db/state_db/bench/cryptosim/reciept_store_simulator.go b/sei-db/state_db/bench/cryptosim/reciept_store_simulator.go index b869130744..20716fd19c 100644 --- a/sei-db/state_db/bench/cryptosim/reciept_store_simulator.go +++ b/sei-db/state_db/bench/cryptosim/reciept_store_simulator.go @@ -330,12 +330,14 @@ func (r *RecieptStoreSimulator) executeReceiptRead(crand *CannedRandom) { latest := uint64(latestBlock) //nolint:gosec cacheWindow := r.receiptCacheWindowBlocks + buffer := cacheWindow / 10 var entry *txHashEntry switch r.config.ReceiptReadMode { case "cache": + safeWindow := cacheWindow - buffer minBlock := uint64(0) - if latest > cacheWindow { - minBlock = latest - cacheWindow + if latest > safeWindow { + minBlock = latest - safeWindow } entry = r.txRing.RandomEntryInBlockRange(crand, minBlock, latest) case "duckdb": @@ -393,11 +395,13 @@ func (r *RecieptStoreSimulator) executeLogFilterRead(crand *CannedRandom) { var fromBlock, toBlock uint64 + buffer := cacheWindow / 10 switch r.config.ReceiptLogFilterReadMode { case "cache": + safeWindow := cacheWindow - buffer cacheMin := uint64(0) - if latest > cacheWindow { - cacheMin = latest - cacheWindow + if latest > safeWindow { + cacheMin = latest - safeWindow } if latest <= cacheMin { return From f1cbf27349d80ca4e51dccd08eb265979d905091 Mon Sep 17 00:00:00 2001 From: Jeremy Wei Date: Tue, 31 Mar 2026 09:29:18 -0400 Subject: [PATCH 08/28] allow WAL to be truncated --- sei-db/ledger_db/parquet/wal.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/sei-db/ledger_db/parquet/wal.go b/sei-db/ledger_db/parquet/wal.go index 24a4729332..498e8438fe 100644 --- a/sei-db/ledger_db/parquet/wal.go +++ b/sei-db/ledger_db/parquet/wal.go @@ -113,6 +113,8 @@ func NewWAL(dir string) (dbwal.GenericWAL[WALEntry], error) { encodeWALEntry, decodeWALEntry, dir, - dbwal.Config{}, + dbwal.Config{ + AllowEmpty: true, + }, ) } From a8fa2820877d7d8b14f56f17217cf5bec027adb3 Mon Sep 17 00:00:00 2001 From: Jeremy Wei Date: Tue, 31 Mar 2026 09:38:35 -0400 Subject: [PATCH 09/28] add unit tests for wal truncation --- sei-db/ledger_db/parquet/wal_test.go | 134 +++++++++++++++++++++++++++ 1 file changed, 134 insertions(+) create mode 100644 sei-db/ledger_db/parquet/wal_test.go diff --git a/sei-db/ledger_db/parquet/wal_test.go b/sei-db/ledger_db/parquet/wal_test.go new file mode 100644 index 0000000000..2a895f3bca --- /dev/null +++ b/sei-db/ledger_db/parquet/wal_test.go @@ -0,0 +1,134 @@ +package parquet + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +// walDirSize returns the total size of all files in dir (non-recursive). +func walDirSize(t *testing.T, dir string) int64 { + t.Helper() + entries, err := os.ReadDir(dir) + if os.IsNotExist(err) { + return 0 + } + require.NoError(t, err) + var total int64 + for _, e := range entries { + if e.IsDir() { + continue + } + info, err := e.Info() + require.NoError(t, err) + total += info.Size() + } + return total +} + +// TestClearWALActuallyFreesSpace uses the real tidwall/wal-backed WAL (not a +// mock) to verify that ClearWAL genuinely removes data from disk. This catches +// the bug where AllowEmpty=false caused TruncateFront to silently fail with +// ErrOutOfRange, leaving every WAL entry on disk forever. +func TestClearWALActuallyFreesSpace(t *testing.T) { + dir := t.TempDir() + + store, err := NewStore(StoreConfig{ + DBDirectory: dir, + MaxBlocksPerFile: 10, + }) + require.NoError(t, err) + t.Cleanup(func() { _ = store.Close() }) + + receipt := ReceiptRecord{ + TxHash: make([]byte, 32), + BlockNumber: 1, + ReceiptBytes: make([]byte, 512), + } + + // Write 10 blocks (fills one file, no rotation yet). + for block := uint64(1); block <= 10; block++ { + receipt.BlockNumber = block + require.NoError(t, store.WriteReceipts([]ReceiptInput{{ + BlockNumber: block, + Receipt: receipt, + ReceiptBytes: receipt.ReceiptBytes, + }})) + } + + walDir := filepath.Join(dir, "parquet-wal") + sizeBeforeRotation := walDirSize(t, walDir) + require.Greater(t, sizeBeforeRotation, int64(0), "WAL should have data before rotation") + + // Block 11 triggers rotation (blocksInFile=10 >= MaxBlocksPerFile=10), + // which calls ClearWAL(). + receipt.BlockNumber = 11 + require.NoError(t, store.WriteReceipts([]ReceiptInput{{ + BlockNumber: 11, + Receipt: receipt, + ReceiptBytes: receipt.ReceiptBytes, + }})) + + sizeAfterRotation := walDirSize(t, walDir) + + // After ClearWAL the WAL should contain at most the single entry from + // block 11. The pre-rotation data (blocks 1-10) must be gone. + require.Less(t, sizeAfterRotation, sizeBeforeRotation, + "WAL should shrink after rotation; ClearWAL may not be truncating (AllowEmpty bug)") +} + +// TestClearWALEmptiesAfterMultipleRotations writes enough blocks to trigger +// several file rotations and verifies the WAL stays bounded rather than +// growing monotonically. +func TestClearWALEmptiesAfterMultipleRotations(t *testing.T) { + dir := t.TempDir() + + store, err := NewStore(StoreConfig{ + DBDirectory: dir, + MaxBlocksPerFile: 5, + }) + require.NoError(t, err) + t.Cleanup(func() { _ = store.Close() }) + + receipt := ReceiptRecord{ + TxHash: make([]byte, 32), + BlockNumber: 1, + ReceiptBytes: make([]byte, 1024), + } + + walDir := filepath.Join(dir, "parquet-wal") + + // Write 5 blocks to fill one file (no rotation yet). The WAL should + // hold all 5 entries at this point. + for block := uint64(1); block <= 5; block++ { + receipt.BlockNumber = block + require.NoError(t, store.WriteReceipts([]ReceiptInput{{ + BlockNumber: block, + Receipt: receipt, + ReceiptBytes: receipt.ReceiptBytes, + }})) + } + sizeBeforeAnyRotation := walDirSize(t, walDir) + require.Greater(t, sizeBeforeAnyRotation, int64(0), + "WAL should have data before first rotation") + + // Write 20 more blocks (blocks 6-25) → 4 more rotations. + for block := uint64(6); block <= 25; block++ { + receipt.BlockNumber = block + require.NoError(t, store.WriteReceipts([]ReceiptInput{{ + BlockNumber: block, + Receipt: receipt, + ReceiptBytes: receipt.ReceiptBytes, + }})) + } + + sizeAtEnd := walDirSize(t, walDir) + + // Without truncation the WAL would hold all 25 blocks (~5x the initial + // size). With working truncation it should be no larger than one + // rotation window. + require.Less(t, sizeAtEnd, sizeBeforeAnyRotation, + "WAL should not grow across rotations; ClearWAL is not reclaiming space") +} From ed48ba5f6037276c8bd25be8cc224d0e4fae7764 Mon Sep 17 00:00:00 2001 From: Jeremy Wei Date: Tue, 31 Mar 2026 09:40:32 -0400 Subject: [PATCH 10/28] switch cryptosim receipt reads to duckdb-only mode Made-with: Cursor --- sei-db/state_db/bench/cryptosim/config/reciept-store.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sei-db/state_db/bench/cryptosim/config/reciept-store.json b/sei-db/state_db/bench/cryptosim/config/reciept-store.json index 748a0d4157..f433ebcc91 100644 --- a/sei-db/state_db/bench/cryptosim/config/reciept-store.json +++ b/sei-db/state_db/bench/cryptosim/config/reciept-store.json @@ -9,10 +9,10 @@ "GenerateReceipts": true, "ReceiptReadConcurrency": 4, "ReceiptReadsPerSecond": 1000, - "ReceiptReadMode": "cache", + "ReceiptReadMode": "duckdb", "ReceiptLogFilterReadConcurrency": 4, "ReceiptLogFilterReadsPerSecond": 200, - "ReceiptLogFilterReadMode": "cache", + "ReceiptLogFilterReadMode": "duckdb", "ReceiptLogFilterMinBlockRange": 1, "ReceiptLogFilterMaxBlockRange": 10 } From a5bcd4f8c1cb1f4577024d78ef3fb9996f7ca6ea Mon Sep 17 00:00:00 2001 From: Jeremy Wei Date: Tue, 31 Mar 2026 10:02:39 -0400 Subject: [PATCH 11/28] parquet: prune files below FromBlock in GetLogs + add pebble tx_hash index Two performance improvements for duckdb-only reads: 1. GetLogs now skips parquet files whose block range is entirely before FromBlock, matching the existing ToBlock pruning. This prevents DuckDB from scanning irrelevant historical files. 2. Add a lightweight pebble-backed tx_hash -> block_number index that narrows GetReceiptByTxHash to the single parquet file containing the target block, instead of scanning all files. Falls back to full scan for tx hashes not yet in the index. Made-with: Cursor --- sei-db/ledger_db/parquet/reader.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/sei-db/ledger_db/parquet/reader.go b/sei-db/ledger_db/parquet/reader.go index 589c545cad..cef45c87cb 100644 --- a/sei-db/ledger_db/parquet/reader.go +++ b/sei-db/ledger_db/parquet/reader.go @@ -425,6 +425,9 @@ func (r *Reader) GetLogs(ctx context.Context, filter LogFilter) ([]LogResult, er if filter.ToBlock != nil && startBlock > *filter.ToBlock { continue } + if filter.FromBlock != nil && startBlock+r.maxBlocksPerFile <= *filter.FromBlock { + continue + } files = append(files, f) } if len(files) == 0 { From ca926300b35fdec4cffa79db1e4f6c6279a8ced2 Mon Sep 17 00:00:00 2001 From: Jeremy Wei Date: Tue, 31 Mar 2026 10:05:49 -0400 Subject: [PATCH 12/28] parquet: add tests for GetLogs FromBlock pruning and tx_hash index Made-with: Cursor --- .../ledger_db/parquet/reader_filter_test.go | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/sei-db/ledger_db/parquet/reader_filter_test.go b/sei-db/ledger_db/parquet/reader_filter_test.go index 5556195c33..5398baa551 100644 --- a/sei-db/ledger_db/parquet/reader_filter_test.go +++ b/sei-db/ledger_db/parquet/reader_filter_test.go @@ -2,13 +2,92 @@ package parquet import ( "context" + "fmt" "math/big" + "os" "testing" "github.com/ethereum/go-ethereum/common" + pqgo "github.com/parquet-go/parquet-go" "github.com/stretchr/testify/require" ) +func createTestLogFile(dir string, startBlock, count uint64) error { + path := fmt.Sprintf("%s/logs_%d.parquet", dir, startBlock) + f, err := os.Create(path) + if err != nil { + return err + } + w := pqgo.NewGenericWriter[LogRecord](f) + for i := uint64(0); i < count; i++ { + block := startBlock + i + txHash := common.BigToHash(new(big.Int).SetUint64(block)) + if _, err := w.Write([]LogRecord{{ + BlockNumber: block, + TxHash: txHash[:], + Address: common.HexToAddress("0xdead").Bytes(), + }}); err != nil { + return err + } + } + if err := w.Close(); err != nil { + return err + } + return f.Close() +} + +func uint64Ptr(v uint64) *uint64 { return &v } + +func TestGetLogsPrunesFilesBelowFromBlock(t *testing.T) { + dir := t.TempDir() + + for _, start := range []uint64{0, 500, 1000, 1500} { + require.NoError(t, createTestReceiptFile(dir, start, 500)) + require.NoError(t, createTestLogFile(dir, start, 500)) + } + + reader, err := NewReaderWithMaxBlocksPerFile(dir, 500) + require.NoError(t, err) + defer func() { _ = reader.Close() }() + + ctx := context.Background() + + results, err := reader.GetLogs(ctx, LogFilter{ + FromBlock: uint64Ptr(1200), + ToBlock: uint64Ptr(1300), + }) + require.NoError(t, err) + + for _, r := range results { + require.GreaterOrEqual(t, r.BlockNumber, uint64(1200)) + require.LessOrEqual(t, r.BlockNumber, uint64(1300)) + } + require.Equal(t, 101, len(results), "should have blocks 1200-1300 inclusive") +} + +func TestGetLogsPrunesBothEnds(t *testing.T) { + dir := t.TempDir() + + for _, start := range []uint64{0, 500, 1000, 1500, 2000} { + require.NoError(t, createTestReceiptFile(dir, start, 500)) + require.NoError(t, createTestLogFile(dir, start, 500)) + } + + reader, err := NewReaderWithMaxBlocksPerFile(dir, 500) + require.NoError(t, err) + defer func() { _ = reader.Close() }() + + ctx := context.Background() + + // Query blocks 1100-1400: should only need files 1000 and 1500 (not 0, 500, 2000) + results, err := reader.GetLogs(ctx, LogFilter{ + FromBlock: uint64Ptr(1100), + ToBlock: uint64Ptr(1400), + }) + require.NoError(t, err) + require.Equal(t, 301, len(results)) +} + func TestGetReceiptByTxHashInBlock(t *testing.T) { dir := t.TempDir() From 0db9a8e6e6772b173f43b046ecd3c984f8c520b0 Mon Sep 17 00:00:00 2001 From: Jeremy Wei Date: Tue, 31 Mar 2026 10:33:34 -0400 Subject: [PATCH 13/28] add TODO --- sei-db/state_db/bench/cryptosim/cryptosim.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/sei-db/state_db/bench/cryptosim/cryptosim.go b/sei-db/state_db/bench/cryptosim/cryptosim.go index 59385eb68c..a81a1eeca9 100644 --- a/sei-db/state_db/bench/cryptosim/cryptosim.go +++ b/sei-db/state_db/bench/cryptosim/cryptosim.go @@ -429,6 +429,8 @@ func (c *CryptoSim) handleNextBlock(blk *block) { c.database.IncrementTransactionCount() } + // TODO: skip executor dispatch and FinalizeBlock when DisableTransactionExecution + // is true and only receipts are being benchmarked. FlatKV commits waste I/O here. for txn := range blk.Iterator() { c.executors[c.nextExecutorIndex].ScheduleForExecution(txn) c.nextExecutorIndex = (c.nextExecutorIndex + 1) % len(c.executors) From e081f2158b1d4198d22a4d44668dfc5d7d4f0bf3 Mon Sep 17 00:00:00 2001 From: Jeremy Wei Date: Wed, 8 Apr 2026 12:37:12 -0400 Subject: [PATCH 14/28] option to disable tx index --- sei-db/config/receipt_config.go | 6 ++++ .../ledger_db/parquet/reader_filter_test.go | 31 +++++++++++++++++++ sei-db/ledger_db/parquet/store.go | 8 +++-- sei-db/ledger_db/parquet/store_config_test.go | 2 ++ sei-db/ledger_db/receipt/parquet_store.go | 1 + .../bench/cryptosim/config/basic-config.json | 1 + .../bench/cryptosim/config/reciept-store.json | 1 + .../bench/cryptosim/cryptosim_config.go | 6 ++++ .../bench/cryptosim/cryptosim_config_test.go | 16 ++++++++++ .../cryptosim/reciept_store_simulator.go | 1 + 10 files changed, 71 insertions(+), 2 deletions(-) diff --git a/sei-db/config/receipt_config.go b/sei-db/config/receipt_config.go index 0d2887ed91..a57627432e 100644 --- a/sei-db/config/receipt_config.go +++ b/sei-db/config/receipt_config.go @@ -48,6 +48,11 @@ type ReceiptStoreConfig struct { // PruneIntervalSeconds defines the interval in seconds to trigger pruning // default to every 600 seconds PruneIntervalSeconds int `mapstructure:"prune-interval-seconds"` + + // DisableTxIndexLookup disables the tx_hash -> block_number lookup during + // receipt reads. When true, parquet receipt reads fall back to scanning all + // closed parquet files instead of narrowing to a single file via the tx index. + DisableTxIndexLookup bool `mapstructure:"disable-tx-index-lookup"` } // DefaultReceiptStoreConfig returns the default ReceiptStoreConfig @@ -57,6 +62,7 @@ func DefaultReceiptStoreConfig() ReceiptStoreConfig { AsyncWriteBuffer: DefaultSSAsyncBuffer, KeepRecent: DefaultSSKeepRecent, PruneIntervalSeconds: DefaultSSPruneInterval, + DisableTxIndexLookup: false, } } diff --git a/sei-db/ledger_db/parquet/reader_filter_test.go b/sei-db/ledger_db/parquet/reader_filter_test.go index 5398baa551..7e2ae0d7aa 100644 --- a/sei-db/ledger_db/parquet/reader_filter_test.go +++ b/sei-db/ledger_db/parquet/reader_filter_test.go @@ -159,6 +159,37 @@ func TestStoreGetReceiptByTxHashUsesIndex(t *testing.T) { require.Equal(t, uint64(750), result.BlockNumber) } +func TestStoreGetReceiptByTxHashCanDisableIndexLookup(t *testing.T) { + dir := t.TempDir() + + for _, start := range []uint64{0, 500, 1000} { + require.NoError(t, createTestReceiptFile(dir, start, 500)) + } + + store, err := NewStore(StoreConfig{ + DBDirectory: dir, + MaxBlocksPerFile: 500, + DisableTxIndexLookup: true, + }) + require.NoError(t, err) + defer func() { _ = store.Close() }() + + txHash := common.BigToHash(new(big.Int).SetUint64(750)) + + // Populate the index with an incorrect block number. With index lookup enabled + // this would narrow to the wrong file and miss. Disabling lookup should fall + // back to a full scan and still find the receipt. + require.NoError(t, store.txIndex.SetBatch([]TxIndexEntry{ + {TxHash: txHash, BlockNumber: 250}, + })) + + ctx := context.Background() + result, err := store.GetReceiptByTxHash(ctx, txHash) + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, uint64(750), result.BlockNumber) +} + func TestStoreWriteReceiptsPopulatesIndex(t *testing.T) { dir := t.TempDir() diff --git a/sei-db/ledger_db/parquet/store.go b/sei-db/ledger_db/parquet/store.go index 2e25da3b18..a1c0a45fed 100644 --- a/sei-db/ledger_db/parquet/store.go +++ b/sei-db/ledger_db/parquet/store.go @@ -36,6 +36,7 @@ type StoreConfig struct { PruneIntervalSeconds int64 BlockFlushInterval uint64 MaxBlocksPerFile uint64 + DisableTxIndexLookup bool } // DefaultStoreConfig returns the default store configuration. @@ -158,6 +159,7 @@ func resolveStoreConfig(cfg StoreConfig) StoreConfig { resolved.DBDirectory = cfg.DBDirectory resolved.KeepRecent = cfg.KeepRecent resolved.PruneIntervalSeconds = cfg.PruneIntervalSeconds + resolved.DisableTxIndexLookup = cfg.DisableTxIndexLookup if cfg.BlockFlushInterval > 0 { resolved.BlockFlushInterval = cfg.BlockFlushInterval } @@ -199,8 +201,10 @@ func (s *Store) SetBlockFlushInterval(interval uint64) { // When the tx index contains the block number, the search is narrowed to the // single parquet file covering that block instead of scanning all files. func (s *Store) GetReceiptByTxHash(ctx context.Context, txHash common.Hash) (*ReceiptResult, error) { - if blockNum, ok := s.txIndex.GetBlockNumber(txHash); ok { - return s.Reader.GetReceiptByTxHashInBlock(ctx, txHash, blockNum) + if !s.config.DisableTxIndexLookup { + if blockNum, ok := s.txIndex.GetBlockNumber(txHash); ok { + return s.Reader.GetReceiptByTxHashInBlock(ctx, txHash, blockNum) + } } return s.Reader.GetReceiptByTxHash(ctx, txHash) } diff --git a/sei-db/ledger_db/parquet/store_config_test.go b/sei-db/ledger_db/parquet/store_config_test.go index 4b891a2ab6..9937e78813 100644 --- a/sei-db/ledger_db/parquet/store_config_test.go +++ b/sei-db/ledger_db/parquet/store_config_test.go @@ -69,12 +69,14 @@ func TestNewStorePreservesKeepRecentAndPruneIntervalSettings(t *testing.T) { DBDirectory: t.TempDir(), KeepRecent: 123, PruneIntervalSeconds: 9, + DisableTxIndexLookup: true, }) require.NoError(t, err) t.Cleanup(func() { _ = store.Close() }) require.Equal(t, int64(123), store.config.KeepRecent) require.Equal(t, int64(9), store.config.PruneIntervalSeconds) + require.True(t, store.config.DisableTxIndexLookup) } func TestPruneOldFilesKeepsTrackingOnDeleteFailure(t *testing.T) { diff --git a/sei-db/ledger_db/receipt/parquet_store.go b/sei-db/ledger_db/receipt/parquet_store.go index 1822c46432..cf03fff495 100644 --- a/sei-db/ledger_db/receipt/parquet_store.go +++ b/sei-db/ledger_db/receipt/parquet_store.go @@ -24,6 +24,7 @@ func newParquetReceiptStore(cfg dbconfig.ReceiptStoreConfig, storeKey sdk.StoreK DBDirectory: cfg.DBDirectory, KeepRecent: int64(cfg.KeepRecent), PruneIntervalSeconds: int64(cfg.PruneIntervalSeconds), + DisableTxIndexLookup: cfg.DisableTxIndexLookup, } store, err := parquet.NewStore(storeCfg) diff --git a/sei-db/state_db/bench/cryptosim/config/basic-config.json b/sei-db/state_db/bench/cryptosim/config/basic-config.json index a867898d84..4dc0b5571f 100644 --- a/sei-db/state_db/bench/cryptosim/config/basic-config.json +++ b/sei-db/state_db/bench/cryptosim/config/basic-config.json @@ -29,6 +29,7 @@ "DeleteLogDirOnStartup": false, "DeleteDataDirOnShutdown": false, "DeleteLogDirOnShutdown": false, + "DisableReceiptTxIndexLookup": false, "ExecutorQueueSize": 1024, "HotAccountProbability": 0.1, "HotErc20ContractProbability": 0.5, diff --git a/sei-db/state_db/bench/cryptosim/config/reciept-store.json b/sei-db/state_db/bench/cryptosim/config/reciept-store.json index f433ebcc91..14fc1eb168 100644 --- a/sei-db/state_db/bench/cryptosim/config/reciept-store.json +++ b/sei-db/state_db/bench/cryptosim/config/reciept-store.json @@ -10,6 +10,7 @@ "ReceiptReadConcurrency": 4, "ReceiptReadsPerSecond": 1000, "ReceiptReadMode": "duckdb", + "DisableReceiptTxIndexLookup": true, "ReceiptLogFilterReadConcurrency": 4, "ReceiptLogFilterReadsPerSecond": 200, "ReceiptLogFilterReadMode": "duckdb", diff --git a/sei-db/state_db/bench/cryptosim/cryptosim_config.go b/sei-db/state_db/bench/cryptosim/cryptosim_config.go index 124acaf8b9..349c7724c5 100644 --- a/sei-db/state_db/bench/cryptosim/cryptosim_config.go +++ b/sei-db/state_db/bench/cryptosim/cryptosim_config.go @@ -198,6 +198,11 @@ type CryptoSimConfig struct { // Required when ReceiptReadConcurrency > 0. ReceiptReadMode string + // If true, disable the parquet tx_hash -> block_number index lookup during + // receipt reads. Cache hits still use the in-memory ledger cache, but backend + // misses will scan all closed parquet files instead of narrowing to one file. + DisableReceiptTxIndexLookup bool + // Number of concurrent goroutines issuing log filter (eth_getLogs) queries. 0 disables log filter reads. // These goroutines are independent from the receipt reader goroutines. ReceiptLogFilterReadConcurrency int @@ -284,6 +289,7 @@ func DefaultCryptoSimConfig() *CryptoSimConfig { ReceiptReadConcurrency: 0, ReceiptReadsPerSecond: 100, ReceiptReadMode: "cache", + DisableReceiptTxIndexLookup: false, ReceiptLogFilterReadConcurrency: 0, ReceiptLogFilterReadsPerSecond: 100, ReceiptLogFilterReadMode: "cache", diff --git a/sei-db/state_db/bench/cryptosim/cryptosim_config_test.go b/sei-db/state_db/bench/cryptosim/cryptosim_config_test.go index 64ca64f37f..ad426f97eb 100644 --- a/sei-db/state_db/bench/cryptosim/cryptosim_config_test.go +++ b/sei-db/state_db/bench/cryptosim/cryptosim_config_test.go @@ -68,3 +68,19 @@ func TestLoadConfigFromFile_DisableTransactionReadsOverride(t *testing.T) { require.Equal(t, wrappers.NoOp, cfg.Backend) require.True(t, cfg.DisableTransactionReads) } + +func TestLoadConfigFromFile_DisableReceiptTxIndexLookupOverride(t *testing.T) { + t.Parallel() + + configPath := filepath.Join(t.TempDir(), "cryptosim.json") + err := os.WriteFile(configPath, []byte(`{ + "DisableReceiptTxIndexLookup": true, + "DataDir": "data", + "LogDir": "logs" +}`), 0o600) + require.NoError(t, err) + + cfg, err := LoadConfigFromFile(configPath) + require.NoError(t, err) + require.True(t, cfg.DisableReceiptTxIndexLookup) +} diff --git a/sei-db/state_db/bench/cryptosim/reciept_store_simulator.go b/sei-db/state_db/bench/cryptosim/reciept_store_simulator.go index 20716fd19c..7f2a3be6ce 100644 --- a/sei-db/state_db/bench/cryptosim/reciept_store_simulator.go +++ b/sei-db/state_db/bench/cryptosim/reciept_store_simulator.go @@ -141,6 +141,7 @@ func NewRecieptStoreSimulator( Backend: "parquet", KeepRecent: int(config.ReceiptKeepRecent), PruneIntervalSeconds: int(config.ReceiptPruneIntervalSeconds), + DisableTxIndexLookup: config.DisableReceiptTxIndexLookup, } // nil StoreKey is safe: the parquet write path never touches the legacy KV store. From af2cff25c23581919a89a1b383e1ede76aad276f Mon Sep 17 00:00:00 2001 From: Jeremy Wei Date: Wed, 8 Apr 2026 12:55:03 -0400 Subject: [PATCH 15/28] adjust receipt store config --- .../bench/cryptosim/config/reciept-store.json | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/sei-db/state_db/bench/cryptosim/config/reciept-store.json b/sei-db/state_db/bench/cryptosim/config/reciept-store.json index 14fc1eb168..b278b264ff 100644 --- a/sei-db/state_db/bench/cryptosim/config/reciept-store.json +++ b/sei-db/state_db/bench/cryptosim/config/reciept-store.json @@ -1,18 +1,19 @@ { - "Comment": "For testing with the receipt store only (state store disabled), with concurrent reads, pruning, and cache.", + "Comment": "For maximizing receipt-read throughput against the parquet receipt store with tx-index lookup disabled. Log-filter reads are disabled to avoid competing with receipt reads.", "DisableTransactionExecution": true, + "BlocksPerCommit": 128, "DataDir": "data", "LogDir": "logs", "LogLevel": "info", "MinimumNumberOfColdAccounts": 1000000, "MinimumNumberOfDormantAccounts": 1000000, "GenerateReceipts": true, - "ReceiptReadConcurrency": 4, - "ReceiptReadsPerSecond": 1000, + "ReceiptReadConcurrency": 64, + "ReceiptReadsPerSecond": 100000, "ReceiptReadMode": "duckdb", "DisableReceiptTxIndexLookup": true, - "ReceiptLogFilterReadConcurrency": 4, - "ReceiptLogFilterReadsPerSecond": 200, + "ReceiptLogFilterReadConcurrency": 0, + "ReceiptLogFilterReadsPerSecond": 0, "ReceiptLogFilterReadMode": "duckdb", "ReceiptLogFilterMinBlockRange": 1, "ReceiptLogFilterMaxBlockRange": 10 From 8737bc39c5ce72e0f95bdf303cc2f92b83865b9a Mon Sep 17 00:00:00 2001 From: Jeremy Wei Date: Wed, 8 Apr 2026 13:03:19 -0400 Subject: [PATCH 16/28] tune receipt read config Reduce receipt read concurrency and fall back to the default commit cadence while keeping tx-index lookup disabled, so the non-indexed read experiment avoids overwhelming write throughput. Made-with: Cursor --- sei-db/state_db/bench/cryptosim/config/reciept-store.json | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/sei-db/state_db/bench/cryptosim/config/reciept-store.json b/sei-db/state_db/bench/cryptosim/config/reciept-store.json index b278b264ff..954a561dc2 100644 --- a/sei-db/state_db/bench/cryptosim/config/reciept-store.json +++ b/sei-db/state_db/bench/cryptosim/config/reciept-store.json @@ -1,14 +1,13 @@ { - "Comment": "For maximizing receipt-read throughput against the parquet receipt store with tx-index lookup disabled. Log-filter reads are disabled to avoid competing with receipt reads.", + "Comment": "For receipt-read-focused benchmarking against the parquet receipt store with tx-index lookup disabled. Log-filter reads are disabled to avoid competing with receipt reads.", "DisableTransactionExecution": true, - "BlocksPerCommit": 128, "DataDir": "data", "LogDir": "logs", "LogLevel": "info", "MinimumNumberOfColdAccounts": 1000000, "MinimumNumberOfDormantAccounts": 1000000, "GenerateReceipts": true, - "ReceiptReadConcurrency": 64, + "ReceiptReadConcurrency": 16, "ReceiptReadsPerSecond": 100000, "ReceiptReadMode": "duckdb", "DisableReceiptTxIndexLookup": true, From 2501e946190dcb128aba31ec69ddc2afab43c9b3 Mon Sep 17 00:00:00 2001 From: Jeremy Wei Date: Wed, 8 Apr 2026 13:43:19 -0400 Subject: [PATCH 17/28] lower keep recent --- .../dashboards/cryptosim-dashboard.json | 74 ------------------- .../ledger_db/receipt/cached_receipt_store.go | 8 -- .../receipt/cached_receipt_store_test.go | 4 - sei-db/ledger_db/receipt/receipt_cache.go | 32 -------- sei-db/ledger_db/receipt/receipt_store.go | 2 - .../bench/cryptosim/config/reciept-store.json | 1 + .../bench/cryptosim/cryptosim_metrics.go | 28 ------- 7 files changed, 1 insertion(+), 148 deletions(-) diff --git a/docker/monitornode/dashboards/cryptosim-dashboard.json b/docker/monitornode/dashboards/cryptosim-dashboard.json index 4553571aa7..8dfc193e75 100644 --- a/docker/monitornode/dashboards/cryptosim-dashboard.json +++ b/docker/monitornode/dashboards/cryptosim-dashboard.json @@ -4674,80 +4674,6 @@ ], "title": "Cache Get Duration", "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "PBFA97CFB590B2093" - }, - "fieldConfig": { - "defaults": { - "color": { "mode": "palette-classic" }, - "custom": { - "axisBorderShow": false, "axisCenteredZero": false, "axisColorMode": "text", - "axisLabel": "", "axisPlacement": "auto", "barAlignment": 0, "barWidthFactor": 0.6, - "drawStyle": "line", "fillOpacity": 0, "gradientMode": "none", - "hideFrom": { "legend": false, "tooltip": false, "viz": false }, - "insertNulls": false, "lineInterpolation": "linear", "lineWidth": 1, - "pointSize": 5, "scaleDistribution": { "type": "linear" }, - "showPoints": "auto", "showValues": false, "spanNulls": false, - "stacking": { "group": "A", "mode": "none" }, - "thresholdsStyle": { "mode": "off" } - }, - "mappings": [], - "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": 0 }, { "color": "red", "value": 80 }] } - }, - "overrides": [] - }, - "gridPos": { "h": 8, "w": 12, "x": 0, "y": 56 }, - "id": 292, - "options": { - "legend": { "calcs": [], "displayMode": "list", "placement": "bottom", "showLegend": true }, - "tooltip": { "hideZeros": false, "mode": "single", "sort": "none" } - }, - "pluginVersion": "12.4.0", - "targets": [ - { "editorMode": "code", "expr": "cryptosim_receipt_cache_log_count", "legendFormat": "log entries", "range": true, "refId": "A" } - ], - "title": "Cache Log Count", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "PBFA97CFB590B2093" - }, - "fieldConfig": { - "defaults": { - "color": { "mode": "palette-classic" }, - "custom": { - "axisBorderShow": false, "axisCenteredZero": false, "axisColorMode": "text", - "axisLabel": "", "axisPlacement": "auto", "barAlignment": 0, "barWidthFactor": 0.6, - "drawStyle": "line", "fillOpacity": 0, "gradientMode": "none", - "hideFrom": { "legend": false, "tooltip": false, "viz": false }, - "insertNulls": false, "lineInterpolation": "linear", "lineWidth": 1, - "pointSize": 5, "scaleDistribution": { "type": "linear" }, - "showPoints": "auto", "showValues": false, "spanNulls": false, - "stacking": { "group": "A", "mode": "none" }, - "thresholdsStyle": { "mode": "off" } - }, - "mappings": [], - "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": 0 }, { "color": "red", "value": 80 }] } - }, - "overrides": [] - }, - "gridPos": { "h": 8, "w": 12, "x": 12, "y": 56 }, - "id": 293, - "options": { - "legend": { "calcs": [], "displayMode": "list", "placement": "bottom", "showLegend": true }, - "tooltip": { "hideZeros": false, "mode": "single", "sort": "none" } - }, - "pluginVersion": "12.4.0", - "targets": [ - { "editorMode": "code", "expr": "cryptosim_receipt_cache_receipt_count", "legendFormat": "receipts", "range": true, "refId": "A" } - ], - "title": "Cache Receipt Count", - "type": "timeseries" } ], "title": "Receipts", diff --git a/sei-db/ledger_db/receipt/cached_receipt_store.go b/sei-db/ledger_db/receipt/cached_receipt_store.go index 3c90b8934e..afb5f14961 100644 --- a/sei-db/ledger_db/receipt/cached_receipt_store.go +++ b/sei-db/ledger_db/receipt/cached_receipt_store.go @@ -113,7 +113,6 @@ func (s *cachedReceiptStore) SetReceipts(ctx sdk.Context, receipts []ReceiptReco return err } s.cacheReceipts(receipts) - s.reportCacheCounts() return nil } @@ -302,10 +301,3 @@ func (s *cachedReceiptStore) reportCacheGetDuration(seconds float64) { s.readObserver.RecordCacheGetDuration(seconds) } } - -func (s *cachedReceiptStore) reportCacheCounts() { - if s.readObserver != nil { - s.readObserver.RecordCacheLogCount(s.cache.LogCount()) - s.readObserver.RecordCacheReceiptCount(s.cache.ReceiptCount()) - } -} diff --git a/sei-db/ledger_db/receipt/cached_receipt_store_test.go b/sei-db/ledger_db/receipt/cached_receipt_store_test.go index 704369c820..83cc93d188 100644 --- a/sei-db/ledger_db/receipt/cached_receipt_store_test.go +++ b/sei-db/ledger_db/receipt/cached_receipt_store_test.go @@ -48,10 +48,6 @@ func (f *fakeReceiptReadObserver) RecordCacheFilterScanDuration(float64) {} func (f *fakeReceiptReadObserver) RecordCacheGetDuration(float64) {} -func (f *fakeReceiptReadObserver) RecordCacheLogCount(int64) {} - -func (f *fakeReceiptReadObserver) RecordCacheReceiptCount(int64) {} - func newFakeReceiptBackend() *fakeReceiptBackend { return &fakeReceiptBackend{ receipts: make(map[common.Hash]*types.Receipt), diff --git a/sei-db/ledger_db/receipt/receipt_cache.go b/sei-db/ledger_db/receipt/receipt_cache.go index e5eeb094b2..b36f784bf4 100644 --- a/sei-db/ledger_db/receipt/receipt_cache.go +++ b/sei-db/ledger_db/receipt/receipt_cache.go @@ -267,38 +267,6 @@ func (c *ledgerCache) logMinBlockLocked() (uint64, bool) { return min, found } -// LogCount returns the total number of individual log entries across all cache chunks. -func (c *ledgerCache) LogCount() int64 { - c.logMu.RLock() - defer c.logMu.RUnlock() - var total int64 - for i := 0; i < numCacheChunks; i++ { - chunk := c.logChunks[i].Load() - if chunk == nil { - continue - } - for _, logs := range chunk.logs { - total += int64(len(logs)) - } - } - return total -} - -// ReceiptCount returns the total number of receipts across all cache chunks. -func (c *ledgerCache) ReceiptCount() int64 { - c.receiptMu.RLock() - defer c.receiptMu.RUnlock() - var total int64 - for i := int32(0); i < numCacheChunks; i++ { - chunk := c.receiptChunks[i].Load() - if chunk == nil { - continue - } - total += int64(len(chunk.receiptIndex)) - } - return total -} - // matchLog checks if a log matches the filter criteria. func matchLog(lg *ethtypes.Log, crit filters.FilterCriteria) bool { // Check address filter diff --git a/sei-db/ledger_db/receipt/receipt_store.go b/sei-db/ledger_db/receipt/receipt_store.go index d50c46b8fa..4777b0be66 100644 --- a/sei-db/ledger_db/receipt/receipt_store.go +++ b/sei-db/ledger_db/receipt/receipt_store.go @@ -61,8 +61,6 @@ type ReceiptReadObserver interface { ReportLogFilterCacheMiss() RecordCacheFilterScanDuration(seconds float64) RecordCacheGetDuration(seconds float64) - RecordCacheLogCount(count int64) - RecordCacheReceiptCount(count int64) } type receiptStore struct { diff --git a/sei-db/state_db/bench/cryptosim/config/reciept-store.json b/sei-db/state_db/bench/cryptosim/config/reciept-store.json index 954a561dc2..8e89ebe8c3 100644 --- a/sei-db/state_db/bench/cryptosim/config/reciept-store.json +++ b/sei-db/state_db/bench/cryptosim/config/reciept-store.json @@ -11,6 +11,7 @@ "ReceiptReadsPerSecond": 100000, "ReceiptReadMode": "duckdb", "DisableReceiptTxIndexLookup": true, + "ReceiptKeepRecent": 33333, "ReceiptLogFilterReadConcurrency": 0, "ReceiptLogFilterReadsPerSecond": 0, "ReceiptLogFilterReadMode": "duckdb", diff --git a/sei-db/state_db/bench/cryptosim/cryptosim_metrics.go b/sei-db/state_db/bench/cryptosim/cryptosim_metrics.go index 3867fe9293..43ef0c87f9 100644 --- a/sei-db/state_db/bench/cryptosim/cryptosim_metrics.go +++ b/sei-db/state_db/bench/cryptosim/cryptosim_metrics.go @@ -71,8 +71,6 @@ type CryptosimMetrics struct { receiptLogFilterLogsReturned metric.Int64Histogram cacheFilterScanDuration metric.Float64Histogram cacheGetDuration metric.Float64Histogram - cacheLogCount metric.Int64Gauge - cacheReceiptCount metric.Int64Gauge mainThreadPhase *metrics.PhaseTimer transactionPhaseTimerFactory *metrics.PhaseTimerFactory @@ -264,16 +262,6 @@ func NewCryptosimMetrics( metric.WithExplicitBucketBoundaries(receiptReadLatencyBuckets...), metric.WithUnit("s"), ) - cacheLogCount, _ := meter.Int64Gauge( - "cryptosim_receipt_cache_log_count", - metric.WithDescription("Total number of log entries across all ledger cache chunks"), - metric.WithUnit("{count}"), - ) - cacheReceiptCount, _ := meter.Int64Gauge( - "cryptosim_receipt_cache_receipt_count", - metric.WithDescription("Total number of receipts across all ledger cache chunks"), - metric.WithUnit("{count}"), - ) mainThreadPhase := dbPhaseTimer if mainThreadPhase == nil { @@ -316,8 +304,6 @@ func NewCryptosimMetrics( receiptLogFilterLogsReturned: receiptLogFilterLogsReturned, cacheFilterScanDuration: cacheFilterScanDuration, cacheGetDuration: cacheGetDuration, - cacheLogCount: cacheLogCount, - cacheReceiptCount: cacheReceiptCount, mainThreadPhase: mainThreadPhase, transactionPhaseTimerFactory: transactionPhaseTimerFactory, } @@ -677,20 +663,6 @@ func (m *CryptosimMetrics) RecordCacheGetDuration(seconds float64) { m.cacheGetDuration.Record(context.Background(), seconds) } -func (m *CryptosimMetrics) RecordCacheLogCount(count int64) { - if m == nil || m.cacheLogCount == nil { - return - } - m.cacheLogCount.Record(context.Background(), count) -} - -func (m *CryptosimMetrics) RecordCacheReceiptCount(count int64) { - if m == nil || m.cacheReceiptCount == nil { - return - } - m.cacheReceiptCount.Record(context.Background(), count) -} - // startReceiptChannelDepthSampling periodically records the depth of the receipt channel. func (m *CryptosimMetrics) startReceiptChannelDepthSampling(ch <-chan *block, intervalSeconds int) { if m == nil || m.receiptChannelDepth == nil || intervalSeconds <= 0 || ch == nil { From d68970dbef57a0e24812a0ca75425735f5ff2752 Mon Sep 17 00:00:00 2001 From: Jeremy Wei Date: Wed, 8 Apr 2026 14:02:39 -0400 Subject: [PATCH 18/28] update configs, prune interval seconds lowered --- sei-db/state_db/bench/cryptosim/config/reciept-store.json | 1 + 1 file changed, 1 insertion(+) diff --git a/sei-db/state_db/bench/cryptosim/config/reciept-store.json b/sei-db/state_db/bench/cryptosim/config/reciept-store.json index 8e89ebe8c3..0bca96011a 100644 --- a/sei-db/state_db/bench/cryptosim/config/reciept-store.json +++ b/sei-db/state_db/bench/cryptosim/config/reciept-store.json @@ -12,6 +12,7 @@ "ReceiptReadMode": "duckdb", "DisableReceiptTxIndexLookup": true, "ReceiptKeepRecent": 33333, + "ReceiptPruneIntervalSeconds": 60, "ReceiptLogFilterReadConcurrency": 0, "ReceiptLogFilterReadsPerSecond": 0, "ReceiptLogFilterReadMode": "duckdb", From 36ea37bcbc581dec113333788fb7e695f7a80b22 Mon Sep 17 00:00:00 2001 From: Jeremy Wei Date: Wed, 8 Apr 2026 14:03:32 -0400 Subject: [PATCH 19/28] only logs now --- .../state_db/bench/cryptosim/config/reciept-store.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/sei-db/state_db/bench/cryptosim/config/reciept-store.json b/sei-db/state_db/bench/cryptosim/config/reciept-store.json index 0bca96011a..7944450259 100644 --- a/sei-db/state_db/bench/cryptosim/config/reciept-store.json +++ b/sei-db/state_db/bench/cryptosim/config/reciept-store.json @@ -7,14 +7,14 @@ "MinimumNumberOfColdAccounts": 1000000, "MinimumNumberOfDormantAccounts": 1000000, "GenerateReceipts": true, - "ReceiptReadConcurrency": 16, - "ReceiptReadsPerSecond": 100000, + "ReceiptReadConcurrency": 0, + "ReceiptReadsPerSecond": 0, "ReceiptReadMode": "duckdb", "DisableReceiptTxIndexLookup": true, - "ReceiptKeepRecent": 33333, + "ReceiptKeepRecent": 20000, "ReceiptPruneIntervalSeconds": 60, - "ReceiptLogFilterReadConcurrency": 0, - "ReceiptLogFilterReadsPerSecond": 0, + "ReceiptLogFilterReadConcurrency": 16, + "ReceiptLogFilterReadsPerSecond": 10000, "ReceiptLogFilterReadMode": "duckdb", "ReceiptLogFilterMinBlockRange": 1, "ReceiptLogFilterMaxBlockRange": 10 From df227996c6f8f8d088f73701322a21d5ba07b636 Mon Sep 17 00:00:00 2001 From: Jeremy Wei Date: Wed, 8 Apr 2026 14:14:16 -0400 Subject: [PATCH 20/28] keep recent back to 100k for log testing --- sei-db/state_db/bench/cryptosim/config/reciept-store.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sei-db/state_db/bench/cryptosim/config/reciept-store.json b/sei-db/state_db/bench/cryptosim/config/reciept-store.json index 7944450259..89c18469ef 100644 --- a/sei-db/state_db/bench/cryptosim/config/reciept-store.json +++ b/sei-db/state_db/bench/cryptosim/config/reciept-store.json @@ -11,7 +11,7 @@ "ReceiptReadsPerSecond": 0, "ReceiptReadMode": "duckdb", "DisableReceiptTxIndexLookup": true, - "ReceiptKeepRecent": 20000, + "ReceiptKeepRecent": 100000, "ReceiptPruneIntervalSeconds": 60, "ReceiptLogFilterReadConcurrency": 16, "ReceiptLogFilterReadsPerSecond": 10000, From cc74cbe339ccd2a437350b375c69bacc08bed023 Mon Sep 17 00:00:00 2001 From: Jeremy Wei Date: Wed, 8 Apr 2026 14:25:19 -0400 Subject: [PATCH 21/28] add buckets for latency and read from cache now --- .../state_db/bench/cryptosim/config/reciept-store.json | 4 ++-- sei-db/state_db/bench/cryptosim/cryptosim_metrics.go | 9 +++++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/sei-db/state_db/bench/cryptosim/config/reciept-store.json b/sei-db/state_db/bench/cryptosim/config/reciept-store.json index 89c18469ef..f91494d19d 100644 --- a/sei-db/state_db/bench/cryptosim/config/reciept-store.json +++ b/sei-db/state_db/bench/cryptosim/config/reciept-store.json @@ -9,13 +9,13 @@ "GenerateReceipts": true, "ReceiptReadConcurrency": 0, "ReceiptReadsPerSecond": 0, - "ReceiptReadMode": "duckdb", + "ReceiptReadMode": "cache", "DisableReceiptTxIndexLookup": true, "ReceiptKeepRecent": 100000, "ReceiptPruneIntervalSeconds": 60, "ReceiptLogFilterReadConcurrency": 16, "ReceiptLogFilterReadsPerSecond": 10000, - "ReceiptLogFilterReadMode": "duckdb", + "ReceiptLogFilterReadMode": "cache", "ReceiptLogFilterMinBlockRange": 1, "ReceiptLogFilterMaxBlockRange": 10 } diff --git a/sei-db/state_db/bench/cryptosim/cryptosim_metrics.go b/sei-db/state_db/bench/cryptosim/cryptosim_metrics.go index 43ef0c87f9..8e9f4b6719 100644 --- a/sei-db/state_db/bench/cryptosim/cryptosim_metrics.go +++ b/sei-db/state_db/bench/cryptosim/cryptosim_metrics.go @@ -31,6 +31,14 @@ var receiptReadLatencyBuckets = []float64{ 0.05, 0.1, 0.25, 0.5, 1, } +var receiptLogFilterLatencyBuckets = []float64{ + 0.00001, 0.00005, 0.0001, 0.00025, 0.0005, + 0.001, 0.0025, 0.005, 0.01, 0.025, + 0.05, 0.075, 0.1, 0.15, 0.25, + 0.5, 0.75, 1, 1.5, 2, + 2.5, 3, 4, 5, 7.5, 10, +} + // CryptosimMetrics holds OpenTelemetry metrics for the cryptosim benchmark. // Metrics are exported via whatever exporter is configured on the global OTel // MeterProvider (e.g., Prometheus, OTLP). This package does not import Prometheus. @@ -231,6 +239,7 @@ func NewCryptosimMetrics( receiptLogFilterDuration, _ := meter.Float64Histogram( "cryptosim_receipt_log_filter_duration_seconds", metric.WithDescription("DuckDB eth_getLogs filter query latency"), + metric.WithExplicitBucketBoundaries(receiptLogFilterLatencyBuckets...), metric.WithUnit("s"), ) receiptLogFilterCacheHitsTotal, _ := meter.Int64Counter( From 19d034f5bf851140a64761b97a901bcb58b0abf5 Mon Sep 17 00:00:00 2001 From: Jeremy Wei Date: Wed, 8 Apr 2026 14:30:12 -0400 Subject: [PATCH 22/28] increase log ceiling even more to 100k --- sei-db/state_db/bench/cryptosim/config/reciept-store.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sei-db/state_db/bench/cryptosim/config/reciept-store.json b/sei-db/state_db/bench/cryptosim/config/reciept-store.json index f91494d19d..c114905595 100644 --- a/sei-db/state_db/bench/cryptosim/config/reciept-store.json +++ b/sei-db/state_db/bench/cryptosim/config/reciept-store.json @@ -14,7 +14,7 @@ "ReceiptKeepRecent": 100000, "ReceiptPruneIntervalSeconds": 60, "ReceiptLogFilterReadConcurrency": 16, - "ReceiptLogFilterReadsPerSecond": 10000, + "ReceiptLogFilterReadsPerSecond": 100000, "ReceiptLogFilterReadMode": "cache", "ReceiptLogFilterMinBlockRange": 1, "ReceiptLogFilterMaxBlockRange": 10 From e5e1b7581dd811b56612e772ee299a1cc0e3a1fe Mon Sep 17 00:00:00 2001 From: Jeremy Wei Date: Wed, 8 Apr 2026 14:33:23 -0400 Subject: [PATCH 23/28] increase log ceiling even more to 500k --- sei-db/state_db/bench/cryptosim/config/reciept-store.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sei-db/state_db/bench/cryptosim/config/reciept-store.json b/sei-db/state_db/bench/cryptosim/config/reciept-store.json index c114905595..f66bedd93b 100644 --- a/sei-db/state_db/bench/cryptosim/config/reciept-store.json +++ b/sei-db/state_db/bench/cryptosim/config/reciept-store.json @@ -14,7 +14,7 @@ "ReceiptKeepRecent": 100000, "ReceiptPruneIntervalSeconds": 60, "ReceiptLogFilterReadConcurrency": 16, - "ReceiptLogFilterReadsPerSecond": 100000, + "ReceiptLogFilterReadsPerSecond": 500000, "ReceiptLogFilterReadMode": "cache", "ReceiptLogFilterMinBlockRange": 1, "ReceiptLogFilterMaxBlockRange": 10 From 7912ba9fa71024c437560f1e76aa144fe787cdc3 Mon Sep 17 00:00:00 2001 From: Jeremy Wei Date: Wed, 8 Apr 2026 14:40:34 -0400 Subject: [PATCH 24/28] only do receipts in cache --- sei-db/state_db/bench/cryptosim/config/reciept-store.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/sei-db/state_db/bench/cryptosim/config/reciept-store.json b/sei-db/state_db/bench/cryptosim/config/reciept-store.json index f66bedd93b..cb20fa8f29 100644 --- a/sei-db/state_db/bench/cryptosim/config/reciept-store.json +++ b/sei-db/state_db/bench/cryptosim/config/reciept-store.json @@ -7,14 +7,14 @@ "MinimumNumberOfColdAccounts": 1000000, "MinimumNumberOfDormantAccounts": 1000000, "GenerateReceipts": true, - "ReceiptReadConcurrency": 0, - "ReceiptReadsPerSecond": 0, + "ReceiptReadConcurrency": 16, + "ReceiptReadsPerSecond": 500000, "ReceiptReadMode": "cache", "DisableReceiptTxIndexLookup": true, "ReceiptKeepRecent": 100000, "ReceiptPruneIntervalSeconds": 60, - "ReceiptLogFilterReadConcurrency": 16, - "ReceiptLogFilterReadsPerSecond": 500000, + "ReceiptLogFilterReadConcurrency": 0, + "ReceiptLogFilterReadsPerSecond": 0, "ReceiptLogFilterReadMode": "cache", "ReceiptLogFilterMinBlockRange": 1, "ReceiptLogFilterMaxBlockRange": 10 From c6d1b1efa7426bbcba1425ab39ae4660314dcfba Mon Sep 17 00:00:00 2001 From: Jeremy Wei Date: Wed, 8 Apr 2026 14:53:21 -0400 Subject: [PATCH 25/28] with pebble tx index, maxed out receipts to duckdb --- sei-db/state_db/bench/cryptosim/config/reciept-store.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sei-db/state_db/bench/cryptosim/config/reciept-store.json b/sei-db/state_db/bench/cryptosim/config/reciept-store.json index cb20fa8f29..5641217047 100644 --- a/sei-db/state_db/bench/cryptosim/config/reciept-store.json +++ b/sei-db/state_db/bench/cryptosim/config/reciept-store.json @@ -9,8 +9,8 @@ "GenerateReceipts": true, "ReceiptReadConcurrency": 16, "ReceiptReadsPerSecond": 500000, - "ReceiptReadMode": "cache", - "DisableReceiptTxIndexLookup": true, + "ReceiptReadMode": "duckdb", + "DisableReceiptTxIndexLookup": false, "ReceiptKeepRecent": 100000, "ReceiptPruneIntervalSeconds": 60, "ReceiptLogFilterReadConcurrency": 0, From 8ad586b51d39033b9ce376cd8e3008ab51b30b7a Mon Sep 17 00:00:00 2001 From: Jeremy Wei Date: Wed, 8 Apr 2026 18:45:33 -0400 Subject: [PATCH 26/28] remove tx index for now --- sei-db/config/receipt_config.go | 8 +- sei-db/ledger_db/parquet/reader.go | 41 ------- .../ledger_db/parquet/reader_filter_test.go | 106 +----------------- sei-db/ledger_db/parquet/reader_race_test.go | 19 ++-- sei-db/ledger_db/parquet/store.go | 45 ++------ sei-db/ledger_db/parquet/store_config_test.go | 32 ++++-- sei-db/ledger_db/parquet/tx_index.go | 103 ----------------- sei-db/ledger_db/parquet/tx_index_test.go | 85 -------------- sei-db/ledger_db/parquet/wal_test.go | 10 +- .../bench/cryptosim/config/basic-config.json | 2 +- .../bench/cryptosim/config/reciept-store.json | 2 +- .../bench/cryptosim/cryptosim_config.go | 8 +- 12 files changed, 60 insertions(+), 401 deletions(-) delete mode 100644 sei-db/ledger_db/parquet/tx_index.go delete mode 100644 sei-db/ledger_db/parquet/tx_index_test.go diff --git a/sei-db/config/receipt_config.go b/sei-db/config/receipt_config.go index a57627432e..52f82c4910 100644 --- a/sei-db/config/receipt_config.go +++ b/sei-db/config/receipt_config.go @@ -49,9 +49,9 @@ type ReceiptStoreConfig struct { // default to every 600 seconds PruneIntervalSeconds int `mapstructure:"prune-interval-seconds"` - // DisableTxIndexLookup disables the tx_hash -> block_number lookup during - // receipt reads. When true, parquet receipt reads fall back to scanning all - // closed parquet files instead of narrowing to a single file via the tx index. + // DisableTxIndexLookup must remain true. The tx_hash -> block_number lookup + // implementation is intentionally unsupported; setting this to false will + // panic during parquet store initialization. DisableTxIndexLookup bool `mapstructure:"disable-tx-index-lookup"` } @@ -62,7 +62,7 @@ func DefaultReceiptStoreConfig() ReceiptStoreConfig { AsyncWriteBuffer: DefaultSSAsyncBuffer, KeepRecent: DefaultSSKeepRecent, PruneIntervalSeconds: DefaultSSPruneInterval, - DisableTxIndexLookup: false, + DisableTxIndexLookup: true, } } diff --git a/sei-db/ledger_db/parquet/reader.go b/sei-db/ledger_db/parquet/reader.go index cef45c87cb..12225d18f2 100644 --- a/sei-db/ledger_db/parquet/reader.go +++ b/sei-db/ledger_db/parquet/reader.go @@ -363,47 +363,6 @@ func (r *Reader) GetReceiptByTxHash(ctx context.Context, txHash common.Hash) (*R return &rec, nil } -// GetReceiptByTxHashInBlock queries for a receipt narrowed to the file covering blockNumber. -func (r *Reader) GetReceiptByTxHashInBlock(ctx context.Context, txHash common.Hash, blockNumber uint64) (*ReceiptResult, error) { - r.pruneMu.RLock() - defer r.pruneMu.RUnlock() - - r.mu.RLock() - closedFiles := make([]string, len(r.closedReceiptFiles)) - copy(closedFiles, r.closedReceiptFiles) - r.mu.RUnlock() - - var targetFile string - for _, f := range closedFiles { - startBlock := ExtractBlockNumber(f) - if blockNumber >= startBlock && blockNumber < startBlock+r.maxBlocksPerFile { - targetFile = f - break - } - } - if targetFile == "" { - return nil, nil - } - - // #nosec G201 -- targetFile derived from local file paths - query := fmt.Sprintf(` - SELECT tx_hash, block_number, receipt_bytes - FROM read_parquet(%s, union_by_name=true) - WHERE tx_hash = $1 - LIMIT 1 - `, quoteSQLString(targetFile)) - - row := r.db.QueryRowContext(ctx, query, txHash[:]) - var rec ReceiptResult - if err := row.Scan(&rec.TxHash, &rec.BlockNumber, &rec.ReceiptBytes); err != nil { - if errors.Is(err, sql.ErrNoRows) { - return nil, nil - } - return nil, fmt.Errorf("failed to query receipt: %w", err) - } - return &rec, nil -} - // GetLogs queries logs matching the given filter. func (r *Reader) GetLogs(ctx context.Context, filter LogFilter) ([]LogResult, error) { // Hold pruneMu first to prevent file deletion, then snapshot the list. diff --git a/sei-db/ledger_db/parquet/reader_filter_test.go b/sei-db/ledger_db/parquet/reader_filter_test.go index 7e2ae0d7aa..e8458d6e08 100644 --- a/sei-db/ledger_db/parquet/reader_filter_test.go +++ b/sei-db/ledger_db/parquet/reader_filter_test.go @@ -88,78 +88,7 @@ func TestGetLogsPrunesBothEnds(t *testing.T) { require.Equal(t, 301, len(results)) } -func TestGetReceiptByTxHashInBlock(t *testing.T) { - dir := t.TempDir() - - for _, start := range []uint64{0, 500, 1000} { - require.NoError(t, createTestReceiptFile(dir, start, 500)) - } - - reader, err := NewReaderWithMaxBlocksPerFile(dir, 500) - require.NoError(t, err) - defer func() { _ = reader.Close() }() - - ctx := context.Background() - - txHash := common.BigToHash(new(big.Int).SetUint64(750)) - result, err := reader.GetReceiptByTxHashInBlock(ctx, txHash, 750) - require.NoError(t, err) - require.NotNil(t, result) - require.Equal(t, uint64(750), result.BlockNumber) - - // Query with wrong block number should return nil (file doesn't contain it). - result, err = reader.GetReceiptByTxHashInBlock(ctx, txHash, 200) - require.NoError(t, err) - require.Nil(t, result, "should not find receipt in wrong file") -} - -func TestGetReceiptByTxHashInBlockMissingFile(t *testing.T) { - dir := t.TempDir() - - require.NoError(t, createTestReceiptFile(dir, 0, 500)) - - reader, err := NewReaderWithMaxBlocksPerFile(dir, 500) - require.NoError(t, err) - defer func() { _ = reader.Close() }() - - ctx := context.Background() - - // Block 999 is in file range 500-999, but that file doesn't exist. - txHash := common.BigToHash(new(big.Int).SetUint64(999)) - result, err := reader.GetReceiptByTxHashInBlock(ctx, txHash, 999) - require.NoError(t, err) - require.Nil(t, result) -} - -func TestStoreGetReceiptByTxHashUsesIndex(t *testing.T) { - dir := t.TempDir() - - for _, start := range []uint64{0, 500, 1000} { - require.NoError(t, createTestReceiptFile(dir, start, 500)) - } - - store, err := NewStore(StoreConfig{ - DBDirectory: dir, - MaxBlocksPerFile: 500, - }) - require.NoError(t, err) - defer func() { _ = store.Close() }() - - txHash := common.BigToHash(new(big.Int).SetUint64(750)) - - // Populate the index manually for block 750. - require.NoError(t, store.txIndex.SetBatch([]TxIndexEntry{ - {TxHash: txHash, BlockNumber: 750}, - })) - - ctx := context.Background() - result, err := store.GetReceiptByTxHash(ctx, txHash) - require.NoError(t, err) - require.NotNil(t, result) - require.Equal(t, uint64(750), result.BlockNumber) -} - -func TestStoreGetReceiptByTxHashCanDisableIndexLookup(t *testing.T) { +func TestStoreGetReceiptByTxHashUsesFullScanWhenLookupDisabled(t *testing.T) { dir := t.TempDir() for _, start := range []uint64{0, 500, 1000} { @@ -176,42 +105,9 @@ func TestStoreGetReceiptByTxHashCanDisableIndexLookup(t *testing.T) { txHash := common.BigToHash(new(big.Int).SetUint64(750)) - // Populate the index with an incorrect block number. With index lookup enabled - // this would narrow to the wrong file and miss. Disabling lookup should fall - // back to a full scan and still find the receipt. - require.NoError(t, store.txIndex.SetBatch([]TxIndexEntry{ - {TxHash: txHash, BlockNumber: 250}, - })) - ctx := context.Background() result, err := store.GetReceiptByTxHash(ctx, txHash) require.NoError(t, err) require.NotNil(t, result) require.Equal(t, uint64(750), result.BlockNumber) } - -func TestStoreWriteReceiptsPopulatesIndex(t *testing.T) { - dir := t.TempDir() - - store, err := NewStore(StoreConfig{ - DBDirectory: dir, - MaxBlocksPerFile: 500, - }) - require.NoError(t, err) - defer func() { _ = store.Close() }() - - txHash := common.HexToHash("0xdeadbeef") - require.NoError(t, store.WriteReceipts([]ReceiptInput{{ - BlockNumber: 42, - Receipt: ReceiptRecord{ - TxHash: txHash[:], - BlockNumber: 42, - ReceiptBytes: []byte{0x1}, - }, - ReceiptBytes: []byte{0x1}, - }})) - - blockNum, ok := store.txIndex.GetBlockNumber(txHash) - require.True(t, ok, "tx hash should be in the index after WriteReceipts") - require.Equal(t, uint64(42), blockNum) -} diff --git a/sei-db/ledger_db/parquet/reader_race_test.go b/sei-db/ledger_db/parquet/reader_race_test.go index b24c5ff9f0..b681d9820e 100644 --- a/sei-db/ledger_db/parquet/reader_race_test.go +++ b/sei-db/ledger_db/parquet/reader_race_test.go @@ -82,9 +82,10 @@ func TestConcurrentReadsAndPrune(t *testing.T) { } store, err := NewStore(StoreConfig{ - DBDirectory: dir, - MaxBlocksPerFile: 500, - KeepRecent: 600, + DBDirectory: dir, + MaxBlocksPerFile: 500, + KeepRecent: 600, + DisableTxIndexLookup: true, }) require.NoError(t, err) t.Cleanup(func() { _ = store.Close() }) @@ -134,8 +135,9 @@ func TestOnFileRotationNotBlockedByPruneMu(t *testing.T) { require.NoError(t, createTestReceiptFile(dir, 0, 1)) store, err := NewStore(StoreConfig{ - DBDirectory: dir, - MaxBlocksPerFile: 500, + DBDirectory: dir, + MaxBlocksPerFile: 500, + DisableTxIndexLookup: true, }) require.NoError(t, err) t.Cleanup(func() { _ = store.Close() }) @@ -168,9 +170,10 @@ func TestConcurrentReadsPruneAndRotation(t *testing.T) { } store, err := NewStore(StoreConfig{ - DBDirectory: dir, - MaxBlocksPerFile: 500, - KeepRecent: 1000, + DBDirectory: dir, + MaxBlocksPerFile: 500, + KeepRecent: 1000, + DisableTxIndexLookup: true, }) require.NoError(t, err) t.Cleanup(func() { _ = store.Close() }) diff --git a/sei-db/ledger_db/parquet/store.go b/sei-db/ledger_db/parquet/store.go index a1c0a45fed..853e21b3f1 100644 --- a/sei-db/ledger_db/parquet/store.go +++ b/sei-db/ledger_db/parquet/store.go @@ -42,8 +42,9 @@ type StoreConfig struct { // DefaultStoreConfig returns the default store configuration. func DefaultStoreConfig() StoreConfig { return StoreConfig{ - BlockFlushInterval: defaultBlockFlushInterval, - MaxBlocksPerFile: defaultMaxBlocksPerFile, + BlockFlushInterval: defaultBlockFlushInterval, + MaxBlocksPerFile: defaultMaxBlocksPerFile, + DisableTxIndexLookup: true, } } @@ -91,7 +92,6 @@ type Store struct { closeOnce sync.Once pruneStop chan struct{} - txIndex *TxIndex // WarmupRecords holds receipts recovered from WAL for cache warming. WarmupRecords []ReceiptRecord @@ -104,6 +104,9 @@ type Store struct { // NewStore creates a new parquet store. func NewStore(cfg StoreConfig) (*Store, error) { storeCfg := resolveStoreConfig(cfg) + if !storeCfg.DisableTxIndexLookup { + panic("not implemented") + } if err := os.MkdirAll(cfg.DBDirectory, 0o750); err != nil { return nil, fmt.Errorf("failed to create parquet base directory: %w", err) @@ -120,11 +123,6 @@ func NewStore(cfg StoreConfig) (*Store, error) { return nil, err } - txIndex, err := OpenTxIndex(cfg.DBDirectory) - if err != nil { - return nil, err - } - store := &Store{ basePath: cfg.DBDirectory, receiptsBuffer: make([]ReceiptRecord, 0, 1000), @@ -133,7 +131,6 @@ func NewStore(cfg StoreConfig) (*Store, error) { Reader: reader, wal: receiptWAL, pruneStop: make(chan struct{}), - txIndex: txIndex, } if maxBlock, ok, err := reader.MaxReceiptBlockNumber(context.Background()); err != nil { @@ -197,15 +194,9 @@ func (s *Store) SetBlockFlushInterval(interval uint64) { s.config.BlockFlushInterval = interval } -// GetReceiptByTxHash retrieves a receipt by transaction hash. -// When the tx index contains the block number, the search is narrowed to the -// single parquet file covering that block instead of scanning all files. +// GetReceiptByTxHash retrieves a receipt by transaction hash via a full scan of +// the closed parquet files tracked by the reader. func (s *Store) GetReceiptByTxHash(ctx context.Context, txHash common.Hash) (*ReceiptResult, error) { - if !s.config.DisableTxIndexLookup { - if blockNum, ok := s.txIndex.GetBlockNumber(txHash); ok { - return s.Reader.GetReceiptByTxHashInBlock(ctx, txHash, blockNum) - } - } return s.Reader.GetReceiptByTxHash(ctx, txHash) } @@ -262,23 +253,10 @@ func (s *Store) WriteReceipts(inputs []ReceiptInput) error { s.mu.Lock() defer s.mu.Unlock() - indexEntries := make([]TxIndexEntry, 0, len(inputs)) for i := range inputs { if err := s.applyReceiptLocked(inputs[i]); err != nil { return err } - if len(inputs[i].Receipt.TxHash) > 0 { - indexEntries = append(indexEntries, TxIndexEntry{ - TxHash: common.BytesToHash(inputs[i].Receipt.TxHash), - BlockNumber: inputs[i].BlockNumber, - }) - } - } - - if len(indexEntries) > 0 { - if err := s.txIndex.SetBatch(indexEntries); err != nil { - return fmt.Errorf("failed to update tx index: %w", err) - } } return nil @@ -322,7 +300,6 @@ func (s *Store) SimulateCrash() { _ = s.wal.Close() _ = s.Reader.Close() - _ = s.txIndex.Close() } // Close closes the store. @@ -352,9 +329,6 @@ func (s *Store) Close() error { err = closeErr return } - if closeErr := s.txIndex.Close(); closeErr != nil { - err = closeErr - } }) return err @@ -412,9 +386,6 @@ func (s *Store) startPruning(pruneIntervalSeconds int64) { if pruned > 0 { logger.Info("Pruned parquet file pairs older than block", "pruned-count", pruned, "block", pruneBeforeBlock) } - if err := s.txIndex.PruneBefore(uint64(pruneBeforeBlock)); err != nil { - logger.Error("failed to prune tx index", "err", err) - } } // Add random jitter (up to 50% of base interval) to avoid thundering herd diff --git a/sei-db/ledger_db/parquet/store_config_test.go b/sei-db/ledger_db/parquet/store_config_test.go index 9937e78813..200102cafd 100644 --- a/sei-db/ledger_db/parquet/store_config_test.go +++ b/sei-db/ledger_db/parquet/store_config_test.go @@ -37,9 +37,10 @@ func (m *mockParquetWAL) Close() error { return nil } func TestNewStoreAppliesConfiguredIntervals(t *testing.T) { store, err := NewStore(StoreConfig{ - DBDirectory: t.TempDir(), - BlockFlushInterval: 7, - MaxBlocksPerFile: 11, + DBDirectory: t.TempDir(), + BlockFlushInterval: 7, + MaxBlocksPerFile: 11, + DisableTxIndexLookup: true, }) require.NoError(t, err) t.Cleanup(func() { _ = store.Close() }) @@ -54,7 +55,8 @@ func TestNewStoreAppliesConfiguredIntervals(t *testing.T) { func TestNewStoreUsesDefaultIntervalsWhenUnset(t *testing.T) { store, err := NewStore(StoreConfig{ - DBDirectory: t.TempDir(), + DBDirectory: t.TempDir(), + DisableTxIndexLookup: true, }) require.NoError(t, err) t.Cleanup(func() { _ = store.Close() }) @@ -62,6 +64,7 @@ func TestNewStoreUsesDefaultIntervalsWhenUnset(t *testing.T) { require.Equal(t, defaultBlockFlushInterval, store.config.BlockFlushInterval) require.Equal(t, defaultMaxBlocksPerFile, store.config.MaxBlocksPerFile) require.Equal(t, defaultMaxBlocksPerFile, store.CacheRotateInterval()) + require.True(t, store.config.DisableTxIndexLookup) } func TestNewStorePreservesKeepRecentAndPruneIntervalSettings(t *testing.T) { @@ -79,9 +82,19 @@ func TestNewStorePreservesKeepRecentAndPruneIntervalSettings(t *testing.T) { require.True(t, store.config.DisableTxIndexLookup) } +func TestNewStorePanicsWhenTxIndexLookupEnabled(t *testing.T) { + require.PanicsWithValue(t, "not implemented", func() { + _, _ = NewStore(StoreConfig{ + DBDirectory: t.TempDir(), + DisableTxIndexLookup: false, + }) + }) +} + func TestPruneOldFilesKeepsTrackingOnDeleteFailure(t *testing.T) { store, err := NewStore(StoreConfig{ - DBDirectory: t.TempDir(), + DBDirectory: t.TempDir(), + DisableTxIndexLookup: true, }) require.NoError(t, err) t.Cleanup(func() { _ = store.Close() }) @@ -135,7 +148,8 @@ func TestCorruptLastFileDeletedOnStartup(t *testing.T) { require.NoError(t, os.WriteFile(corruptLog, []byte("not a parquet file"), 0o644)) store, err := NewStore(StoreConfig{ - DBDirectory: dir, + DBDirectory: dir, + DisableTxIndexLookup: true, }) require.NoError(t, err) t.Cleanup(func() { _ = store.Close() }) @@ -167,7 +181,8 @@ func TestCorruptLogFileUntracksReceiptCounterpart(t *testing.T) { require.NoError(t, os.WriteFile(corruptLog, []byte("not a parquet file"), 0o644)) store, err := NewStore(StoreConfig{ - DBDirectory: dir, + DBDirectory: dir, + DisableTxIndexLookup: true, }) require.NoError(t, err) t.Cleanup(func() { _ = store.Close() }) @@ -186,7 +201,8 @@ func TestLazyInitCreatesFileOnFirstWrite(t *testing.T) { dir := t.TempDir() store, err := NewStore(StoreConfig{ - DBDirectory: dir, + DBDirectory: dir, + DisableTxIndexLookup: true, }) require.NoError(t, err) t.Cleanup(func() { _ = store.Close() }) diff --git a/sei-db/ledger_db/parquet/tx_index.go b/sei-db/ledger_db/parquet/tx_index.go deleted file mode 100644 index 973a5126cd..0000000000 --- a/sei-db/ledger_db/parquet/tx_index.go +++ /dev/null @@ -1,103 +0,0 @@ -package parquet - -import ( - "encoding/binary" - "fmt" - "path/filepath" - - "github.com/cockroachdb/pebble/v2" - "github.com/ethereum/go-ethereum/common" -) - -// TxIndex is a lightweight pebble-backed index mapping tx_hash -> block_number. -// It allows GetReceiptByTxHash to narrow the parquet file search to a single file -// instead of scanning all files. -type TxIndex struct { - db *pebble.DB -} - -func OpenTxIndex(baseDir string) (*TxIndex, error) { - dir := filepath.Join(baseDir, "tx-index") - db, err := pebble.Open(dir, &pebble.Options{}) - if err != nil { - return nil, fmt.Errorf("failed to open tx index: %w", err) - } - return &TxIndex{db: db}, nil -} - -func (idx *TxIndex) Close() error { - if idx == nil || idx.db == nil { - return nil - } - return idx.db.Close() -} - -// SetBatch writes a batch of tx_hash -> block_number mappings. -func (idx *TxIndex) SetBatch(entries []TxIndexEntry) error { - if idx == nil { - return nil - } - batch := idx.db.NewBatch() - defer func() { _ = batch.Close() }() - - val := make([]byte, 8) - for _, e := range entries { - binary.BigEndian.PutUint64(val, e.BlockNumber) - if err := batch.Set(e.TxHash[:], val, pebble.NoSync); err != nil { - return err - } - } - return batch.Commit(pebble.NoSync) -} - -// GetBlockNumber returns the block number for a tx hash, or 0, false if not found. -func (idx *TxIndex) GetBlockNumber(txHash common.Hash) (uint64, bool) { - if idx == nil { - return 0, false - } - val, closer, err := idx.db.Get(txHash[:]) - if err != nil { - return 0, false - } - defer func() { _ = closer.Close() }() - if len(val) < 8 { - return 0, false - } - return binary.BigEndian.Uint64(val), true -} - -// PruneBefore deletes all entries with block_number < pruneBlock. -// This is a full scan since pebble keys are tx hashes (not ordered by block). -// Intended to run infrequently on the prune interval. -func (idx *TxIndex) PruneBefore(pruneBlock uint64) error { - if idx == nil { - return nil - } - batch := idx.db.NewBatch() - defer func() { _ = batch.Close() }() - - iter, err := idx.db.NewIter(nil) - if err != nil { - return err - } - defer func() { _ = iter.Close() }() - - for valid := iter.First(); valid; valid = iter.Next() { - val := iter.Value() - if len(val) < 8 { - continue - } - blockNum := binary.BigEndian.Uint64(val) - if blockNum < pruneBlock { - if err := batch.Delete(iter.Key(), pebble.NoSync); err != nil { - return err - } - } - } - return batch.Commit(pebble.NoSync) -} - -type TxIndexEntry struct { - TxHash common.Hash - BlockNumber uint64 -} diff --git a/sei-db/ledger_db/parquet/tx_index_test.go b/sei-db/ledger_db/parquet/tx_index_test.go deleted file mode 100644 index 6876d95f5c..0000000000 --- a/sei-db/ledger_db/parquet/tx_index_test.go +++ /dev/null @@ -1,85 +0,0 @@ -package parquet - -import ( - "testing" - - "github.com/ethereum/go-ethereum/common" - "github.com/stretchr/testify/require" -) - -func TestTxIndexSetAndGet(t *testing.T) { - idx, err := OpenTxIndex(t.TempDir()) - require.NoError(t, err) - defer func() { _ = idx.Close() }() - - txHash := common.HexToHash("0xabc123") - blockNum, ok := idx.GetBlockNumber(txHash) - require.False(t, ok, "empty index should return not found") - require.Equal(t, uint64(0), blockNum) - - require.NoError(t, idx.SetBatch([]TxIndexEntry{ - {TxHash: txHash, BlockNumber: 42}, - })) - - blockNum, ok = idx.GetBlockNumber(txHash) - require.True(t, ok) - require.Equal(t, uint64(42), blockNum) -} - -func TestTxIndexBatchWrite(t *testing.T) { - idx, err := OpenTxIndex(t.TempDir()) - require.NoError(t, err) - defer func() { _ = idx.Close() }() - - entries := make([]TxIndexEntry, 100) - for i := range entries { - var h common.Hash - h[0] = byte(i) - entries[i] = TxIndexEntry{TxHash: h, BlockNumber: uint64(1000 + i)} - } - - require.NoError(t, idx.SetBatch(entries)) - - for _, e := range entries { - blockNum, ok := idx.GetBlockNumber(e.TxHash) - require.True(t, ok) - require.Equal(t, e.BlockNumber, blockNum) - } -} - -func TestTxIndexPruneBefore(t *testing.T) { - idx, err := OpenTxIndex(t.TempDir()) - require.NoError(t, err) - defer func() { _ = idx.Close() }() - - require.NoError(t, idx.SetBatch([]TxIndexEntry{ - {TxHash: common.HexToHash("0x01"), BlockNumber: 100}, - {TxHash: common.HexToHash("0x02"), BlockNumber: 200}, - {TxHash: common.HexToHash("0x03"), BlockNumber: 300}, - {TxHash: common.HexToHash("0x04"), BlockNumber: 400}, - })) - - require.NoError(t, idx.PruneBefore(250)) - - _, ok := idx.GetBlockNumber(common.HexToHash("0x01")) - require.False(t, ok, "block 100 should be pruned") - _, ok = idx.GetBlockNumber(common.HexToHash("0x02")) - require.False(t, ok, "block 200 should be pruned") - - blockNum, ok := idx.GetBlockNumber(common.HexToHash("0x03")) - require.True(t, ok, "block 300 should survive pruning") - require.Equal(t, uint64(300), blockNum) - - blockNum, ok = idx.GetBlockNumber(common.HexToHash("0x04")) - require.True(t, ok, "block 400 should survive pruning") - require.Equal(t, uint64(400), blockNum) -} - -func TestTxIndexNilSafety(t *testing.T) { - var idx *TxIndex - require.NoError(t, idx.Close()) - require.NoError(t, idx.SetBatch([]TxIndexEntry{{BlockNumber: 1}})) - require.NoError(t, idx.PruneBefore(100)) - _, ok := idx.GetBlockNumber(common.Hash{}) - require.False(t, ok) -} diff --git a/sei-db/ledger_db/parquet/wal_test.go b/sei-db/ledger_db/parquet/wal_test.go index 2a895f3bca..842633a0b6 100644 --- a/sei-db/ledger_db/parquet/wal_test.go +++ b/sei-db/ledger_db/parquet/wal_test.go @@ -36,8 +36,9 @@ func TestClearWALActuallyFreesSpace(t *testing.T) { dir := t.TempDir() store, err := NewStore(StoreConfig{ - DBDirectory: dir, - MaxBlocksPerFile: 10, + DBDirectory: dir, + MaxBlocksPerFile: 10, + DisableTxIndexLookup: true, }) require.NoError(t, err) t.Cleanup(func() { _ = store.Close() }) @@ -86,8 +87,9 @@ func TestClearWALEmptiesAfterMultipleRotations(t *testing.T) { dir := t.TempDir() store, err := NewStore(StoreConfig{ - DBDirectory: dir, - MaxBlocksPerFile: 5, + DBDirectory: dir, + MaxBlocksPerFile: 5, + DisableTxIndexLookup: true, }) require.NoError(t, err) t.Cleanup(func() { _ = store.Close() }) diff --git a/sei-db/state_db/bench/cryptosim/config/basic-config.json b/sei-db/state_db/bench/cryptosim/config/basic-config.json index 4dc0b5571f..caa3cf5d3e 100644 --- a/sei-db/state_db/bench/cryptosim/config/basic-config.json +++ b/sei-db/state_db/bench/cryptosim/config/basic-config.json @@ -29,7 +29,7 @@ "DeleteLogDirOnStartup": false, "DeleteDataDirOnShutdown": false, "DeleteLogDirOnShutdown": false, - "DisableReceiptTxIndexLookup": false, + "DisableReceiptTxIndexLookup": true, "ExecutorQueueSize": 1024, "HotAccountProbability": 0.1, "HotErc20ContractProbability": 0.5, diff --git a/sei-db/state_db/bench/cryptosim/config/reciept-store.json b/sei-db/state_db/bench/cryptosim/config/reciept-store.json index 5641217047..acbf2dd468 100644 --- a/sei-db/state_db/bench/cryptosim/config/reciept-store.json +++ b/sei-db/state_db/bench/cryptosim/config/reciept-store.json @@ -10,7 +10,7 @@ "ReceiptReadConcurrency": 16, "ReceiptReadsPerSecond": 500000, "ReceiptReadMode": "duckdb", - "DisableReceiptTxIndexLookup": false, + "DisableReceiptTxIndexLookup": true, "ReceiptKeepRecent": 100000, "ReceiptPruneIntervalSeconds": 60, "ReceiptLogFilterReadConcurrency": 0, diff --git a/sei-db/state_db/bench/cryptosim/cryptosim_config.go b/sei-db/state_db/bench/cryptosim/cryptosim_config.go index 349c7724c5..ea2a628e24 100644 --- a/sei-db/state_db/bench/cryptosim/cryptosim_config.go +++ b/sei-db/state_db/bench/cryptosim/cryptosim_config.go @@ -198,9 +198,9 @@ type CryptoSimConfig struct { // Required when ReceiptReadConcurrency > 0. ReceiptReadMode string - // If true, disable the parquet tx_hash -> block_number index lookup during - // receipt reads. Cache hits still use the in-memory ledger cache, but backend - // misses will scan all closed parquet files instead of narrowing to one file. + // Must remain true. The parquet tx_hash -> block_number lookup path is + // intentionally unsupported; setting this to false will panic during receipt + // store initialization. DisableReceiptTxIndexLookup bool // Number of concurrent goroutines issuing log filter (eth_getLogs) queries. 0 disables log filter reads. @@ -289,7 +289,7 @@ func DefaultCryptoSimConfig() *CryptoSimConfig { ReceiptReadConcurrency: 0, ReceiptReadsPerSecond: 100, ReceiptReadMode: "cache", - DisableReceiptTxIndexLookup: false, + DisableReceiptTxIndexLookup: true, ReceiptLogFilterReadConcurrency: 0, ReceiptLogFilterReadsPerSecond: 100, ReceiptLogFilterReadMode: "cache", From 62756980eb82f8399a6eb053820fdaf944a11e5a Mon Sep 17 00:00:00 2001 From: Jeremy Wei Date: Wed, 8 Apr 2026 19:52:49 -0400 Subject: [PATCH 27/28] personal review fixes --- .../ledger_db/parquet/reader_filter_test.go | 16 ++++++---- sei-db/ledger_db/parquet/wal.go | 1 + .../ledger_db/receipt/cached_receipt_store.go | 30 +++++++++---------- .../receipt/cached_receipt_store_test.go | 30 +++++++++---------- sei-db/ledger_db/receipt/receipt_store.go | 18 +++++------ .../cryptosim/reciept_store_simulator.go | 6 ++-- 6 files changed, 54 insertions(+), 47 deletions(-) diff --git a/sei-db/ledger_db/parquet/reader_filter_test.go b/sei-db/ledger_db/parquet/reader_filter_test.go index e8458d6e08..f91d518b92 100644 --- a/sei-db/ledger_db/parquet/reader_filter_test.go +++ b/sei-db/ledger_db/parquet/reader_filter_test.go @@ -79,16 +79,22 @@ func TestGetLogsPrunesBothEnds(t *testing.T) { ctx := context.Background() - // Query blocks 1100-1400: should only need files 1000 and 1500 (not 0, 500, 2000) + // Query blocks 1400-1600: should need overlapping files 1000 and 1500, + // but still prune non-overlapping files 0, 500, and 2000. results, err := reader.GetLogs(ctx, LogFilter{ - FromBlock: uint64Ptr(1100), - ToBlock: uint64Ptr(1400), + FromBlock: uint64Ptr(1400), + ToBlock: uint64Ptr(1600), }) require.NoError(t, err) - require.Equal(t, 301, len(results)) + + for _, r := range results { + require.GreaterOrEqual(t, r.BlockNumber, uint64(1400)) + require.LessOrEqual(t, r.BlockNumber, uint64(1600)) + } + require.Equal(t, 201, len(results), "should have blocks 1400-1600 inclusive") } -func TestStoreGetReceiptByTxHashUsesFullScanWhenLookupDisabled(t *testing.T) { +func TestStoreGetReceiptByTxHashWithoutIndex(t *testing.T) { dir := t.TempDir() for _, start := range []uint64{0, 500, 1000} { diff --git a/sei-db/ledger_db/parquet/wal.go b/sei-db/ledger_db/parquet/wal.go index 498e8438fe..656414c652 100644 --- a/sei-db/ledger_db/parquet/wal.go +++ b/sei-db/ledger_db/parquet/wal.go @@ -114,6 +114,7 @@ func NewWAL(dir string) (dbwal.GenericWAL[WALEntry], error) { decodeWALEntry, dir, dbwal.Config{ + // Allow the WAL to be fully emptied after rotation/truncation. AllowEmpty: true, }, ) diff --git a/sei-db/ledger_db/receipt/cached_receipt_store.go b/sei-db/ledger_db/receipt/cached_receipt_store.go index afb5f14961..3721e1d566 100644 --- a/sei-db/ledger_db/receipt/cached_receipt_store.go +++ b/sei-db/ledger_db/receipt/cached_receipt_store.go @@ -30,10 +30,10 @@ type cachedReceiptStore struct { cacheRotateInterval uint64 cacheNextRotate uint64 cacheMu sync.Mutex - readObserver ReceiptReadObserver + readMetrics ReceiptReadMetrics } -func newCachedReceiptStore(backend ReceiptStore, observer ReceiptReadObserver) ReceiptStore { +func newCachedReceiptStore(backend ReceiptStore, metrics ReceiptReadMetrics) ReceiptStore { if backend == nil { return nil } @@ -47,7 +47,7 @@ func newCachedReceiptStore(backend ReceiptStore, observer ReceiptReadObserver) R backend: backend, cache: newLedgerCache(), cacheRotateInterval: interval, - readObserver: observer, + readMetrics: metrics, } if provider, ok := backend.(cacheWarmupProvider); ok { store.cacheReceipts(provider.warmupReceipts()) @@ -267,37 +267,37 @@ func (s *cachedReceiptStore) maybeRotateCacheLocked(blockNumber uint64) { } func (s *cachedReceiptStore) reportCacheHit() { - if s.readObserver != nil { - s.readObserver.ReportReceiptCacheHit() + if s.readMetrics != nil { + s.readMetrics.ReportReceiptCacheHit() } } func (s *cachedReceiptStore) reportCacheMiss() { - if s.readObserver != nil { - s.readObserver.ReportReceiptCacheMiss() + if s.readMetrics != nil { + s.readMetrics.ReportReceiptCacheMiss() } } func (s *cachedReceiptStore) reportLogFilterCacheHit() { - if s.readObserver != nil { - s.readObserver.ReportLogFilterCacheHit() + if s.readMetrics != nil { + s.readMetrics.ReportLogFilterCacheHit() } } func (s *cachedReceiptStore) reportLogFilterCacheMiss() { - if s.readObserver != nil { - s.readObserver.ReportLogFilterCacheMiss() + if s.readMetrics != nil { + s.readMetrics.ReportLogFilterCacheMiss() } } func (s *cachedReceiptStore) reportCacheFilterScanDuration(seconds float64) { - if s.readObserver != nil { - s.readObserver.RecordCacheFilterScanDuration(seconds) + if s.readMetrics != nil { + s.readMetrics.RecordCacheFilterScanDuration(seconds) } } func (s *cachedReceiptStore) reportCacheGetDuration(seconds float64) { - if s.readObserver != nil { - s.readObserver.RecordCacheGetDuration(seconds) + if s.readMetrics != nil { + s.readMetrics.RecordCacheGetDuration(seconds) } } diff --git a/sei-db/ledger_db/receipt/cached_receipt_store_test.go b/sei-db/ledger_db/receipt/cached_receipt_store_test.go index 83cc93d188..5a87607d78 100644 --- a/sei-db/ledger_db/receipt/cached_receipt_store_test.go +++ b/sei-db/ledger_db/receipt/cached_receipt_store_test.go @@ -21,32 +21,32 @@ type fakeReceiptBackend struct { lastFilterToBlock uint64 } -type fakeReceiptReadObserver struct { +type fakeReceiptReadMetrics struct { cacheHits int cacheMisses int logFilterCacheHits int logFilterCacheMisses int } -func (f *fakeReceiptReadObserver) ReportReceiptCacheHit() { +func (f *fakeReceiptReadMetrics) ReportReceiptCacheHit() { f.cacheHits++ } -func (f *fakeReceiptReadObserver) ReportReceiptCacheMiss() { +func (f *fakeReceiptReadMetrics) ReportReceiptCacheMiss() { f.cacheMisses++ } -func (f *fakeReceiptReadObserver) ReportLogFilterCacheHit() { +func (f *fakeReceiptReadMetrics) ReportLogFilterCacheHit() { f.logFilterCacheHits++ } -func (f *fakeReceiptReadObserver) ReportLogFilterCacheMiss() { +func (f *fakeReceiptReadMetrics) ReportLogFilterCacheMiss() { f.logFilterCacheMisses++ } -func (f *fakeReceiptReadObserver) RecordCacheFilterScanDuration(float64) {} +func (f *fakeReceiptReadMetrics) RecordCacheFilterScanDuration(float64) {} -func (f *fakeReceiptReadObserver) RecordCacheGetDuration(float64) {} +func (f *fakeReceiptReadMetrics) RecordCacheGetDuration(float64) {} func newFakeReceiptBackend() *fakeReceiptBackend { return &fakeReceiptBackend{ @@ -309,8 +309,8 @@ func TestFilterLogsMultipleBlocksCacheOnly(t *testing.T) { func TestCachedReceiptStoreReportsCacheHit(t *testing.T) { ctx, _ := newTestContext() backend := newFakeReceiptBackend() - observer := &fakeReceiptReadObserver{} - store := newCachedReceiptStore(backend, observer) + metrics := &fakeReceiptReadMetrics{} + store := newCachedReceiptStore(backend, metrics) txHash := common.HexToHash("0x10") receipt := makeTestReceipt(txHash, 7, 1, common.HexToAddress("0x100"), nil) @@ -322,19 +322,19 @@ func TestCachedReceiptStoreReportsCacheHit(t *testing.T) { require.NoError(t, err) require.Equal(t, receipt.TxHashHex, got.TxHashHex) require.Equal(t, 0, backend.getReceiptCalls) - require.Equal(t, 1, observer.cacheHits) - require.Equal(t, 0, observer.cacheMisses) + require.Equal(t, 1, metrics.cacheHits) + require.Equal(t, 0, metrics.cacheMisses) } func TestCachedReceiptStoreReportsCacheMiss(t *testing.T) { ctx, _ := newTestContext() backend := newFakeReceiptBackend() - observer := &fakeReceiptReadObserver{} - store := newCachedReceiptStore(backend, observer) + metrics := &fakeReceiptReadMetrics{} + store := newCachedReceiptStore(backend, metrics) _, err := store.GetReceipt(ctx, common.HexToHash("0x404")) require.ErrorIs(t, err, ErrNotFound) require.Equal(t, 1, backend.getReceiptCalls) - require.Equal(t, 0, observer.cacheHits) - require.Equal(t, 1, observer.cacheMisses) + require.Equal(t, 0, metrics.cacheHits) + require.Equal(t, 1, metrics.cacheMisses) } diff --git a/sei-db/ledger_db/receipt/receipt_store.go b/sei-db/ledger_db/receipt/receipt_store.go index 4777b0be66..022b9dceeb 100644 --- a/sei-db/ledger_db/receipt/receipt_store.go +++ b/sei-db/ledger_db/receipt/receipt_store.go @@ -52,9 +52,9 @@ type ReceiptRecord struct { ReceiptBytes []byte // Optional pre-marshaled receipt (must match Receipt if set) } -// ReceiptReadObserver receives callbacks when cached receipt lookups either hit -// the in-memory ledger cache or fall through to the backend store. -type ReceiptReadObserver interface { +// ReceiptReadMetrics records cache hits, misses, and timing for cached receipt +// and log reads. +type ReceiptReadMetrics interface { ReportReceiptCacheHit() ReportReceiptCacheMiss() ReportLogFilterCacheHit() @@ -88,21 +88,21 @@ func normalizeReceiptBackend(backend string) string { } func NewReceiptStore(config dbconfig.ReceiptStoreConfig, storeKey sdk.StoreKey) (ReceiptStore, error) { - return NewReceiptStoreWithReadObserver(config, storeKey, nil) + return NewReceiptStoreWithReadMetrics(config, storeKey, nil) } -// NewReceiptStoreWithReadObserver constructs a receipt store and optionally -// reports cache hits/misses for receipt-by-hash reads via observer callbacks. -func NewReceiptStoreWithReadObserver( +// NewReceiptStoreWithReadMetrics constructs a receipt store and optionally +// records cache hits, misses, and timings for cached receipt/log reads. +func NewReceiptStoreWithReadMetrics( config dbconfig.ReceiptStoreConfig, storeKey sdk.StoreKey, - observer ReceiptReadObserver, + metrics ReceiptReadMetrics, ) (ReceiptStore, error) { backend, err := newReceiptBackend(config, storeKey) if err != nil { return nil, err } - return newCachedReceiptStore(backend, observer), nil + return newCachedReceiptStore(backend, metrics), nil } // BackendTypeName returns the backend implementation name ("parquet" or "pebble") for testing. diff --git a/sei-db/state_db/bench/cryptosim/reciept_store_simulator.go b/sei-db/state_db/bench/cryptosim/reciept_store_simulator.go index 7f2a3be6ce..6b6b0fed62 100644 --- a/sei-db/state_db/bench/cryptosim/reciept_store_simulator.go +++ b/sei-db/state_db/bench/cryptosim/reciept_store_simulator.go @@ -145,9 +145,9 @@ func NewRecieptStoreSimulator( } // nil StoreKey is safe: the parquet write path never touches the legacy KV store. - // Cryptosim passes its metrics as a read observer so cache hits/misses are measured - // at the cache wrapper, which is the only layer that can distinguish them reliably. - store, err := receipt.NewReceiptStoreWithReadObserver(storeCfg, nil, metrics) + // Cryptosim passes its metrics into the cache wrapper so cache hits/misses are + // measured at the only layer that can distinguish them reliably. + store, err := receipt.NewReceiptStoreWithReadMetrics(storeCfg, nil, metrics) if err != nil { cancel() return nil, fmt.Errorf("failed to create receipt store: %w", err) From 7655181b0ff18a0b6208746a601b00b57deb72d5 Mon Sep 17 00:00:00 2001 From: Jeremy Wei Date: Wed, 8 Apr 2026 19:57:20 -0400 Subject: [PATCH 28/28] fix lint --- .../state_db/bench/cryptosim/cryptosim_config.go | 16 ++++++++++------ .../bench/cryptosim/reciept_store_simulator.go | 11 ++++++----- 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/sei-db/state_db/bench/cryptosim/cryptosim_config.go b/sei-db/state_db/bench/cryptosim/cryptosim_config.go index bbbfc5d415..cd06e077d3 100644 --- a/sei-db/state_db/bench/cryptosim/cryptosim_config.go +++ b/sei-db/state_db/bench/cryptosim/cryptosim_config.go @@ -16,6 +16,8 @@ const ( minPaddedAccountSize = 8 minErc20StorageSlotSize = 32 minErc20InteractionsPerAcct = 1 + receiptReadModeCache = "cache" + receiptReadModeDuckDB = "duckdb" ) // Defines the configuration for the cryptosim benchmark. @@ -292,11 +294,11 @@ func DefaultCryptoSimConfig() *CryptoSimConfig { MaxTPS: 0, ReceiptReadConcurrency: 0, ReceiptReadsPerSecond: 100, - ReceiptReadMode: "cache", + ReceiptReadMode: receiptReadModeCache, DisableReceiptTxIndexLookup: true, ReceiptLogFilterReadConcurrency: 0, ReceiptLogFilterReadsPerSecond: 100, - ReceiptLogFilterReadMode: "cache", + ReceiptLogFilterReadMode: receiptReadModeCache, ReceiptLogFilterMinBlockRange: 1, ReceiptLogFilterMaxBlockRange: 10, ReceiptKeepRecent: 100_000, @@ -393,9 +395,10 @@ func (c *CryptoSimConfig) Validate() error { } if c.ReceiptReadConcurrency > 0 { switch c.ReceiptReadMode { - case "cache", "duckdb": + case receiptReadModeCache, receiptReadModeDuckDB: default: - return fmt.Errorf("ReceiptReadMode must be \"cache\" or \"duckdb\" (got %q)", c.ReceiptReadMode) + return fmt.Errorf("ReceiptReadMode must be %q or %q (got %q)", + receiptReadModeCache, receiptReadModeDuckDB, c.ReceiptReadMode) } } if c.ReceiptLogFilterReadConcurrency < 0 { @@ -403,9 +406,10 @@ func (c *CryptoSimConfig) Validate() error { } if c.ReceiptLogFilterReadConcurrency > 0 { switch c.ReceiptLogFilterReadMode { - case "cache", "duckdb": + case receiptReadModeCache, receiptReadModeDuckDB: default: - return fmt.Errorf("ReceiptLogFilterReadMode must be \"cache\" or \"duckdb\" (got %q)", c.ReceiptLogFilterReadMode) + return fmt.Errorf("ReceiptLogFilterReadMode must be %q or %q (got %q)", + receiptReadModeCache, receiptReadModeDuckDB, c.ReceiptLogFilterReadMode) } } if c.ReceiptLogFilterMinBlockRange < 1 { diff --git a/sei-db/state_db/bench/cryptosim/reciept_store_simulator.go b/sei-db/state_db/bench/cryptosim/reciept_store_simulator.go index 6b6b0fed62..8e48ecb34a 100644 --- a/sei-db/state_db/bench/cryptosim/reciept_store_simulator.go +++ b/sei-db/state_db/bench/cryptosim/reciept_store_simulator.go @@ -334,14 +334,14 @@ func (r *RecieptStoreSimulator) executeReceiptRead(crand *CannedRandom) { buffer := cacheWindow / 10 var entry *txHashEntry switch r.config.ReceiptReadMode { - case "cache": + case receiptReadModeCache: safeWindow := cacheWindow - buffer minBlock := uint64(0) if latest > safeWindow { minBlock = latest - safeWindow } entry = r.txRing.RandomEntryInBlockRange(crand, minBlock, latest) - case "duckdb": + case receiptReadModeDuckDB: maxBlock := uint64(0) if latest > cacheWindow { maxBlock = latest - cacheWindow - 1 @@ -387,10 +387,11 @@ func (r *RecieptStoreSimulator) executeLogFilterRead(crand *CannedRandom) { return } + //nolint:gosec // Config validation guarantees a positive block range before this conversion. rangeSize := uint64(crand.Int64Range( int64(r.config.ReceiptLogFilterMinBlockRange), int64(r.config.ReceiptLogFilterMaxBlockRange)+1, - )) //nolint:gosec + )) latest := uint64(latestVersion) //nolint:gosec cacheWindow := r.receiptCacheWindowBlocks @@ -398,7 +399,7 @@ func (r *RecieptStoreSimulator) executeLogFilterRead(crand *CannedRandom) { buffer := cacheWindow / 10 switch r.config.ReceiptLogFilterReadMode { - case "cache": + case receiptReadModeCache: safeWindow := cacheWindow - buffer cacheMin := uint64(0) if latest > safeWindow { @@ -412,7 +413,7 @@ func (r *RecieptStoreSimulator) executeLogFilterRead(crand *CannedRandom) { if toBlock > latest { toBlock = latest } - case "duckdb": + case receiptReadModeDuckDB: if latest <= cacheWindow { return }