diff --git a/store/metrics.go b/store/metrics.go index 789d2f60..6924b775 100644 --- a/store/metrics.go +++ b/store/metrics.go @@ -2,6 +2,7 @@ package store import ( "context" + "errors" "fmt" "math" "sync/atomic" @@ -17,9 +18,13 @@ var meter = otel.Meter("header/store") type metrics struct { headHeight atomic.Uint64 tailHeight atomic.Uint64 + cacheAccesses atomic.Uint64 + cacheHits atomic.Uint64 headHeightInst metric.Int64ObservableGauge tailHeightInst metric.Int64ObservableGauge + cacheHitInst metric.Float64ObservableGauge heightReg metric.Registration + cacheHitReg metric.Registration flushTimeInst metric.Float64Histogram readTimeInst metric.Float64Histogram @@ -47,6 +52,22 @@ func newMetrics() (m *metrics, err error) { if err != nil { return nil, err } + m.cacheHitInst, err = meter.Float64ObservableGauge( + "hdr_store_cache_hit_ratio_gauge", + metric.WithDescription( + "ratio of header store reads served by cache instead of datastore, from 0 to 1", + ), + metric.WithUnit("1"), + ) + if err != nil { + _ = m.heightReg.Unregister() + return nil, err + } + m.cacheHitReg, err = meter.RegisterCallback(m.observeCacheHitRatio, m.cacheHitInst) + if err != nil { + _ = m.heightReg.Unregister() + return nil, err + } m.flushTimeInst, err = meter.Float64Histogram( "hdr_store_flush_time_hist", metric.WithDescription("header store flush time in seconds"), @@ -105,6 +126,30 @@ func (m *metrics) observeHeight(_ context.Context, obs metric.Observer) error { return nil } +func (m *metrics) observeCacheHitRatio(_ context.Context, obs metric.Observer) error { + obs.ObserveFloat64(m.cacheHitInst, m.cacheHitRatio()) + return nil +} + +func (m *metrics) cacheAccess(hit bool) { + if m == nil { + return + } + + m.cacheAccesses.Add(1) + if hit { + m.cacheHits.Add(1) + } +} + +func (m *metrics) cacheHitRatio() float64 { + accesses := m.cacheAccesses.Load() + if accesses == 0 { + return 0 + } + return float64(m.cacheHits.Load()) / float64(accesses) +} + func (m *metrics) flush(ctx context.Context, duration time.Duration, amount int, failed bool) { m.observe(ctx, func(ctx context.Context) { m.flushTimeInst.Record(ctx, @@ -149,5 +194,5 @@ func (m *metrics) Close() error { return nil } - return m.heightReg.Unregister() + return errors.Join(m.heightReg.Unregister(), m.cacheHitReg.Unregister()) } diff --git a/store/store.go b/store/store.go index 38ece29f..fd75d9d9 100644 --- a/store/store.go +++ b/store/store.go @@ -218,6 +218,7 @@ func (s *Store[H]) Tail(_ context.Context) (H, error) { func (s *Store[H]) Get(ctx context.Context, hash header.Hash) (H, error) { var zero H if v, ok := s.cache.Get(hash.String()); ok { + s.metrics.cacheAccess(true) return v, nil } // check if the requested header is not yet written on disk @@ -225,6 +226,7 @@ func (s *Store[H]) Get(ctx context.Context, hash header.Hash) (H, error) { return h, nil } + s.metrics.cacheAccess(false) b, err := s.get(ctx, hash) if err != nil { return zero, err diff --git a/store/store_test.go b/store/store_test.go index f5b18a34..ca04ead5 100644 --- a/store/store_test.go +++ b/store/store_test.go @@ -103,6 +103,35 @@ func TestStore(t *testing.T) { assert.Len(t, out, 1) } +func TestStore_CacheHitRatio(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) + t.Cleanup(cancel) + + suite := headertest.NewTestSuite(t) + ds := sync.MutexWrap(datastore.NewMapDatastore()) + store := NewTestStore(t, ctx, ds, suite.Head(), WithMetrics(), WithWriteBatchSize(1)) + + store.cache.Purge() + baselineAccesses := store.metrics.cacheAccesses.Load() + baselineHits := store.metrics.cacheHits.Load() + + _, err := store.Get(ctx, suite.Head().Hash()) + require.NoError(t, err) + assert.Equal(t, baselineAccesses+1, store.metrics.cacheAccesses.Load()) + assert.Equal(t, baselineHits, store.metrics.cacheHits.Load()) + + _, err = store.Get(ctx, suite.Head().Hash()) + require.NoError(t, err) + assert.Equal(t, baselineAccesses+2, store.metrics.cacheAccesses.Load()) + assert.Equal(t, baselineHits+1, store.metrics.cacheHits.Load()) + assert.InDelta( + t, + float64(baselineHits+1)/float64(baselineAccesses+2), + store.metrics.cacheHitRatio(), + 0.0001, + ) +} + // TestStore_GetRangeByHeight_ExpectedRange func TestStore_GetRangeByHeight_ExpectedRange(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), time.Second*5)