From ecb8eea741bc4a1cf8f213468ca503fe5afd105d Mon Sep 17 00:00:00 2001 From: Yaroslav Kukharuk Date: Fri, 22 May 2026 16:56:34 +0200 Subject: [PATCH 1/7] refactor(lru): switch to golang-lru --- blockchain/aggregated_bloom_filter_cache.go | 10 +++++++--- go.mod | 2 +- rpc/v10/handlers.go | 12 +++++++----- rpc/v8/handlers.go | 8 ++++++-- rpc/v9/handlers.go | 11 ++++++----- sync/pending_polling.go | 8 ++++---- 6 files changed, 31 insertions(+), 20 deletions(-) diff --git a/blockchain/aggregated_bloom_filter_cache.go b/blockchain/aggregated_bloom_filter_cache.go index e7f6c40131..eeaf9e5d9c 100644 --- a/blockchain/aggregated_bloom_filter_cache.go +++ b/blockchain/aggregated_bloom_filter_cache.go @@ -6,7 +6,7 @@ import ( "github.com/NethermindEth/juno/core" "github.com/bits-and-blooms/bitset" - "github.com/ethereum/go-ethereum/common/lru" + lru "github.com/hashicorp/golang-lru/v2" ) // NOTE(Ege): consider making it configurable @@ -27,15 +27,19 @@ type EventFiltersCacheKey struct { // for block ranges, supporting fallback loading and bulk insertion. // It is safe for concurrent use. type AggregatedBloomFilterCache struct { - cache lru.Cache[EventFiltersCacheKey, *core.AggregatedBloomFilter] + cache *lru.Cache[EventFiltersCacheKey, *core.AggregatedBloomFilter] fallbackFunc func(EventFiltersCacheKey) (core.AggregatedBloomFilter, error) } // NewAggregatedBloomCache creates a new LRU cache for aggregated bloom filters // with the specified maximum size (number of ranges to cache). func NewAggregatedBloomCache(size int) AggregatedBloomFilterCache { + // TODO: error below is raised only when size is <= 0. + // Modifying the return signature with cascade in bunch of code changes. + // Do it, but rather as the an optional/last step to reduce noise + cache, _ := lru.New[EventFiltersCacheKey, *core.AggregatedBloomFilter](size) return AggregatedBloomFilterCache{ - cache: *lru.NewCache[EventFiltersCacheKey, *core.AggregatedBloomFilter](size), + cache: cache, } } diff --git a/go.mod b/go.mod index 90f777e562..ffb73e3cc2 100644 --- a/go.mod +++ b/go.mod @@ -15,6 +15,7 @@ require ( github.com/ethereum/go-ethereum v1.17.3 github.com/fxamacker/cbor/v2 v2.9.2 github.com/go-playground/validator/v10 v10.30.2 + github.com/hashicorp/golang-lru/v2 v2.0.7 github.com/libp2p/go-libp2p v0.48.0 github.com/libp2p/go-libp2p-kad-dht v0.39.1 github.com/libp2p/go-libp2p-pubsub v0.16.0 @@ -84,7 +85,6 @@ require ( github.com/google/uuid v1.6.0 // indirect github.com/gorilla/websocket v1.5.3 // indirect github.com/hashicorp/golang-lru v1.0.2 // indirect - github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect github.com/holiman/uint256 v1.3.2 // indirect github.com/huin/goupnp v1.3.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect diff --git a/rpc/v10/handlers.go b/rpc/v10/handlers.go index f1b3be80a6..11ad970f6f 100644 --- a/rpc/v10/handlers.go +++ b/rpc/v10/handlers.go @@ -23,7 +23,7 @@ import ( "github.com/NethermindEth/juno/utils/log" "github.com/NethermindEth/juno/vm" "github.com/ethereum/go-ethereum/accounts/abi" - "github.com/ethereum/go-ethereum/common/lru" + lru "github.com/hashicorp/golang-lru/v2" "github.com/sourcegraph/conc" ) @@ -78,6 +78,11 @@ func New( if err != nil { logger.Fatalf("Failed to parse ABI: %v", err) } + + cache, err := lru.New[rpccore.TraceCacheKey, TraceBlockTransactionsResponse](rpccore.TraceCacheSize) + if err != nil { + logger.Fatalf("Failed to initilize LRU cache: %v", err) + } return &Handler{ bcReader: bcReader, syncReader: syncReader, @@ -95,10 +100,7 @@ func New( l1Heads: feed.New[*core.L1Head](), preLatestFeed: feed.New[*pending.PreLatest](), - blockTraceCache: lru.NewCache[ - rpccore.TraceCacheKey, - TraceBlockTransactionsResponse, - ](rpccore.TraceCacheSize), + blockTraceCache: cache, filterLimit: math.MaxUint, coreContractABI: contractABI, } diff --git a/rpc/v8/handlers.go b/rpc/v8/handlers.go index 145feb5c74..16cf865f6f 100644 --- a/rpc/v8/handlers.go +++ b/rpc/v8/handlers.go @@ -23,7 +23,7 @@ import ( "github.com/NethermindEth/juno/utils/log" "github.com/NethermindEth/juno/vm" "github.com/ethereum/go-ethereum/accounts/abi" - "github.com/ethereum/go-ethereum/common/lru" + lru "github.com/hashicorp/golang-lru/v2" "github.com/sourcegraph/conc" ) @@ -73,6 +73,10 @@ func New( if err != nil { logger.Fatalf("Failed to parse ABI: %v", err) } + cache, err := lru.New[rpccore.TraceCacheKey, []TracedBlockTransaction](rpccore.TraceCacheSize) + if err != nil { + logger.Fatalf("Failed to initilize LRU cache: %v", err) + } return &Handler{ bcReader: bcReader, syncReader: syncReader, @@ -89,7 +93,7 @@ func New( preConfirmedFeed: feed.New[*pendingpkg.PreConfirmed](), l1Heads: feed.New[*core.L1Head](), - blockTraceCache: lru.NewCache[rpccore.TraceCacheKey, []TracedBlockTransaction](rpccore.TraceCacheSize), + blockTraceCache: cache, filterLimit: math.MaxUint, coreContractABI: contractABI, } diff --git a/rpc/v9/handlers.go b/rpc/v9/handlers.go index 3048f88880..8b92567cae 100644 --- a/rpc/v9/handlers.go +++ b/rpc/v9/handlers.go @@ -23,7 +23,7 @@ import ( "github.com/NethermindEth/juno/utils/log" "github.com/NethermindEth/juno/vm" "github.com/ethereum/go-ethereum/accounts/abi" - "github.com/ethereum/go-ethereum/common/lru" + lru "github.com/hashicorp/golang-lru/v2" "github.com/sourcegraph/conc" ) @@ -77,6 +77,10 @@ func New( if err != nil { logger.Fatalf("Failed to parse ABI: %v", err) } + cache, err := lru.New[rpccore.TraceCacheKey, []TracedBlockTransaction](rpccore.TraceCacheSize) + if err != nil { + logger.Fatalf("Failed to initilize LRU cache: %v", err) + } return &Handler{ bcReader: bcReader, syncReader: syncReader, @@ -94,10 +98,7 @@ func New( l1Heads: feed.New[*core.L1Head](), preLatestFeed: feed.New[*pending.PreLatest](), - blockTraceCache: lru.NewCache[ - rpccore.TraceCacheKey, - []TracedBlockTransaction, - ](rpccore.TraceCacheSize), + blockTraceCache: cache, filterLimit: math.MaxUint, coreContractABI: contractABI, } diff --git a/sync/pending_polling.go b/sync/pending_polling.go index 82b5659a95..055f715907 100644 --- a/sync/pending_polling.go +++ b/sync/pending_polling.go @@ -10,7 +10,7 @@ import ( "github.com/NethermindEth/juno/core/felt" "github.com/NethermindEth/juno/core/pending" "github.com/NethermindEth/juno/db" - "github.com/ethereum/go-ethereum/common/lru" + "github.com/hashicorp/golang-lru/v2/simplelru" "go.uber.org/zap" ) @@ -130,7 +130,7 @@ func (s *Synchronizer) storeEmptyPreConfirmed( func (s *Synchronizer) handleTickerPreLatest( ctx context.Context, currentHead *core.Block, - seenByParent *lru.BasicLRU[felt.Felt, *pending.PreLatest], + seenByParent *simplelru.LRU[felt.Felt, *pending.PreLatest], out chan<- *pending.PreLatest, ) bool { preLatest, err := s.dataSource.BlockPreLatest(ctx) @@ -168,7 +168,7 @@ func (s *Synchronizer) pollPreLatest(ctx context.Context, out chan<- *pending.Pr // Cache of pre-latest blocks keyed by the hash of their parent. // When we receive the head with this parent hash, we emit the cached pre-latest. - seenByParent := lru.NewBasicLRU[felt.Felt, *pending.PreLatest](preLatestCacheSize) + seenByParent, _ := simplelru.NewLRU[felt.Felt, *pending.PreLatest](preLatestCacheSize, nil) ticker := time.NewTicker(s.preLatestPollInterval) defer ticker.Stop() @@ -219,7 +219,7 @@ func (s *Synchronizer) pollPreLatest(ctx context.Context, out chan<- *pending.Pr deliveredForHead = s.handleTickerPreLatest( ctx, currentHead, - &seenByParent, + seenByParent, out, ) } From f68d61e39100e2496ca6f502af0f5c5f06076f21 Mon Sep 17 00:00:00 2001 From: Yaroslav Kukharuk Date: Fri, 22 May 2026 17:11:20 +0200 Subject: [PATCH 2/7] refactor(lru): wrap lru constructors so we could panic on error. Rationale is that LRU returns a error if the size is <= 0, and in our case, all the size parameters are comptime known constants, so discarding a error is fine at the moment, but panics (hopefully) protect us in the future in case of mis-use --- blockchain/aggregated_bloom_filter_cache.go | 9 ++---- .../aggregated_bloom_filter_cache_test.go | 8 ++--- blockchain/blockchain.go | 2 +- rpc/v10/handlers.go | 7 ++--- rpc/v8/handlers.go | 7 ++--- rpc/v9/handlers.go | 7 ++--- sync/pending_polling.go | 3 +- utils/lru.go | 30 +++++++++++++++++++ 8 files changed, 46 insertions(+), 27 deletions(-) create mode 100644 utils/lru.go diff --git a/blockchain/aggregated_bloom_filter_cache.go b/blockchain/aggregated_bloom_filter_cache.go index eeaf9e5d9c..fd7000db0c 100644 --- a/blockchain/aggregated_bloom_filter_cache.go +++ b/blockchain/aggregated_bloom_filter_cache.go @@ -5,6 +5,7 @@ import ( "fmt" "github.com/NethermindEth/juno/core" + "github.com/NethermindEth/juno/utils" "github.com/bits-and-blooms/bitset" lru "github.com/hashicorp/golang-lru/v2" ) @@ -33,13 +34,9 @@ type AggregatedBloomFilterCache struct { // NewAggregatedBloomCache creates a new LRU cache for aggregated bloom filters // with the specified maximum size (number of ranges to cache). -func NewAggregatedBloomCache(size int) AggregatedBloomFilterCache { - // TODO: error below is raised only when size is <= 0. - // Modifying the return signature with cascade in bunch of code changes. - // Do it, but rather as the an optional/last step to reduce noise - cache, _ := lru.New[EventFiltersCacheKey, *core.AggregatedBloomFilter](size) +func NewAggregatedBloomCache() AggregatedBloomFilterCache { return AggregatedBloomFilterCache{ - cache: cache, + cache: utils.NewLRU[EventFiltersCacheKey, *core.AggregatedBloomFilter](AggregatedBloomFilterCacheSize), } } diff --git a/blockchain/aggregated_bloom_filter_cache_test.go b/blockchain/aggregated_bloom_filter_cache_test.go index 22f80f554a..906ec346d9 100644 --- a/blockchain/aggregated_bloom_filter_cache_test.go +++ b/blockchain/aggregated_bloom_filter_cache_test.go @@ -147,7 +147,7 @@ func populateAggregatedBloomDeterministic( func TestMatchBlockIterator_InsertAndQueryRandomEvents(t *testing.T) { numEvents := 64 - numAggregatedBloomFilters := uint64(16) + var numAggregatedBloomFilters uint64 = blockchain.AggregatedBloomFilterCacheSize blocksPerFilter := core.NumBlocksPerFilter chainHeight := numAggregatedBloomFilters*blocksPerFilter - 1 @@ -159,7 +159,7 @@ func TestMatchBlockIterator_InsertAndQueryRandomEvents(t *testing.T) { testDB := memory.New() // Create cache and insert filters - cache := blockchain.NewAggregatedBloomCache(int(numAggregatedBloomFilters)) + cache := blockchain.NewAggregatedBloomCache() cache.SetMany(filters) runningFilterStart := numAggregatedBloomFilters * blocksPerFilter innerFilter := core.NewAggregatedFilter(runningFilterStart) @@ -188,7 +188,7 @@ func TestMatchBlockIterator_InsertAndQueryRandomEvents(t *testing.T) { } func TestMatchedBlockIterator_BasicCases(t *testing.T) { - var numAggregatedBloomFilters uint64 = 16 + var numAggregatedBloomFilters uint64 = blockchain.AggregatedBloomFilterCacheSize chainHeight := numAggregatedBloomFilters*core.NumBlocksPerFilter - 1 events := generateRandomEvents(t, 1, 3, 1) @@ -196,7 +196,7 @@ func TestMatchedBlockIterator_BasicCases(t *testing.T) { emmitedEvery := 4 filters := populateAggregatedBloomDeterministic(t, numAggregatedBloomFilters, test, core.NumBlocksPerFilter, uint64(emmitedEvery)) - cache := blockchain.NewAggregatedBloomCache(int(numAggregatedBloomFilters)) + cache := blockchain.NewAggregatedBloomCache() cache.SetMany(filters) testDB := memory.New() diff --git a/blockchain/blockchain.go b/blockchain/blockchain.go index 20f6f617da..9776721d66 100644 --- a/blockchain/blockchain.go +++ b/blockchain/blockchain.go @@ -129,7 +129,7 @@ func New(database db.KeyValueStore, network *networks.Network, opts ...Option) * opt(&o) } - cachedFilters := NewAggregatedBloomCache(AggregatedBloomFilterCacheSize) + cachedFilters := NewAggregatedBloomCache() fallback := func(key EventFiltersCacheKey) (core.AggregatedBloomFilter, error) { return core.GetAggregatedBloomFilter(database, key.fromBlock, key.toBlock) } diff --git a/rpc/v10/handlers.go b/rpc/v10/handlers.go index 11ad970f6f..9aacb73e8a 100644 --- a/rpc/v10/handlers.go +++ b/rpc/v10/handlers.go @@ -20,6 +20,7 @@ import ( "github.com/NethermindEth/juno/rpc/rpccore" "github.com/NethermindEth/juno/starknet/compiler" "github.com/NethermindEth/juno/sync" + "github.com/NethermindEth/juno/utils" "github.com/NethermindEth/juno/utils/log" "github.com/NethermindEth/juno/vm" "github.com/ethereum/go-ethereum/accounts/abi" @@ -79,10 +80,6 @@ func New( logger.Fatalf("Failed to parse ABI: %v", err) } - cache, err := lru.New[rpccore.TraceCacheKey, TraceBlockTransactionsResponse](rpccore.TraceCacheSize) - if err != nil { - logger.Fatalf("Failed to initilize LRU cache: %v", err) - } return &Handler{ bcReader: bcReader, syncReader: syncReader, @@ -100,7 +97,7 @@ func New( l1Heads: feed.New[*core.L1Head](), preLatestFeed: feed.New[*pending.PreLatest](), - blockTraceCache: cache, + blockTraceCache: utils.NewLRU[rpccore.TraceCacheKey, TraceBlockTransactionsResponse](rpccore.TraceCacheSize), filterLimit: math.MaxUint, coreContractABI: contractABI, } diff --git a/rpc/v8/handlers.go b/rpc/v8/handlers.go index 16cf865f6f..4474223213 100644 --- a/rpc/v8/handlers.go +++ b/rpc/v8/handlers.go @@ -20,6 +20,7 @@ import ( "github.com/NethermindEth/juno/rpc/rpccore" "github.com/NethermindEth/juno/starknet/compiler" "github.com/NethermindEth/juno/sync" + "github.com/NethermindEth/juno/utils" "github.com/NethermindEth/juno/utils/log" "github.com/NethermindEth/juno/vm" "github.com/ethereum/go-ethereum/accounts/abi" @@ -73,10 +74,6 @@ func New( if err != nil { logger.Fatalf("Failed to parse ABI: %v", err) } - cache, err := lru.New[rpccore.TraceCacheKey, []TracedBlockTransaction](rpccore.TraceCacheSize) - if err != nil { - logger.Fatalf("Failed to initilize LRU cache: %v", err) - } return &Handler{ bcReader: bcReader, syncReader: syncReader, @@ -93,7 +90,7 @@ func New( preConfirmedFeed: feed.New[*pendingpkg.PreConfirmed](), l1Heads: feed.New[*core.L1Head](), - blockTraceCache: cache, + blockTraceCache: utils.NewLRU[rpccore.TraceCacheKey, []TracedBlockTransaction](rpccore.TraceCacheSize), filterLimit: math.MaxUint, coreContractABI: contractABI, } diff --git a/rpc/v9/handlers.go b/rpc/v9/handlers.go index 8b92567cae..68f8eb307d 100644 --- a/rpc/v9/handlers.go +++ b/rpc/v9/handlers.go @@ -20,6 +20,7 @@ import ( "github.com/NethermindEth/juno/rpc/rpccore" "github.com/NethermindEth/juno/starknet/compiler" "github.com/NethermindEth/juno/sync" + "github.com/NethermindEth/juno/utils" "github.com/NethermindEth/juno/utils/log" "github.com/NethermindEth/juno/vm" "github.com/ethereum/go-ethereum/accounts/abi" @@ -77,10 +78,6 @@ func New( if err != nil { logger.Fatalf("Failed to parse ABI: %v", err) } - cache, err := lru.New[rpccore.TraceCacheKey, []TracedBlockTransaction](rpccore.TraceCacheSize) - if err != nil { - logger.Fatalf("Failed to initilize LRU cache: %v", err) - } return &Handler{ bcReader: bcReader, syncReader: syncReader, @@ -98,7 +95,7 @@ func New( l1Heads: feed.New[*core.L1Head](), preLatestFeed: feed.New[*pending.PreLatest](), - blockTraceCache: cache, + blockTraceCache: utils.NewLRU[rpccore.TraceCacheKey, []TracedBlockTransaction](rpccore.TraceCacheSize), filterLimit: math.MaxUint, coreContractABI: contractABI, } diff --git a/sync/pending_polling.go b/sync/pending_polling.go index 055f715907..45ee05cbb0 100644 --- a/sync/pending_polling.go +++ b/sync/pending_polling.go @@ -10,6 +10,7 @@ import ( "github.com/NethermindEth/juno/core/felt" "github.com/NethermindEth/juno/core/pending" "github.com/NethermindEth/juno/db" + "github.com/NethermindEth/juno/utils" "github.com/hashicorp/golang-lru/v2/simplelru" "go.uber.org/zap" ) @@ -168,7 +169,7 @@ func (s *Synchronizer) pollPreLatest(ctx context.Context, out chan<- *pending.Pr // Cache of pre-latest blocks keyed by the hash of their parent. // When we receive the head with this parent hash, we emit the cached pre-latest. - seenByParent, _ := simplelru.NewLRU[felt.Felt, *pending.PreLatest](preLatestCacheSize, nil) + seenByParent := utils.NewSimpleLRU[felt.Felt, *pending.PreLatest](preLatestCacheSize) ticker := time.NewTicker(s.preLatestPollInterval) defer ticker.Stop() diff --git a/utils/lru.go b/utils/lru.go new file mode 100644 index 0000000000..e2199ad766 --- /dev/null +++ b/utils/lru.go @@ -0,0 +1,30 @@ +package utils + +import ( + "fmt" + + lru "github.com/hashicorp/golang-lru/v2" + "github.com/hashicorp/golang-lru/v2/simplelru" +) + +// NewLRU returns a new thread-safe LRU cache or panics if size <= 0. +// Use for caches sized by constants or validated config, where a zero size +// indicates a programmer error rather than a runtime condition. +func NewLRU[K comparable, V any](size int) *lru.Cache[K, V] { + c, err := lru.New[K, V](size) + if err != nil { + panic(fmt.Errorf("lru: %w (size=%d)", err, size)) + } + return c +} + +// NewSimpleLRU returns a new non-thread-safe LRU cache or panics if size <= 0. +// Use the same way as NewLRU when external synchronization is provided +// (e.g. single-goroutine ownership). +func NewSimpleLRU[K comparable, V any](size int) *simplelru.LRU[K, V] { + c, err := simplelru.NewLRU[K, V](size, nil) + if err != nil { + panic(fmt.Errorf("simplelru: %w (size=%d)", err, size)) + } + return c +} From bfeda6c6b074a18145213d09a1d03b646c9afbe2 Mon Sep 17 00:00:00 2001 From: Yaroslav Kukharuk Date: Fri, 22 May 2026 17:53:40 +0200 Subject: [PATCH 3/7] add tests and fix lint errors --- blockchain/aggregated_bloom_filter_cache.go | 6 +- rpc/v10/handlers.go | 5 +- rpc/v8/handlers.go | 5 +- rpc/v9/handlers.go | 5 +- utils/lru_test.go | 66 +++++++++++++++++++++ 5 files changed, 82 insertions(+), 5 deletions(-) create mode 100644 utils/lru_test.go diff --git a/blockchain/aggregated_bloom_filter_cache.go b/blockchain/aggregated_bloom_filter_cache.go index fd7000db0c..137be86850 100644 --- a/blockchain/aggregated_bloom_filter_cache.go +++ b/blockchain/aggregated_bloom_filter_cache.go @@ -33,10 +33,12 @@ type AggregatedBloomFilterCache struct { } // NewAggregatedBloomCache creates a new LRU cache for aggregated bloom filters -// with the specified maximum size (number of ranges to cache). func NewAggregatedBloomCache() AggregatedBloomFilterCache { return AggregatedBloomFilterCache{ - cache: utils.NewLRU[EventFiltersCacheKey, *core.AggregatedBloomFilter](AggregatedBloomFilterCacheSize), + cache: utils.NewLRU[ + EventFiltersCacheKey, + *core.AggregatedBloomFilter, + ](AggregatedBloomFilterCacheSize), } } diff --git a/rpc/v10/handlers.go b/rpc/v10/handlers.go index 9aacb73e8a..6eff5c6989 100644 --- a/rpc/v10/handlers.go +++ b/rpc/v10/handlers.go @@ -97,7 +97,10 @@ func New( l1Heads: feed.New[*core.L1Head](), preLatestFeed: feed.New[*pending.PreLatest](), - blockTraceCache: utils.NewLRU[rpccore.TraceCacheKey, TraceBlockTransactionsResponse](rpccore.TraceCacheSize), + blockTraceCache: utils.NewLRU[ + rpccore.TraceCacheKey, + TraceBlockTransactionsResponse, + ](rpccore.TraceCacheSize), filterLimit: math.MaxUint, coreContractABI: contractABI, } diff --git a/rpc/v8/handlers.go b/rpc/v8/handlers.go index 4474223213..c67ecf3e33 100644 --- a/rpc/v8/handlers.go +++ b/rpc/v8/handlers.go @@ -90,7 +90,10 @@ func New( preConfirmedFeed: feed.New[*pendingpkg.PreConfirmed](), l1Heads: feed.New[*core.L1Head](), - blockTraceCache: utils.NewLRU[rpccore.TraceCacheKey, []TracedBlockTransaction](rpccore.TraceCacheSize), + blockTraceCache: utils.NewLRU[ + rpccore.TraceCacheKey, + []TracedBlockTransaction, + ](rpccore.TraceCacheSize), filterLimit: math.MaxUint, coreContractABI: contractABI, } diff --git a/rpc/v9/handlers.go b/rpc/v9/handlers.go index 68f8eb307d..5cffaffcca 100644 --- a/rpc/v9/handlers.go +++ b/rpc/v9/handlers.go @@ -95,7 +95,10 @@ func New( l1Heads: feed.New[*core.L1Head](), preLatestFeed: feed.New[*pending.PreLatest](), - blockTraceCache: utils.NewLRU[rpccore.TraceCacheKey, []TracedBlockTransaction](rpccore.TraceCacheSize), + blockTraceCache: utils.NewLRU[ + rpccore.TraceCacheKey, + []TracedBlockTransaction, + ](rpccore.TraceCacheSize), filterLimit: math.MaxUint, coreContractABI: contractABI, } diff --git a/utils/lru_test.go b/utils/lru_test.go new file mode 100644 index 0000000000..6d9399095f --- /dev/null +++ b/utils/lru_test.go @@ -0,0 +1,66 @@ +package utils + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +//nolint:dupl // duplicate tests as there's identical APIs +func TestNewLRU(t *testing.T) { + t.Run("returns usable cache for positive size", func(t *testing.T) { + c := NewLRU[string, int](2) + require.NotNil(t, c) + assert.Equal(t, 0, c.Len()) + + c.Add("a", 1) + c.Add("b", 2) + assert.Equal(t, 2, c.Len()) + + v, ok := c.Get("a") + assert.True(t, ok) + assert.Equal(t, 1, v) + }) + + t.Run("panics on zero size", func(t *testing.T) { + assert.PanicsWithError(t, "lru: must provide a positive size (size=0)", func() { + NewLRU[string, int](0) + }) + }) + + t.Run("panics on negative size", func(t *testing.T) { + assert.PanicsWithError(t, "lru: must provide a positive size (size=-1)", func() { + NewLRU[string, int](-1) + }) + }) +} + +//nolint:dupl // duplicate tests as there's identical APIs +func TestNewSimpleLRU(t *testing.T) { + t.Run("returns usable cache for positive size", func(t *testing.T) { + c := NewSimpleLRU[string, int](2) + require.NotNil(t, c) + assert.Equal(t, 0, c.Len()) + + c.Add("a", 1) + c.Add("b", 2) + assert.Equal(t, 2, c.Len()) + + v, ok := c.Get("a") + assert.True(t, ok) + assert.Equal(t, 1, v) + }) + + t.Run("panics on zero size", func(t *testing.T) { + assert.PanicsWithError(t, "simplelru: must provide a positive size (size=0)", func() { + NewSimpleLRU[string, int](0) + }) + }) + + t.Run("panics on negative size", func(t *testing.T) { + assert.PanicsWithError(t, "simplelru: must provide a positive size (size=-1)", func() { + NewSimpleLRU[string, int](-1) + }) + }) +} From 3ad6be2e0b81055b6bd89850ed33b840b9b6c72a Mon Sep 17 00:00:00 2001 From: Yaroslav Kukharuk Date: Mon, 25 May 2026 14:17:09 +0200 Subject: [PATCH 4/7] wrap golang-lru in our package YAGNI style --- blockchain/aggregated_bloom_filter_cache.go | 3 +- rpc/v10/handlers.go | 3 +- rpc/v8/handlers.go | 3 +- rpc/v9/handlers.go | 3 +- sync/pending_polling.go | 3 +- utils/lru.go | 33 +++++++++++++++++---- 6 files changed, 32 insertions(+), 16 deletions(-) diff --git a/blockchain/aggregated_bloom_filter_cache.go b/blockchain/aggregated_bloom_filter_cache.go index 137be86850..fe70fe22ce 100644 --- a/blockchain/aggregated_bloom_filter_cache.go +++ b/blockchain/aggregated_bloom_filter_cache.go @@ -7,7 +7,6 @@ import ( "github.com/NethermindEth/juno/core" "github.com/NethermindEth/juno/utils" "github.com/bits-and-blooms/bitset" - lru "github.com/hashicorp/golang-lru/v2" ) // NOTE(Ege): consider making it configurable @@ -28,7 +27,7 @@ type EventFiltersCacheKey struct { // for block ranges, supporting fallback loading and bulk insertion. // It is safe for concurrent use. type AggregatedBloomFilterCache struct { - cache *lru.Cache[EventFiltersCacheKey, *core.AggregatedBloomFilter] + cache *utils.LRU[EventFiltersCacheKey, *core.AggregatedBloomFilter] fallbackFunc func(EventFiltersCacheKey) (core.AggregatedBloomFilter, error) } diff --git a/rpc/v10/handlers.go b/rpc/v10/handlers.go index 6eff5c6989..e5eb31a3ba 100644 --- a/rpc/v10/handlers.go +++ b/rpc/v10/handlers.go @@ -24,7 +24,6 @@ import ( "github.com/NethermindEth/juno/utils/log" "github.com/NethermindEth/juno/vm" "github.com/ethereum/go-ethereum/accounts/abi" - lru "github.com/hashicorp/golang-lru/v2" "github.com/sourcegraph/conc" ) @@ -49,7 +48,7 @@ type Handler struct { // todo(rdr): why do we have the `TraceCacheKey` type and why it feels uncomfortable // to use. It makes no sense, why not use `Felt` or `Hash` directly? - blockTraceCache *lru.Cache[rpccore.TraceCacheKey, TraceBlockTransactionsResponse] + blockTraceCache *utils.LRU[rpccore.TraceCacheKey, TraceBlockTransactionsResponse] // todo(rdr): Can this cache be genericified and can it be applied to the `blockTraceCache` submittedTransactionsCache *rpccore.TransactionCache diff --git a/rpc/v8/handlers.go b/rpc/v8/handlers.go index c67ecf3e33..9efeef6628 100644 --- a/rpc/v8/handlers.go +++ b/rpc/v8/handlers.go @@ -24,7 +24,6 @@ import ( "github.com/NethermindEth/juno/utils/log" "github.com/NethermindEth/juno/vm" "github.com/ethereum/go-ethereum/accounts/abi" - lru "github.com/hashicorp/golang-lru/v2" "github.com/sourcegraph/conc" ) @@ -47,7 +46,7 @@ type Handler struct { idgen func() string subscriptions stdsync.Map // map[string]*subscription - blockTraceCache *lru.Cache[rpccore.TraceCacheKey, []TracedBlockTransaction] + blockTraceCache *utils.LRU[rpccore.TraceCacheKey, []TracedBlockTransaction] submittedTransactionsCache *rpccore.TransactionCache filterLimit uint diff --git a/rpc/v9/handlers.go b/rpc/v9/handlers.go index 5cffaffcca..23ef638402 100644 --- a/rpc/v9/handlers.go +++ b/rpc/v9/handlers.go @@ -24,7 +24,6 @@ import ( "github.com/NethermindEth/juno/utils/log" "github.com/NethermindEth/juno/vm" "github.com/ethereum/go-ethereum/accounts/abi" - lru "github.com/hashicorp/golang-lru/v2" "github.com/sourcegraph/conc" ) @@ -50,7 +49,7 @@ type Handler struct { // todo(rdr): why do we have the `TraceCacheKey` type and why it feels uncomfortable // to use. It makes no sense, why not use `Felt` or `Hash` directly? - blockTraceCache *lru.Cache[rpccore.TraceCacheKey, []TracedBlockTransaction] + blockTraceCache *utils.LRU[rpccore.TraceCacheKey, []TracedBlockTransaction] // todo(rdr): Can this cache be genericified and can it be applied to the `blockTraceCache` submittedTransactionsCache *rpccore.TransactionCache diff --git a/sync/pending_polling.go b/sync/pending_polling.go index 45ee05cbb0..9ae53063d1 100644 --- a/sync/pending_polling.go +++ b/sync/pending_polling.go @@ -11,7 +11,6 @@ import ( "github.com/NethermindEth/juno/core/pending" "github.com/NethermindEth/juno/db" "github.com/NethermindEth/juno/utils" - "github.com/hashicorp/golang-lru/v2/simplelru" "go.uber.org/zap" ) @@ -131,7 +130,7 @@ func (s *Synchronizer) storeEmptyPreConfirmed( func (s *Synchronizer) handleTickerPreLatest( ctx context.Context, currentHead *core.Block, - seenByParent *simplelru.LRU[felt.Felt, *pending.PreLatest], + seenByParent *utils.SimpleLRU[felt.Felt, *pending.PreLatest], out chan<- *pending.PreLatest, ) bool { preLatest, err := s.dataSource.BlockPreLatest(ctx) diff --git a/utils/lru.go b/utils/lru.go index e2199ad766..d936ddab20 100644 --- a/utils/lru.go +++ b/utils/lru.go @@ -7,24 +7,45 @@ import ( "github.com/hashicorp/golang-lru/v2/simplelru" ) +// LRU is a thread-safe size-bounded LRU cache. +type LRU[K comparable, V any] struct { + c *lru.Cache[K, V] +} + // NewLRU returns a new thread-safe LRU cache or panics if size <= 0. // Use for caches sized by constants or validated config, where a zero size // indicates a programmer error rather than a runtime condition. -func NewLRU[K comparable, V any](size int) *lru.Cache[K, V] { +func NewLRU[K comparable, V any](size int) *LRU[K, V] { c, err := lru.New[K, V](size) if err != nil { panic(fmt.Errorf("lru: %w (size=%d)", err, size)) } - return c + return &LRU[K, V]{c: c} +} + +func (l *LRU[K, V]) Add(key K, value V) (evicted bool) { return l.c.Add(key, value) } +func (l *LRU[K, V]) Get(key K) (V, bool) { return l.c.Get(key) } +func (l *LRU[K, V]) Remove(key K) (present bool) { return l.c.Remove(key) } +func (l *LRU[K, V]) Purge() { l.c.Purge() } +func (l *LRU[K, V]) Len() int { return l.c.Len() } + +// SimpleLRU is a non-thread-safe size-bounded LRU cache. +// Use when external synchronization is provided (e.g. single-goroutine ownership). +type SimpleLRU[K comparable, V any] struct { + c *simplelru.LRU[K, V] } // NewSimpleLRU returns a new non-thread-safe LRU cache or panics if size <= 0. -// Use the same way as NewLRU when external synchronization is provided -// (e.g. single-goroutine ownership). -func NewSimpleLRU[K comparable, V any](size int) *simplelru.LRU[K, V] { +func NewSimpleLRU[K comparable, V any](size int) *SimpleLRU[K, V] { c, err := simplelru.NewLRU[K, V](size, nil) if err != nil { panic(fmt.Errorf("simplelru: %w (size=%d)", err, size)) } - return c + return &SimpleLRU[K, V]{c: c} } + +func (l *SimpleLRU[K, V]) Add(key K, value V) (evicted bool) { return l.c.Add(key, value) } +func (l *SimpleLRU[K, V]) Get(key K) (V, bool) { return l.c.Get(key) } +func (l *SimpleLRU[K, V]) Remove(key K) (present bool) { return l.c.Remove(key) } +func (l *SimpleLRU[K, V]) Purge() { l.c.Purge() } +func (l *SimpleLRU[K, V]) Len() int { return l.c.Len() } From 4354dd1d15b64bcabc01b1a8601e88bb1a583881 Mon Sep 17 00:00:00 2001 From: Yaroslav Kukharuk Date: Wed, 27 May 2026 12:59:47 +0200 Subject: [PATCH 5/7] add missing tests --- utils/lru_test.go | 54 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/utils/lru_test.go b/utils/lru_test.go index 6d9399095f..2ed7f22002 100644 --- a/utils/lru_test.go +++ b/utils/lru_test.go @@ -36,6 +36,44 @@ func TestNewLRU(t *testing.T) { }) } +func TestLRU_Remove(t *testing.T) { + t.Run("removes present key", func(t *testing.T) { + c := NewLRU[string, int](2) + c.Add("a", 1) + c.Add("b", 2) + + assert.True(t, c.Remove("a")) + assert.Equal(t, 1, c.Len()) + + _, ok := c.Get("a") + assert.False(t, ok) + }) + + t.Run("returns false for missing key", func(t *testing.T) { + c := NewLRU[string, int](2) + c.Add("a", 1) + + assert.False(t, c.Remove("missing")) + assert.Equal(t, 1, c.Len()) + }) +} + +func TestLRU_Purge(t *testing.T) { + c := NewLRU[string, int](3) + c.Add("a", 1) + c.Add("b", 2) + c.Add("c", 3) + + c.Purge() + assert.Equal(t, 0, c.Len()) + + _, ok := c.Get("a") + assert.False(t, ok) + + c.Add("d", 4) + assert.Equal(t, 1, c.Len()) +} + //nolint:dupl // duplicate tests as there's identical APIs func TestNewSimpleLRU(t *testing.T) { t.Run("returns usable cache for positive size", func(t *testing.T) { @@ -64,3 +102,19 @@ func TestNewSimpleLRU(t *testing.T) { }) }) } + +func TestSimpleLRU_Purge(t *testing.T) { + c := NewSimpleLRU[string, int](3) + c.Add("a", 1) + c.Add("b", 2) + c.Add("c", 3) + + c.Purge() + assert.Equal(t, 0, c.Len()) + + _, ok := c.Get("a") + assert.False(t, ok) + + c.Add("d", 4) + assert.Equal(t, 1, c.Len()) +} From 1d8d5783ec420627e24071b47702a5583c54acca Mon Sep 17 00:00:00 2001 From: Yaroslav Kukharuk Date: Wed, 27 May 2026 13:18:53 +0200 Subject: [PATCH 6/7] revert AggregatedBloomFilterCache changes --- blockchain/aggregated_bloom_filter_cache.go | 5 +++-- blockchain/aggregated_bloom_filter_cache_test.go | 8 ++++---- blockchain/blockchain.go | 2 +- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/blockchain/aggregated_bloom_filter_cache.go b/blockchain/aggregated_bloom_filter_cache.go index fe70fe22ce..778f512306 100644 --- a/blockchain/aggregated_bloom_filter_cache.go +++ b/blockchain/aggregated_bloom_filter_cache.go @@ -32,12 +32,13 @@ type AggregatedBloomFilterCache struct { } // NewAggregatedBloomCache creates a new LRU cache for aggregated bloom filters -func NewAggregatedBloomCache() AggregatedBloomFilterCache { +// with the specified maximum size (number of ranges to cache). +func NewAggregatedBloomCache(size int) AggregatedBloomFilterCache { return AggregatedBloomFilterCache{ cache: utils.NewLRU[ EventFiltersCacheKey, *core.AggregatedBloomFilter, - ](AggregatedBloomFilterCacheSize), + ](size), } } diff --git a/blockchain/aggregated_bloom_filter_cache_test.go b/blockchain/aggregated_bloom_filter_cache_test.go index 906ec346d9..22f80f554a 100644 --- a/blockchain/aggregated_bloom_filter_cache_test.go +++ b/blockchain/aggregated_bloom_filter_cache_test.go @@ -147,7 +147,7 @@ func populateAggregatedBloomDeterministic( func TestMatchBlockIterator_InsertAndQueryRandomEvents(t *testing.T) { numEvents := 64 - var numAggregatedBloomFilters uint64 = blockchain.AggregatedBloomFilterCacheSize + numAggregatedBloomFilters := uint64(16) blocksPerFilter := core.NumBlocksPerFilter chainHeight := numAggregatedBloomFilters*blocksPerFilter - 1 @@ -159,7 +159,7 @@ func TestMatchBlockIterator_InsertAndQueryRandomEvents(t *testing.T) { testDB := memory.New() // Create cache and insert filters - cache := blockchain.NewAggregatedBloomCache() + cache := blockchain.NewAggregatedBloomCache(int(numAggregatedBloomFilters)) cache.SetMany(filters) runningFilterStart := numAggregatedBloomFilters * blocksPerFilter innerFilter := core.NewAggregatedFilter(runningFilterStart) @@ -188,7 +188,7 @@ func TestMatchBlockIterator_InsertAndQueryRandomEvents(t *testing.T) { } func TestMatchedBlockIterator_BasicCases(t *testing.T) { - var numAggregatedBloomFilters uint64 = blockchain.AggregatedBloomFilterCacheSize + var numAggregatedBloomFilters uint64 = 16 chainHeight := numAggregatedBloomFilters*core.NumBlocksPerFilter - 1 events := generateRandomEvents(t, 1, 3, 1) @@ -196,7 +196,7 @@ func TestMatchedBlockIterator_BasicCases(t *testing.T) { emmitedEvery := 4 filters := populateAggregatedBloomDeterministic(t, numAggregatedBloomFilters, test, core.NumBlocksPerFilter, uint64(emmitedEvery)) - cache := blockchain.NewAggregatedBloomCache() + cache := blockchain.NewAggregatedBloomCache(int(numAggregatedBloomFilters)) cache.SetMany(filters) testDB := memory.New() diff --git a/blockchain/blockchain.go b/blockchain/blockchain.go index 9776721d66..20f6f617da 100644 --- a/blockchain/blockchain.go +++ b/blockchain/blockchain.go @@ -129,7 +129,7 @@ func New(database db.KeyValueStore, network *networks.Network, opts ...Option) * opt(&o) } - cachedFilters := NewAggregatedBloomCache() + cachedFilters := NewAggregatedBloomCache(AggregatedBloomFilterCacheSize) fallback := func(key EventFiltersCacheKey) (core.AggregatedBloomFilter, error) { return core.GetAggregatedBloomFilter(database, key.fromBlock, key.toBlock) } From f48a4c9e93fad62f36a816c8862a09fd64ed1522 Mon Sep 17 00:00:00 2001 From: Yaroslav Kukharuk Date: Thu, 28 May 2026 13:48:08 +0200 Subject: [PATCH 7/7] refactor(lru): extract into lru package --- blockchain/aggregated_bloom_filter_cache.go | 6 +- rpc/v10/handlers.go | 6 +- rpc/v8/handlers.go | 6 +- rpc/v9/handlers.go | 6 +- sync/pending_polling.go | 6 +- utils/lru.go | 51 ---- utils/lru/lru.go | 51 ++++ utils/lru/lru_bench_test.go | 309 ++++++++++++++++++++ utils/{ => lru}/lru_test.go | 32 +- 9 files changed, 391 insertions(+), 82 deletions(-) delete mode 100644 utils/lru.go create mode 100644 utils/lru/lru.go create mode 100644 utils/lru/lru_bench_test.go rename utils/{ => lru}/lru_test.go (80%) diff --git a/blockchain/aggregated_bloom_filter_cache.go b/blockchain/aggregated_bloom_filter_cache.go index 778f512306..d93c917efd 100644 --- a/blockchain/aggregated_bloom_filter_cache.go +++ b/blockchain/aggregated_bloom_filter_cache.go @@ -5,7 +5,7 @@ import ( "fmt" "github.com/NethermindEth/juno/core" - "github.com/NethermindEth/juno/utils" + "github.com/NethermindEth/juno/utils/lru" "github.com/bits-and-blooms/bitset" ) @@ -27,7 +27,7 @@ type EventFiltersCacheKey struct { // for block ranges, supporting fallback loading and bulk insertion. // It is safe for concurrent use. type AggregatedBloomFilterCache struct { - cache *utils.LRU[EventFiltersCacheKey, *core.AggregatedBloomFilter] + cache *lru.Cache[EventFiltersCacheKey, *core.AggregatedBloomFilter] fallbackFunc func(EventFiltersCacheKey) (core.AggregatedBloomFilter, error) } @@ -35,7 +35,7 @@ type AggregatedBloomFilterCache struct { // with the specified maximum size (number of ranges to cache). func NewAggregatedBloomCache(size int) AggregatedBloomFilterCache { return AggregatedBloomFilterCache{ - cache: utils.NewLRU[ + cache: lru.New[ EventFiltersCacheKey, *core.AggregatedBloomFilter, ](size), diff --git a/rpc/v10/handlers.go b/rpc/v10/handlers.go index e5eb31a3ba..593722062e 100644 --- a/rpc/v10/handlers.go +++ b/rpc/v10/handlers.go @@ -20,8 +20,8 @@ import ( "github.com/NethermindEth/juno/rpc/rpccore" "github.com/NethermindEth/juno/starknet/compiler" "github.com/NethermindEth/juno/sync" - "github.com/NethermindEth/juno/utils" "github.com/NethermindEth/juno/utils/log" + "github.com/NethermindEth/juno/utils/lru" "github.com/NethermindEth/juno/vm" "github.com/ethereum/go-ethereum/accounts/abi" "github.com/sourcegraph/conc" @@ -48,7 +48,7 @@ type Handler struct { // todo(rdr): why do we have the `TraceCacheKey` type and why it feels uncomfortable // to use. It makes no sense, why not use `Felt` or `Hash` directly? - blockTraceCache *utils.LRU[rpccore.TraceCacheKey, TraceBlockTransactionsResponse] + blockTraceCache *lru.Cache[rpccore.TraceCacheKey, TraceBlockTransactionsResponse] // todo(rdr): Can this cache be genericified and can it be applied to the `blockTraceCache` submittedTransactionsCache *rpccore.TransactionCache @@ -96,7 +96,7 @@ func New( l1Heads: feed.New[*core.L1Head](), preLatestFeed: feed.New[*pending.PreLatest](), - blockTraceCache: utils.NewLRU[ + blockTraceCache: lru.New[ rpccore.TraceCacheKey, TraceBlockTransactionsResponse, ](rpccore.TraceCacheSize), diff --git a/rpc/v8/handlers.go b/rpc/v8/handlers.go index 9efeef6628..c231f6e220 100644 --- a/rpc/v8/handlers.go +++ b/rpc/v8/handlers.go @@ -20,8 +20,8 @@ import ( "github.com/NethermindEth/juno/rpc/rpccore" "github.com/NethermindEth/juno/starknet/compiler" "github.com/NethermindEth/juno/sync" - "github.com/NethermindEth/juno/utils" "github.com/NethermindEth/juno/utils/log" + "github.com/NethermindEth/juno/utils/lru" "github.com/NethermindEth/juno/vm" "github.com/ethereum/go-ethereum/accounts/abi" "github.com/sourcegraph/conc" @@ -46,7 +46,7 @@ type Handler struct { idgen func() string subscriptions stdsync.Map // map[string]*subscription - blockTraceCache *utils.LRU[rpccore.TraceCacheKey, []TracedBlockTransaction] + blockTraceCache *lru.Cache[rpccore.TraceCacheKey, []TracedBlockTransaction] submittedTransactionsCache *rpccore.TransactionCache filterLimit uint @@ -89,7 +89,7 @@ func New( preConfirmedFeed: feed.New[*pendingpkg.PreConfirmed](), l1Heads: feed.New[*core.L1Head](), - blockTraceCache: utils.NewLRU[ + blockTraceCache: lru.New[ rpccore.TraceCacheKey, []TracedBlockTransaction, ](rpccore.TraceCacheSize), diff --git a/rpc/v9/handlers.go b/rpc/v9/handlers.go index 23ef638402..d169ce40a3 100644 --- a/rpc/v9/handlers.go +++ b/rpc/v9/handlers.go @@ -20,8 +20,8 @@ import ( "github.com/NethermindEth/juno/rpc/rpccore" "github.com/NethermindEth/juno/starknet/compiler" "github.com/NethermindEth/juno/sync" - "github.com/NethermindEth/juno/utils" "github.com/NethermindEth/juno/utils/log" + "github.com/NethermindEth/juno/utils/lru" "github.com/NethermindEth/juno/vm" "github.com/ethereum/go-ethereum/accounts/abi" "github.com/sourcegraph/conc" @@ -49,7 +49,7 @@ type Handler struct { // todo(rdr): why do we have the `TraceCacheKey` type and why it feels uncomfortable // to use. It makes no sense, why not use `Felt` or `Hash` directly? - blockTraceCache *utils.LRU[rpccore.TraceCacheKey, []TracedBlockTransaction] + blockTraceCache *lru.Cache[rpccore.TraceCacheKey, []TracedBlockTransaction] // todo(rdr): Can this cache be genericified and can it be applied to the `blockTraceCache` submittedTransactionsCache *rpccore.TransactionCache @@ -94,7 +94,7 @@ func New( l1Heads: feed.New[*core.L1Head](), preLatestFeed: feed.New[*pending.PreLatest](), - blockTraceCache: utils.NewLRU[ + blockTraceCache: lru.New[ rpccore.TraceCacheKey, []TracedBlockTransaction, ](rpccore.TraceCacheSize), diff --git a/sync/pending_polling.go b/sync/pending_polling.go index 9ae53063d1..29af7f31f6 100644 --- a/sync/pending_polling.go +++ b/sync/pending_polling.go @@ -10,7 +10,7 @@ import ( "github.com/NethermindEth/juno/core/felt" "github.com/NethermindEth/juno/core/pending" "github.com/NethermindEth/juno/db" - "github.com/NethermindEth/juno/utils" + "github.com/NethermindEth/juno/utils/lru" "go.uber.org/zap" ) @@ -130,7 +130,7 @@ func (s *Synchronizer) storeEmptyPreConfirmed( func (s *Synchronizer) handleTickerPreLatest( ctx context.Context, currentHead *core.Block, - seenByParent *utils.SimpleLRU[felt.Felt, *pending.PreLatest], + seenByParent *lru.SimpleCache[felt.Felt, *pending.PreLatest], out chan<- *pending.PreLatest, ) bool { preLatest, err := s.dataSource.BlockPreLatest(ctx) @@ -168,7 +168,7 @@ func (s *Synchronizer) pollPreLatest(ctx context.Context, out chan<- *pending.Pr // Cache of pre-latest blocks keyed by the hash of their parent. // When we receive the head with this parent hash, we emit the cached pre-latest. - seenByParent := utils.NewSimpleLRU[felt.Felt, *pending.PreLatest](preLatestCacheSize) + seenByParent := lru.NewSimple[felt.Felt, *pending.PreLatest](preLatestCacheSize) ticker := time.NewTicker(s.preLatestPollInterval) defer ticker.Stop() diff --git a/utils/lru.go b/utils/lru.go deleted file mode 100644 index d936ddab20..0000000000 --- a/utils/lru.go +++ /dev/null @@ -1,51 +0,0 @@ -package utils - -import ( - "fmt" - - lru "github.com/hashicorp/golang-lru/v2" - "github.com/hashicorp/golang-lru/v2/simplelru" -) - -// LRU is a thread-safe size-bounded LRU cache. -type LRU[K comparable, V any] struct { - c *lru.Cache[K, V] -} - -// NewLRU returns a new thread-safe LRU cache or panics if size <= 0. -// Use for caches sized by constants or validated config, where a zero size -// indicates a programmer error rather than a runtime condition. -func NewLRU[K comparable, V any](size int) *LRU[K, V] { - c, err := lru.New[K, V](size) - if err != nil { - panic(fmt.Errorf("lru: %w (size=%d)", err, size)) - } - return &LRU[K, V]{c: c} -} - -func (l *LRU[K, V]) Add(key K, value V) (evicted bool) { return l.c.Add(key, value) } -func (l *LRU[K, V]) Get(key K) (V, bool) { return l.c.Get(key) } -func (l *LRU[K, V]) Remove(key K) (present bool) { return l.c.Remove(key) } -func (l *LRU[K, V]) Purge() { l.c.Purge() } -func (l *LRU[K, V]) Len() int { return l.c.Len() } - -// SimpleLRU is a non-thread-safe size-bounded LRU cache. -// Use when external synchronization is provided (e.g. single-goroutine ownership). -type SimpleLRU[K comparable, V any] struct { - c *simplelru.LRU[K, V] -} - -// NewSimpleLRU returns a new non-thread-safe LRU cache or panics if size <= 0. -func NewSimpleLRU[K comparable, V any](size int) *SimpleLRU[K, V] { - c, err := simplelru.NewLRU[K, V](size, nil) - if err != nil { - panic(fmt.Errorf("simplelru: %w (size=%d)", err, size)) - } - return &SimpleLRU[K, V]{c: c} -} - -func (l *SimpleLRU[K, V]) Add(key K, value V) (evicted bool) { return l.c.Add(key, value) } -func (l *SimpleLRU[K, V]) Get(key K) (V, bool) { return l.c.Get(key) } -func (l *SimpleLRU[K, V]) Remove(key K) (present bool) { return l.c.Remove(key) } -func (l *SimpleLRU[K, V]) Purge() { l.c.Purge() } -func (l *SimpleLRU[K, V]) Len() int { return l.c.Len() } diff --git a/utils/lru/lru.go b/utils/lru/lru.go new file mode 100644 index 0000000000..a91a3866c7 --- /dev/null +++ b/utils/lru/lru.go @@ -0,0 +1,51 @@ +package lru + +import ( + "fmt" + + hashilru "github.com/hashicorp/golang-lru/v2" + "github.com/hashicorp/golang-lru/v2/simplelru" +) + +// Cache is a thread-safe size-bounded LRU cache. +type Cache[K comparable, V any] struct { + c *hashilru.Cache[K, V] +} + +// New returns a new thread-safe LRU cache or panics if size <= 0. +// Use for caches sized by constants or validated config, where a zero size +// indicates a programmer error rather than a runtime condition. +func New[K comparable, V any](size int) *Cache[K, V] { + c, err := hashilru.New[K, V](size) + if err != nil { + panic(fmt.Errorf("lru: %w (size=%d)", err, size)) + } + return &Cache[K, V]{c: c} +} + +func (l *Cache[K, V]) Add(key K, value V) (evicted bool) { return l.c.Add(key, value) } +func (l *Cache[K, V]) Get(key K) (V, bool) { return l.c.Get(key) } +func (l *Cache[K, V]) Remove(key K) (present bool) { return l.c.Remove(key) } +func (l *Cache[K, V]) Purge() { l.c.Purge() } +func (l *Cache[K, V]) Len() int { return l.c.Len() } + +// SimpleCache is a non-thread-safe size-bounded LRU cache. +// Use when external synchronization is provided (e.g. single-goroutine ownership). +type SimpleCache[K comparable, V any] struct { + c *simplelru.LRU[K, V] +} + +// NewSimple returns a new non-thread-safe LRU cache or panics if size <= 0. +func NewSimple[K comparable, V any](size int) *SimpleCache[K, V] { + c, err := simplelru.NewLRU[K, V](size, nil) + if err != nil { + panic(fmt.Errorf("simplelru: %w (size=%d)", err, size)) + } + return &SimpleCache[K, V]{c: c} +} + +func (l *SimpleCache[K, V]) Add(key K, value V) (evicted bool) { return l.c.Add(key, value) } +func (l *SimpleCache[K, V]) Get(key K) (V, bool) { return l.c.Get(key) } +func (l *SimpleCache[K, V]) Remove(key K) (present bool) { return l.c.Remove(key) } +func (l *SimpleCache[K, V]) Purge() { l.c.Purge() } +func (l *SimpleCache[K, V]) Len() int { return l.c.Len() } diff --git a/utils/lru/lru_bench_test.go b/utils/lru/lru_bench_test.go new file mode 100644 index 0000000000..b72dc48f19 --- /dev/null +++ b/utils/lru/lru_bench_test.go @@ -0,0 +1,309 @@ +package lru + +import ( + "math/rand/v2" + "testing" + + gethlru "github.com/ethereum/go-ethereum/common/lru" + hashilru "github.com/hashicorp/golang-lru/v2" + hashisimple "github.com/hashicorp/golang-lru/v2/simplelru" +) + +// Paired benchmarks: ethereum/common/lru (old) vs hashicorp/golang-lru/v2 (new). +// Key shapes match real callsites: +// - int baseline +// - bloomKey {u64, u64} blockchain.EventFiltersCacheKey +// - feltKey [4]uint64 rpccore.TraceCacheKey (felt.Felt underlies as [4]uint64) +// +// Sizes mirror production: +// - 16 AggregatedBloomFilterCacheSize +// - 128 TraceCacheSize +// - 8192 stress +// +// Run with: go test -bench=. -benchmem -count=10 ./utils/lru/ + +type bloomKey struct { + from uint64 + to uint64 +} + +type feltKey [4]uint64 + +// generic cache interface so benchmarks can be written once. +type lruCache[K comparable] interface { + Add(key K, value int) (evicted bool) + Get(key K) (value int, ok bool) +} + +// adapters + +type gethCache[K comparable] struct{ c *gethlru.Cache[K, int] } + +func (g gethCache[K]) Add(k K, v int) bool { return g.c.Add(k, v) } +func (g gethCache[K]) Get(k K) (int, bool) { return g.c.Get(k) } + +type hashiCache[K comparable] struct{ c *hashilru.Cache[K, int] } + +func (h hashiCache[K]) Add(k K, v int) bool { evicted := h.c.Add(k, v); return evicted } +func (h hashiCache[K]) Get(k K) (int, bool) { return h.c.Get(k) } + +func newGeth[K comparable](size int) lruCache[K] { + return gethCache[K]{c: gethlru.NewCache[K, int](size)} +} + +func newHashi[K comparable](size int) lruCache[K] { + c, err := hashilru.New[K, int](size) + if err != nil { + panic(err) + } + return hashiCache[K]{c: c} +} + +// key generators + +func intKey(i int) int { return i } +func bloomFromInt(i int) bloomKey { return bloomKey{from: uint64(i), to: uint64(i) + 1024} } +func feltFromInt(i int) feltKey { + return feltKey{uint64(i), uint64(i) >> 1, uint64(i) << 1, ^uint64(i)} +} + +// per-impl, per-keytype matrix runner + +type implCase[K comparable] struct { + name string + make func(int) lruCache[K] +} + +func impls[K comparable]() []implCase[K] { + return []implCase[K]{ + {name: "geth", make: newGeth[K]}, + {name: "hashi", make: newHashi[K]}, + } +} + +// --- Add-only (write-heavy with evictions) --- + +func benchAdd[K comparable](b *testing.B, size int, gen func(int) K) { + for _, imp := range impls[K]() { + b.Run(imp.name, func(b *testing.B) { + c := imp.make(size) + b.ReportAllocs() + b.ResetTimer() + for i := range b.N { + c.Add(gen(i), i) + } + }) + } +} + +func BenchmarkAdd_Int_128(b *testing.B) { benchAdd[int](b, 128, intKey) } +func BenchmarkAdd_Int_8192(b *testing.B) { benchAdd[int](b, 8192, intKey) } +func BenchmarkAdd_Bloom_16(b *testing.B) { benchAdd[bloomKey](b, 16, bloomFromInt) } +func BenchmarkAdd_Felt_128(b *testing.B) { benchAdd[feltKey](b, 128, feltFromInt) } + +// --- Get hit (read-heavy, all keys present) --- + +func benchGetHit[K comparable](b *testing.B, size int, gen func(int) K) { + for _, imp := range impls[K]() { + b.Run(imp.name, func(b *testing.B) { + c := imp.make(size) + for i := range size { + c.Add(gen(i), i) + } + b.ReportAllocs() + b.ResetTimer() + for i := range b.N { + _, _ = c.Get(gen(i % size)) + } + }) + } +} + +func BenchmarkGetHit_Int_128(b *testing.B) { benchGetHit[int](b, 128, intKey) } +func BenchmarkGetHit_Int_8192(b *testing.B) { benchGetHit[int](b, 8192, intKey) } +func BenchmarkGetHit_Bloom_16(b *testing.B) { benchGetHit[bloomKey](b, 16, bloomFromInt) } +func BenchmarkGetHit_Felt_128(b *testing.B) { benchGetHit[feltKey](b, 128, feltFromInt) } + +// --- Get miss (all lookups fail) --- + +func benchGetMiss[K comparable](b *testing.B, size int, gen func(int) K) { + for _, imp := range impls[K]() { + b.Run(imp.name, func(b *testing.B) { + c := imp.make(size) + for i := range size { + c.Add(gen(i), i) + } + b.ReportAllocs() + b.ResetTimer() + for i := range b.N { + _, _ = c.Get(gen(size + i)) + } + }) + } +} + +func BenchmarkGetMiss_Int_128(b *testing.B) { benchGetMiss[int](b, 128, intKey) } +func BenchmarkGetMiss_Bloom_16(b *testing.B) { benchGetMiss[bloomKey](b, 16, bloomFromInt) } +func BenchmarkGetMiss_Felt_128(b *testing.B) { benchGetMiss[feltKey](b, 128, feltFromInt) } + +// --- Mixed 80/20 read/write with random keys over 2*size space (~50% hit rate) --- + +//nolint:dupl // benchSimpleMixed mirrors this shape over a different cache interface. +func benchMixed[K comparable](b *testing.B, size int, gen func(int) K) { + for _, imp := range impls[K]() { + b.Run(imp.name, func(b *testing.B) { + c := imp.make(size) + for i := range size { + c.Add(gen(i), i) + } + rng := rand.New(rand.NewPCG(1, 2)) + space := size * 2 + b.ReportAllocs() + b.ResetTimer() + for range b.N { + k := rng.IntN(space) + if rng.IntN(10) < 8 { + _, _ = c.Get(gen(k)) + } else { + c.Add(gen(k), k) + } + } + }) + } +} + +func BenchmarkMixed_Int_128(b *testing.B) { benchMixed[int](b, 128, intKey) } +func BenchmarkMixed_Bloom_16(b *testing.B) { benchMixed[bloomKey](b, 16, bloomFromInt) } +func BenchmarkMixed_Felt_128(b *testing.B) { benchMixed[feltKey](b, 128, feltFromInt) } + +// --- Parallel mixed: measures lock contention --- + +func benchParallelMixed[K comparable](b *testing.B, size int, gen func(int) K) { + for _, imp := range impls[K]() { + b.Run(imp.name, func(b *testing.B) { + c := imp.make(size) + for i := range size { + c.Add(gen(i), i) + } + b.ReportAllocs() + b.ResetTimer() + b.RunParallel(func(pb *testing.PB) { + rng := rand.New(rand.NewPCG(rand.Uint64(), rand.Uint64())) + space := size * 2 + for pb.Next() { + k := rng.IntN(space) + if rng.IntN(10) < 8 { + _, _ = c.Get(gen(k)) + } else { + c.Add(gen(k), k) + } + } + }) + }) + } +} + +func BenchmarkParallel_Int_128(b *testing.B) { benchParallelMixed[int](b, 128, intKey) } +func BenchmarkParallel_Bloom_16(b *testing.B) { benchParallelMixed[bloomKey](b, 16, bloomFromInt) } +func BenchmarkParallel_Felt_128(b *testing.B) { benchParallelMixed[feltKey](b, 128, feltFromInt) } + +// --- Non-thread-safe variants (basiclru vs simplelru) --- +// Mirrors sync/pending_polling.go preLatest cache: felt key, size=10. +// No locks → measures pure data-structure cost. + +type simpleCache[K comparable] interface { + Add(key K, value int) bool + Get(key K) (int, bool) +} + +type gethBasic[K comparable] struct{ c gethlru.BasicLRU[K, int] } + +func (g *gethBasic[K]) Add(k K, v int) bool { return g.c.Add(k, v) } +func (g *gethBasic[K]) Get(k K) (int, bool) { return g.c.Get(k) } + +type hashiSimple[K comparable] struct{ c *hashisimple.LRU[K, int] } + +func (h *hashiSimple[K]) Add(k K, v int) bool { return h.c.Add(k, v) } +func (h *hashiSimple[K]) Get(k K) (int, bool) { return h.c.Get(k) } + +func newGethBasic[K comparable](size int) simpleCache[K] { + c := gethlru.NewBasicLRU[K, int](size) + return &gethBasic[K]{c: c} +} + +func newHashiSimple[K comparable](size int) simpleCache[K] { + c, err := hashisimple.NewLRU[K, int](size, nil) + if err != nil { + panic(err) + } + return &hashiSimple[K]{c: c} +} + +type simpleImplCase[K comparable] struct { + name string + make func(int) simpleCache[K] +} + +func simpleImpls[K comparable]() []simpleImplCase[K] { + return []simpleImplCase[K]{ + {name: "geth-basic", make: newGethBasic[K]}, + {name: "hashi-simple", make: newHashiSimple[K]}, + } +} + +func benchSimpleAdd[K comparable](b *testing.B, size int, gen func(int) K) { + for _, imp := range simpleImpls[K]() { + b.Run(imp.name, func(b *testing.B) { + c := imp.make(size) + b.ReportAllocs() + b.ResetTimer() + for i := range b.N { + c.Add(gen(i), i) + } + }) + } +} + +func benchSimpleGetHit[K comparable](b *testing.B, size int, gen func(int) K) { + for _, imp := range simpleImpls[K]() { + b.Run(imp.name, func(b *testing.B) { + c := imp.make(size) + for i := range size { + c.Add(gen(i), i) + } + b.ReportAllocs() + b.ResetTimer() + for i := range b.N { + _, _ = c.Get(gen(i % size)) + } + }) + } +} + +//nolint:dupl // benchMixed mirrors this shape over a different cache interface. +func benchSimpleMixed[K comparable](b *testing.B, size int, gen func(int) K) { + for _, imp := range simpleImpls[K]() { + b.Run(imp.name, func(b *testing.B) { + c := imp.make(size) + for i := range size { + c.Add(gen(i), i) + } + rng := rand.New(rand.NewPCG(1, 2)) + space := size * 2 + b.ReportAllocs() + b.ResetTimer() + for range b.N { + k := rng.IntN(space) + if rng.IntN(10) < 8 { + _, _ = c.Get(gen(k)) + } else { + c.Add(gen(k), k) + } + } + }) + } +} + +func BenchmarkSimpleAdd_Felt_10(b *testing.B) { benchSimpleAdd[feltKey](b, 10, feltFromInt) } +func BenchmarkSimpleGetHit_Felt_10(b *testing.B) { benchSimpleGetHit[feltKey](b, 10, feltFromInt) } +func BenchmarkSimpleMixed_Felt_10(b *testing.B) { benchSimpleMixed[feltKey](b, 10, feltFromInt) } diff --git a/utils/lru_test.go b/utils/lru/lru_test.go similarity index 80% rename from utils/lru_test.go rename to utils/lru/lru_test.go index 2ed7f22002..cf0380ef69 100644 --- a/utils/lru_test.go +++ b/utils/lru/lru_test.go @@ -1,4 +1,4 @@ -package utils +package lru import ( "testing" @@ -8,9 +8,9 @@ import ( ) //nolint:dupl // duplicate tests as there's identical APIs -func TestNewLRU(t *testing.T) { +func TestNew(t *testing.T) { t.Run("returns usable cache for positive size", func(t *testing.T) { - c := NewLRU[string, int](2) + c := New[string, int](2) require.NotNil(t, c) assert.Equal(t, 0, c.Len()) @@ -25,20 +25,20 @@ func TestNewLRU(t *testing.T) { t.Run("panics on zero size", func(t *testing.T) { assert.PanicsWithError(t, "lru: must provide a positive size (size=0)", func() { - NewLRU[string, int](0) + New[string, int](0) }) }) t.Run("panics on negative size", func(t *testing.T) { assert.PanicsWithError(t, "lru: must provide a positive size (size=-1)", func() { - NewLRU[string, int](-1) + New[string, int](-1) }) }) } -func TestLRU_Remove(t *testing.T) { +func TestCache_Remove(t *testing.T) { t.Run("removes present key", func(t *testing.T) { - c := NewLRU[string, int](2) + c := New[string, int](2) c.Add("a", 1) c.Add("b", 2) @@ -50,7 +50,7 @@ func TestLRU_Remove(t *testing.T) { }) t.Run("returns false for missing key", func(t *testing.T) { - c := NewLRU[string, int](2) + c := New[string, int](2) c.Add("a", 1) assert.False(t, c.Remove("missing")) @@ -58,8 +58,8 @@ func TestLRU_Remove(t *testing.T) { }) } -func TestLRU_Purge(t *testing.T) { - c := NewLRU[string, int](3) +func TestCache_Purge(t *testing.T) { + c := New[string, int](3) c.Add("a", 1) c.Add("b", 2) c.Add("c", 3) @@ -75,9 +75,9 @@ func TestLRU_Purge(t *testing.T) { } //nolint:dupl // duplicate tests as there's identical APIs -func TestNewSimpleLRU(t *testing.T) { +func TestNewSimple(t *testing.T) { t.Run("returns usable cache for positive size", func(t *testing.T) { - c := NewSimpleLRU[string, int](2) + c := NewSimple[string, int](2) require.NotNil(t, c) assert.Equal(t, 0, c.Len()) @@ -92,19 +92,19 @@ func TestNewSimpleLRU(t *testing.T) { t.Run("panics on zero size", func(t *testing.T) { assert.PanicsWithError(t, "simplelru: must provide a positive size (size=0)", func() { - NewSimpleLRU[string, int](0) + NewSimple[string, int](0) }) }) t.Run("panics on negative size", func(t *testing.T) { assert.PanicsWithError(t, "simplelru: must provide a positive size (size=-1)", func() { - NewSimpleLRU[string, int](-1) + NewSimple[string, int](-1) }) }) } -func TestSimpleLRU_Purge(t *testing.T) { - c := NewSimpleLRU[string, int](3) +func TestSimpleCache_Purge(t *testing.T) { + c := NewSimple[string, int](3) c.Add("a", 1) c.Add("b", 2) c.Add("c", 3)