Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 63 additions & 10 deletions chainstore/cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package chainstore

import (
"math/big"
"strconv"
"sync"
"time"
)
Expand All @@ -13,6 +14,39 @@ type CachedValue[T any] struct {
ttl time.Duration
}

// feeHistoryCacheKey identifies an eth_feeHistory request. The percentile
// slice is serialized so the key does not retain caller-owned mutable memory.
type feeHistoryCacheKey struct {
blockCount int
newestBlock string
rewardPercentiles string
}

func newFeeHistoryCacheKey(blockCount int, newestBlock string, rewardPercentiles []float64) feeHistoryCacheKey {
return feeHistoryCacheKey{
blockCount: blockCount,
newestBlock: newestBlock,
rewardPercentiles: serializeRewardPercentiles(rewardPercentiles),
}
}

func serializeRewardPercentiles(rewardPercentiles []float64) string {
if rewardPercentiles == nil {
return "null"
}

serialized := make([]byte, 0, len(rewardPercentiles)*8+2)
serialized = append(serialized, '[')
for i, percentile := range rewardPercentiles {
if i > 0 {
serialized = append(serialized, ',')
}
serialized = strconv.AppendFloat(serialized, percentile, 'g', -1, 64)
}
serialized = append(serialized, ']')
return string(serialized)
}

// NewCachedValue creates a new cached value
func NewCachedValue[T any](value T, ttl time.Duration) *CachedValue[T] {
return &CachedValue[T]{
Expand Down Expand Up @@ -57,8 +91,8 @@ type ChainCache struct {
baseFeeBlock *big.Int

// Frequent data (30-60 second TTL)
gasPrice *CachedValue[*big.Int]
feeHistory *CachedValue[*FeeHistoryResult]
gasPrice *CachedValue[*big.Int]
feeHistories map[feeHistoryCacheKey]*CachedValue[*FeeHistoryResult]

// Very frequent data (5-10 second TTL)
pendingTxCount *CachedValue[*big.Int]
Expand All @@ -71,7 +105,8 @@ type ChainCache struct {
// NewChainCache creates a new chain cache
func NewChainCache() *ChainCache {
return &ChainCache{
signatures: make(map[string]*CachedValue[[]Signature]),
feeHistories: make(map[feeHistoryCacheKey]*CachedValue[*FeeHistoryResult]),
signatures: make(map[string]*CachedValue[[]Signature]),
}
}

Expand Down Expand Up @@ -200,20 +235,38 @@ func (cc *ChainCache) SetGasPrice(gasPrice *big.Int, ttl time.Duration) {
}

// GetFeeHistory gets cached fee history
func (cc *ChainCache) GetFeeHistory(ttl time.Duration) (*FeeHistoryResult, bool) {
cc.mu.RLock()
defer cc.mu.RUnlock()
if cc.feeHistory == nil {
func (cc *ChainCache) GetFeeHistory(blockCount int, newestBlock string, rewardPercentiles []float64) (*FeeHistoryResult, bool) {
key := newFeeHistoryCacheKey(blockCount, newestBlock, rewardPercentiles)

cc.mu.Lock()
defer cc.mu.Unlock()

cached, exists := cc.feeHistories[key]
if !exists {
return nil, false
}
return cc.feeHistory.Get()

feeHistory, valid := cached.Get()
if !valid {
delete(cc.feeHistories, key)
return nil, false
}
return feeHistory, true
}

// SetFeeHistory caches fee history
func (cc *ChainCache) SetFeeHistory(feeHistory *FeeHistoryResult, ttl time.Duration) {
func (cc *ChainCache) SetFeeHistory(blockCount int, newestBlock string, rewardPercentiles []float64, feeHistory *FeeHistoryResult, ttl time.Duration) {
key := newFeeHistoryCacheKey(blockCount, newestBlock, rewardPercentiles)

cc.mu.Lock()
defer cc.mu.Unlock()
cc.feeHistory = NewCachedValue(feeHistory, ttl)

for cachedKey, cached := range cc.feeHistories {
if !cached.IsValid() {
delete(cc.feeHistories, cachedKey)
}
}
cc.feeHistories[key] = NewCachedValue(feeHistory, ttl)
}

// GetPendingTxCount gets cached pending transaction count
Expand Down
183 changes: 183 additions & 0 deletions chainstore/fee_history_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
package chainstore

import (
"context"
"math/big"
"sync"
"testing"
"time"

"github.com/ethereum/go-ethereum/rpc"
"github.com/stretchr/testify/require"
)

type feeHistoryRPCCall struct {
blockCount string
newestBlock string
rewardPercentiles []float64
}

type feeHistoryRPCService struct {
mu sync.Mutex
calls []feeHistoryRPCCall
}

func (s *feeHistoryRPCService) FeeHistory(_ context.Context, blockCount string, newestBlock string, rewardPercentiles []float64) (*FeeHistoryResult, error) {
s.mu.Lock()
defer s.mu.Unlock()

s.calls = append(s.calls, feeHistoryRPCCall{
blockCount: blockCount,
newestBlock: newestBlock,
rewardPercentiles: append([]float64(nil), rewardPercentiles...),
})
callNumber := int64(len(s.calls))

return &FeeHistoryResult{
OldestBlock: big.NewInt(callNumber),
BaseFeePerGas: []*big.Int{big.NewInt(callNumber)},
GasUsedRatio: []float64{float64(callNumber)},
}, nil
}

func (s *feeHistoryRPCService) callCount() int {
s.mu.Lock()
defer s.mu.Unlock()
return len(s.calls)
}

func newFeeHistoryTestStore(t *testing.T, ttl time.Duration) (*PassthroughStore, *feeHistoryRPCService) {
t.Helper()

server := rpc.NewServer()
service := &feeHistoryRPCService{}
require.NoError(t, server.RegisterName("eth", service))

client := rpc.DialInProc(server)
capabilities := NewCapabilityManager(client, time.Hour)
capabilities.capabilities["eth_feeHistory"] = true
capabilities.lastChecked = time.Now()

store := &PassthroughStore{
client: client,
cache: NewChainCache(),
capabilities: capabilities,
config: &ChainStoreConfig{
FrequentTTL: ttl,
},
}

t.Cleanup(func() {
client.Close()
server.Stop()
})

return store, service
}

func TestGetFeeHistoryCachesIdenticalRequests(t *testing.T) {
store, service := newFeeHistoryTestStore(t, time.Minute)
percentiles := []float64{10, 50, 90}

first, err := store.GetFeeHistory(t.Context(), 10, "latest", percentiles)
require.NoError(t, err)

percentiles[0] = 25
second, err := store.GetFeeHistory(t.Context(), 10, "latest", []float64{10, 50, 90})
require.NoError(t, err)

require.Equal(t, 1, service.callCount())
require.Equal(t, first, second)
}

func TestGetFeeHistorySeparatesRequestsByParameters(t *testing.T) {
tests := []struct {
name string
firstBlockCount int
firstNewestBlock string
firstRewardPercentiles []float64
secondBlockCount int
secondNewestBlock string
secondRewardPercentiles []float64
}{
{
name: "block count",
firstBlockCount: 1,
firstNewestBlock: "latest",
firstRewardPercentiles: []float64{10, 50, 90},
secondBlockCount: 100,
secondNewestBlock: "latest",
secondRewardPercentiles: []float64{10, 50, 90},
},
{
name: "newest block",
firstBlockCount: 10,
firstNewestBlock: "latest",
firstRewardPercentiles: []float64{10, 50, 90},
secondBlockCount: 10,
secondNewestBlock: "0x1234",
secondRewardPercentiles: []float64{10, 50, 90},
},
{
name: "reward percentiles",
firstBlockCount: 10,
firstNewestBlock: "latest",
firstRewardPercentiles: []float64{10, 50, 90},
secondBlockCount: 10,
secondNewestBlock: "latest",
secondRewardPercentiles: []float64{25, 50, 75},
},
{
name: "nil and empty reward percentiles",
firstBlockCount: 10,
firstNewestBlock: "latest",
firstRewardPercentiles: nil,
secondBlockCount: 10,
secondNewestBlock: "latest",
secondRewardPercentiles: []float64{},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
store, service := newFeeHistoryTestStore(t, time.Minute)

first, err := store.GetFeeHistory(t.Context(), tt.firstBlockCount, tt.firstNewestBlock, tt.firstRewardPercentiles)
require.NoError(t, err)
second, err := store.GetFeeHistory(t.Context(), tt.secondBlockCount, tt.secondNewestBlock, tt.secondRewardPercentiles)
require.NoError(t, err)

require.Equal(t, 2, service.callCount())
require.NotEqual(t, first.OldestBlock, second.OldestBlock)
})
}
}

func TestGetFeeHistoryRefetchesAfterTTLExpires(t *testing.T) {
const ttl = 10 * time.Millisecond
store, service := newFeeHistoryTestStore(t, ttl)

first, err := store.GetFeeHistory(t.Context(), 10, "latest", []float64{10, 50, 90})
require.NoError(t, err)

time.Sleep(2 * ttl)

second, err := store.GetFeeHistory(t.Context(), 10, "latest", []float64{10, 50, 90})
require.NoError(t, err)

require.Equal(t, 2, service.callCount())
require.NotEqual(t, first.OldestBlock, second.OldestBlock)
}

func TestSetFeeHistoryPrunesExpiredEntries(t *testing.T) {
cache := NewChainCache()
cache.SetFeeHistory(1, "latest", nil, &FeeHistoryResult{}, time.Nanosecond)

require.Eventually(t, func() bool {
_, valid := cache.GetFeeHistory(1, "latest", nil)
return !valid
}, time.Second, time.Millisecond)

cache.SetFeeHistory(2, "latest", nil, &FeeHistoryResult{}, time.Minute)
require.Len(t, cache.feeHistories, 1)
}
6 changes: 3 additions & 3 deletions chainstore/passthrough_store.go
Original file line number Diff line number Diff line change
Expand Up @@ -362,8 +362,8 @@ func (s *PassthroughStore) GetGasPrice(ctx context.Context) (*big.Int, error) {

// GetFeeHistory retrieves fee history (cached frequently)
func (s *PassthroughStore) GetFeeHistory(ctx context.Context, blockCount int, newestBlock string, rewardPercentiles []float64) (*FeeHistoryResult, error) {
// Check cache first (simple cache key for now)
if feeHistory, valid := s.cache.GetFeeHistory(s.config.FrequentTTL); valid {
// Check cache first
if feeHistory, valid := s.cache.GetFeeHistory(blockCount, newestBlock, rewardPercentiles); valid {
return feeHistory, nil
}

Expand All @@ -378,7 +378,7 @@ func (s *PassthroughStore) GetFeeHistory(ctx context.Context, blockCount int, ne
}

// Cache the result
s.cache.SetFeeHistory(&result, s.config.FrequentTTL)
s.cache.SetFeeHistory(blockCount, newestBlock, rewardPercentiles, &result, s.config.FrequentTTL)

return &result, nil
}
Expand Down