From 07f315dbf8688da6ab85919b3125af889cc3e6fc Mon Sep 17 00:00:00 2001 From: Vasily Tsybenko Date: Tue, 3 Sep 2024 22:56:34 +0300 Subject: [PATCH] Rework and simplify `lrucache` package --- go.mod | 2 +- httpserver/middleware/in_flight_limit.go | 19 +- httpserver/middleware/rate_limit.go | 36 +-- lrucache/cache.go | 166 +++++------ lrucache/cache_test.go | 333 +++++++++++------------ lrucache/doc.go | 2 +- lrucache/example_test.go | 41 +-- lrucache/metrics.go | 154 +++++++---- restapi/response.go | 4 +- 9 files changed, 373 insertions(+), 384 deletions(-) diff --git a/go.mod b/go.mod index edde3f7..c9c52ea 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,6 @@ require ( github.com/RussellLuo/slidingwindow v0.0.0-20200528002341-535bb99d338b github.com/cenkalti/backoff/v4 v4.3.0 github.com/go-chi/chi/v5 v5.1.0 - github.com/hashicorp/golang-lru v1.0.2 github.com/mitchellh/mapstructure v1.5.0 github.com/prometheus/client_golang v1.19.1 github.com/rs/xid v1.5.0 @@ -28,6 +27,7 @@ require ( github.com/cespare/xxhash/v2 v2.2.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect + github.com/hashicorp/golang-lru v1.0.2 // indirect github.com/hashicorp/hcl v1.0.0 // indirect github.com/magiconair/properties v1.8.7 // indirect github.com/pelletier/go-toml/v2 v2.2.2 // indirect diff --git a/httpserver/middleware/in_flight_limit.go b/httpserver/middleware/in_flight_limit.go index 7a2d3a9..3f12fbe 100644 --- a/httpserver/middleware/in_flight_limit.go +++ b/httpserver/middleware/in_flight_limit.go @@ -11,12 +11,10 @@ import ( "math" "net/http" "strconv" - "sync" "time" - "github.com/hashicorp/golang-lru/simplelru" - "github.com/acronis/go-appkit/log" + "github.com/acronis/go-appkit/lrucache" "github.com/acronis/go-appkit/restapi" ) @@ -292,24 +290,17 @@ func makeInFlightLimitSlotsProvider(limit, backlogLimit, maxKeys int) (func(key backlogSlots chan struct{} } - keysZone, err := simplelru.NewLRU(maxKeys, nil) + keysZone, err := lrucache.New[string, KeysZoneItem](maxKeys, nil) if err != nil { return nil, fmt.Errorf("new LRU in-memory store for keys: %w", err) } - var keysZoneMu sync.Mutex return func(key string) (chan struct{}, chan struct{}) { - keysZoneMu.Lock() - defer keysZoneMu.Unlock() - var keysZoneItem KeysZoneItem - if val, ok := keysZone.Get(key); ok { - keysZoneItem = val.(KeysZoneItem) - } else { - keysZoneItem = KeysZoneItem{ + keysZoneItem, _ := keysZone.GetOrAdd(key, func() KeysZoneItem { + return KeysZoneItem{ slots: make(chan struct{}, limit), backlogSlots: make(chan struct{}, limit+backlogLimit), } - keysZone.Add(key, keysZoneItem) - } + }) return keysZoneItem.slots, keysZoneItem.backlogSlots }, nil } diff --git a/httpserver/middleware/rate_limit.go b/httpserver/middleware/rate_limit.go index 539e9b9..5ac3f26 100644 --- a/httpserver/middleware/rate_limit.go +++ b/httpserver/middleware/rate_limit.go @@ -12,15 +12,14 @@ import ( "math" "net/http" "strconv" - "sync" "time" "github.com/RussellLuo/slidingwindow" - "github.com/hashicorp/golang-lru/simplelru" "github.com/throttled/throttled/v2" "github.com/throttled/throttled/v2/store/memstore" "github.com/acronis/go-appkit/log" + "github.com/acronis/go-appkit/lrucache" "github.com/acronis/go-appkit/restapi" ) @@ -355,19 +354,14 @@ func makeRateLimitBacklogSlotsProvider(backlogLimit, maxKeys int) (func(key stri }, nil } - keysZone, err := simplelru.NewLRU(maxKeys, nil) + keysZone, err := lrucache.New[string, chan struct{}](maxKeys, nil) if err != nil { return nil, fmt.Errorf("new LRU in-memory store for keys: %w", err) } - var mu sync.Mutex return func(key string) chan struct{} { - mu.Lock() - defer mu.Unlock() - if val, ok := keysZone.Get(key); ok { - return val.(chan struct{}) - } - backlogSlots := make(chan struct{}, backlogLimit) - keysZone.Add(key, backlogSlots) + backlogSlots, _ := keysZone.GetOrAdd(key, func() chan struct{} { + return make(chan struct{}, backlogLimit) + }) return backlogSlots }, nil } @@ -423,24 +417,20 @@ func newSlidingWindowLimiter(maxRate Rate, maxKeys int) (*slidingWindowLimiter, }, nil } - store, err := simplelru.NewLRU(maxKeys, nil) + store, err := lrucache.New[string, *slidingwindow.Limiter](maxKeys, nil) if err != nil { return nil, fmt.Errorf("new LRU in-memory store for keys: %w", err) } - var mu sync.Mutex return &slidingWindowLimiter{ maxRate: maxRate, getLimiter: func(key string) *slidingwindow.Limiter { - mu.Lock() - defer mu.Unlock() - if val, ok := store.Get(key); ok { - return val.(*slidingwindow.Limiter) - } - lim, _ := slidingwindow.NewLimiter( - maxRate.Duration, int64(maxRate.Count), func() (slidingwindow.Window, slidingwindow.StopFunc) { - return slidingwindow.NewLocalWindow() - }) - store.Add(key, lim) + lim, _ := store.GetOrAdd(key, func() *slidingwindow.Limiter { + lim, _ := slidingwindow.NewLimiter( + maxRate.Duration, int64(maxRate.Count), func() (slidingwindow.Window, slidingwindow.StopFunc) { + return slidingwindow.NewLocalWindow() + }) + return lim + }) return lim }, }, nil diff --git a/lrucache/cache.go b/lrucache/cache.go index 750b9c1..333118d 100644 --- a/lrucache/cache.go +++ b/lrucache/cache.go @@ -12,175 +12,157 @@ import ( "sync" ) -// EntryType is a type of storing in cache entries. -type EntryType int - -// EntryTypeDefault is a default entry type. -const EntryTypeDefault EntryType = 0 - -type cacheKey[K comparable] struct { - key K - entryType EntryType -} - -type cacheEntry[K comparable] struct { - key cacheKey[K] - value interface{} +type cacheEntry[K comparable, V any] struct { + key K + value V } // LRUCache represents an LRU cache with eviction mechanism and Prometheus metrics. -type LRUCache[K comparable] struct { +type LRUCache[K comparable, V any] struct { maxEntries int mu sync.RWMutex lruList *list.List - cache map[cacheKey[K]]*list.Element // map of cache entries, value is a lruList element + cache map[K]*list.Element // map of cache entries, value is a lruList element - MetricsCollector *MetricsCollector + metricsCollector MetricsCollector } // New creates a new LRUCache with the provided maximum number of entries. -func New[K comparable](maxEntries int, metricsCollector *MetricsCollector) (*LRUCache[K], error) { +func New[K comparable, V any](maxEntries int, metricsCollector MetricsCollector) (*LRUCache[K, V], error) { if maxEntries <= 0 { return nil, fmt.Errorf("maxEntries must be greater than 0") } - return &LRUCache[K]{ + if metricsCollector == nil { + metricsCollector = disabledMetrics{} + } + return &LRUCache[K, V]{ maxEntries: maxEntries, lruList: list.New(), - cache: make(map[cacheKey[K]]*list.Element), - MetricsCollector: metricsCollector, + cache: make(map[K]*list.Element), + metricsCollector: metricsCollector, }, nil } // Get returns a value from the cache by the provided key and type. -func (c *LRUCache[K]) Get(key K, entryType EntryType) (value interface{}, ok bool) { - metrics := c.MetricsCollector.getEntryTypeMetrics(entryType) - - defer func() { - if ok { - metrics.HitsTotal.Inc() - } else { - metrics.MissesTotal.Inc() - } - }() - - cKey := cacheKey[K]{key, entryType} - +func (c *LRUCache[K, V]) Get(key K) (value V, ok bool) { c.mu.Lock() defer c.mu.Unlock() - - if elem, hit := c.cache[cKey]; hit { - c.lruList.MoveToFront(elem) - return elem.Value.(*cacheEntry[K]).value, true - } - return nil, false + return c.get(key) } // Add adds a value to the cache with the provided key and type. // If the cache is full, the oldest entry will be removed. -func (c *LRUCache[K]) Add(key K, value interface{}, entryType EntryType) { - var evictedEntry *cacheEntry[K] - - defer func() { - if evictedEntry != nil { - c.MetricsCollector.getEntryTypeMetrics(evictedEntry.key.entryType).EvictionsTotal.Inc() - } - }() - - cKey := cacheKey[K]{key, entryType} - entry := &cacheEntry[K]{key: cKey, value: value} - +func (c *LRUCache[K, V]) Add(key K, value V) { c.mu.Lock() defer c.mu.Unlock() - if elem, ok := c.cache[cKey]; ok { + if elem, ok := c.cache[key]; ok { c.lruList.MoveToFront(elem) - elem.Value = entry + elem.Value = &cacheEntry[K, V]{key: key, value: value} return } + c.addNew(key, value) +} - c.cache[cKey] = c.lruList.PushFront(entry) - c.MetricsCollector.getEntryTypeMetrics(cKey.entryType).Amount.Inc() - if len(c.cache) <= c.maxEntries { - return - } - if evictedEntry = c.removeOldest(); evictedEntry != nil { - c.MetricsCollector.getEntryTypeMetrics(evictedEntry.key.entryType).Amount.Dec() +func (c *LRUCache[K, V]) GetOrAdd(key K, valueProvider func() V) (value V, exists bool) { + c.mu.Lock() + defer c.mu.Unlock() + + if value, exists = c.get(key); exists { + return value, exists } + value = valueProvider() + c.addNew(key, value) + return value, false } // Remove removes a value from the cache by the provided key and type. -func (c *LRUCache[K]) Remove(key K, entryType EntryType) bool { - cKey := cacheKey[K]{key, entryType} - +func (c *LRUCache[K, V]) Remove(key K) bool { c.mu.Lock() defer c.mu.Unlock() - elem, ok := c.cache[cKey] + elem, ok := c.cache[key] if !ok { return false } c.lruList.Remove(elem) - delete(c.cache, cKey) - c.MetricsCollector.getEntryTypeMetrics(entryType).Amount.Dec() + delete(c.cache, key) + c.metricsCollector.SetAmount(len(c.cache)) return true } // Purge clears the cache. -func (c *LRUCache[K]) Purge() { +// Keep in mind that this method does not reset the cache size +// and does not reset Prometheus metrics except for the total number of entries. +// All removed entries will not be counted as evictions. +func (c *LRUCache[K, V]) Purge() { c.mu.Lock() defer c.mu.Unlock() - for _, etMetrics := range c.MetricsCollector.entryTypeMetrics { - etMetrics.Amount.Set(0) - } - c.cache = make(map[cacheKey[K]]*list.Element) + c.metricsCollector.SetAmount(0) + c.cache = make(map[K]*list.Element) c.lruList.Init() } -// Resize changes the cache size. -// Note that resizing the cache may cause some entries to be evicted. -func (c *LRUCache[K]) Resize(size int) { +// Resize changes the cache size and returns the number of evicted entries. +func (c *LRUCache[K, V]) Resize(size int) (evicted int) { if size <= 0 { - return + return 0 } + c.mu.Lock() defer c.mu.Unlock() c.maxEntries = size - diff := len(c.cache) - size - if diff <= 0 { + evicted = len(c.cache) - size + if evicted <= 0 { return } - - rmCounts := make([]int, len(c.MetricsCollector.entryTypeMetrics)) - for i := 0; i < diff; i++ { - if rmEntry := c.removeOldest(); rmEntry != nil { - rmCounts[rmEntry.key.entryType]++ - } - } - for et, cnt := range rmCounts { - typeMetrics := c.MetricsCollector.getEntryTypeMetrics(EntryType(et)) - typeMetrics.Amount.Sub(float64(cnt)) - typeMetrics.EvictionsTotal.Add(float64(cnt)) + for i := 0; i < evicted; i++ { + _ = c.removeOldest() } + c.metricsCollector.SetAmount(len(c.cache)) + c.metricsCollector.AddEvictions(evicted) + return evicted } // Len returns the number of items in the cache. -func (c *LRUCache[K]) Len() int { +func (c *LRUCache[K, V]) Len() int { c.mu.RLock() defer c.mu.RUnlock() return len(c.cache) } -func (c *LRUCache[K]) removeOldest() *cacheEntry[K] { +func (c *LRUCache[K, V]) get(key K) (value V, ok bool) { + if elem, hit := c.cache[key]; hit { + c.lruList.MoveToFront(elem) + c.metricsCollector.IncHits() + return elem.Value.(*cacheEntry[K, V]).value, true + } + c.metricsCollector.IncMisses() + return value, false +} + +func (c *LRUCache[K, V]) addNew(key K, value V) { + c.cache[key] = c.lruList.PushFront(&cacheEntry[K, V]{key: key, value: value}) + if len(c.cache) <= c.maxEntries { + c.metricsCollector.SetAmount(len(c.cache)) + return + } + if evictedEntry := c.removeOldest(); evictedEntry != nil { + c.metricsCollector.AddEvictions(1) + } +} + +func (c *LRUCache[K, V]) removeOldest() *cacheEntry[K, V] { elem := c.lruList.Back() if elem == nil { return nil } c.lruList.Remove(elem) - entry := elem.Value.(*cacheEntry[K]) + entry := elem.Value.(*cacheEntry[K, V]) delete(c.cache, entry.key) return entry } diff --git a/lrucache/cache_test.go b/lrucache/cache_test.go index 5767ede..9da6ea4 100644 --- a/lrucache/cache_test.go +++ b/lrucache/cache_test.go @@ -7,7 +7,6 @@ Released under MIT license. package lrucache import ( - "crypto/sha256" "testing" "github.com/prometheus/client_golang/prometheus/testutil" @@ -16,226 +15,226 @@ import ( ) func TestLRUCache(t *testing.T) { - users := map[[sha256.Size]byte]User{ - sha256.Sum256([]byte("user:1")): {"Bob"}, - sha256.Sum256([]byte("user:42")): {"John"}, - sha256.Sum256([]byte("user:777")): {"Ivan"}, - } - posts := map[[sha256.Size]byte]Post{ - sha256.Sum256([]byte("post:101")): {"My first post."}, - sha256.Sum256([]byte("post:777")): {"My second post."}, - } - - fillCache := func(cache *LRUCache[[sha256.Size]byte]) { - keys := [][sha256.Size]byte{ - sha256.Sum256([]byte("user:1")), - sha256.Sum256([]byte("user:42")), - sha256.Sum256([]byte("user:777"))} - - for _, key := range keys { - cache.Add(key, users[key], entryTypeUser) - } - for _, key := range [][sha256.Size]byte{sha256.Sum256([]byte("post:101")), sha256.Sum256([]byte("post:777"))} { - cache.Add(key, posts[key], entryTypePost) - } - } + bob := User{"375ea40b-c49f-43a9-ba49-df720d21d274", "Bob"} + john := User{"06f84bd8-df55-4de4-b062-ce4a12fad14c", "John"} + piter := User{"d69fa1a4-84ad-48ac-a8a9-1cbd4092ac9f", "Piter"} + firstPost := Post{"5ce98a1e-0090-4681-80b4-4c7fdb849d78", "My first post."} tests := []struct { - name string - maxEntries int - fn func(t *testing.T, cache *LRUCache[[sha256.Size]byte]) - wantMetrics testMetrics + name string + maxEntries int + fn func(t *testing.T, adminCache *LRUCache[string, User], customerCache *LRUCache[string, User], postCache *LRUCache[string, Post]) + adminExpectedMetrics expectedMetrics + customerExpectedMetrics expectedMetrics + postExpectedMetrics expectedMetrics }{ { name: "attempt to get not existing keys", maxEntries: 100, - fn: func(t *testing.T, cache *LRUCache[[sha256.Size]byte]) { - for key := range users { - _, found := cache.Get(key, entryTypeUser) - require.False(t, found) - } - for key := range posts { - _, found := cache.Get(key, entryTypePost) - require.False(t, found) - } - }, - wantMetrics: testMetrics{ - Misses: testMetricsPair{len(users), len(posts)}, + fn: func(t *testing.T, adminCache *LRUCache[string, User], customerCache *LRUCache[string, User], postCache *LRUCache[string, Post]) { + var found bool + _, found = adminCache.Get("not_existing_key_1") + require.False(t, found) + _, found = adminCache.Get("not_existing_key_2") + require.False(t, found) + _, found = customerCache.Get("not_existing_key_3") + require.False(t, found) + _, found = postCache.Get("not_existing_key_4") + require.False(t, found) }, + adminExpectedMetrics: expectedMetrics{MissesTotal: 2}, + customerExpectedMetrics: expectedMetrics{MissesTotal: 1}, + postExpectedMetrics: expectedMetrics{MissesTotal: 1}, }, { name: "add entries and get them", maxEntries: 100, - fn: func(t *testing.T, cache *LRUCache[[sha256.Size]byte]) { - fillCache(cache) + fn: func(t *testing.T, adminCache *LRUCache[string, User], customerCache *LRUCache[string, User], postCache *LRUCache[string, Post]) { + adminCache.Add(bob.ID, bob) + customerCache.Add(john.ID, john) + customerCache.Add(piter.ID, piter) + postCache.Add(firstPost.ID, firstPost) - for key, wantUser := range users { - val, found := cache.Get(key, entryTypeUser) - require.True(t, found) - require.Equal(t, wantUser, val.(User)) - } - for key, wantPost := range posts { - val, found := cache.Get(key, entryTypePost) + user, found := adminCache.Get(bob.ID) + require.True(t, found) + require.Equal(t, bob, user) + + user, found = customerCache.Get(john.ID) + require.True(t, found) + require.Equal(t, john, user) + + user, found = customerCache.Get(piter.ID) + require.True(t, found) + require.Equal(t, piter, user) + + for i := 0; i < 10; i++ { + post, found := postCache.Get(firstPost.ID) require.True(t, found) - require.Equal(t, wantPost, val.(Post)) + require.Equal(t, firstPost, post) } }, - wantMetrics: testMetrics{ - Amount: testMetricsPair{len(users), len(posts)}, - Hits: testMetricsPair{len(users), len(posts)}, - }, + adminExpectedMetrics: expectedMetrics{EntriesAmount: 1, HitsTotal: 1}, + customerExpectedMetrics: expectedMetrics{EntriesAmount: 2, HitsTotal: 2}, + postExpectedMetrics: expectedMetrics{EntriesAmount: 1, HitsTotal: 10}, }, { name: "add entries with evictions", - maxEntries: len(users) + len(posts) - 1, - fn: func(t *testing.T, cache *LRUCache[[sha256.Size]byte]) { - fillCache(cache) // "user:1" key will be evicted. - - for key, wantUser := range users { - if key == sha256.Sum256([]byte("user:1")) { - _, found := cache.Get(key, entryTypeUser) - require.False(t, found) - continue - } - val, found := cache.Get(key, entryTypeUser) - require.True(t, found) - require.Equal(t, wantUser, val.(User)) - } - for key, wantPost := range posts { - val, found := cache.Get(key, entryTypePost) - require.True(t, found) - require.Equal(t, wantPost, val.(Post)) - } + maxEntries: 2, + fn: func(t *testing.T, _ *LRUCache[string, User], customerCache *LRUCache[string, User], _ *LRUCache[string, Post]) { + alice := User{ID: "a9fb0f2b-2675-4287-bedd-4ae3ba0b1b36", Name: "Alice"} + kate := User{ID: "96c8b9a0-0a70-4b49-85e6-b514009b62a1", Name: "Kate"} + + // Fill cache with entries. + customerCache.Add(john.ID, john) + customerCache.Add(piter.ID, piter) + + // Add a new entry, which should evict the oldest one (John). + customerCache.Add(alice.ID, alice) + _, found := customerCache.Get(john.ID) // John should be evicted. + require.False(t, found) + user, found := customerCache.Get(alice.ID) + require.True(t, found) + require.Equal(t, user, alice) + user, found = customerCache.Get(piter.ID) + require.True(t, found) + require.Equal(t, user, piter) + + // Add a new entry, which should evict the oldest one (Alice). + customerCache.Add(kate.ID, kate) + _, found = customerCache.Get(alice.ID) // Alice should be evicted. + require.False(t, found) + user, found = customerCache.Get(piter.ID) + require.True(t, found) + require.Equal(t, user, piter) + user, found = customerCache.Get(kate.ID) + require.True(t, found) + require.Equal(t, user, kate) }, - wantMetrics: testMetrics{ - Amount: testMetricsPair{len(users) - 1, len(posts)}, - Hits: testMetricsPair{len(users) - 1, len(posts)}, - Misses: testMetricsPair{1, 0}, - Evictions: testMetricsPair{1, 0}, + customerExpectedMetrics: expectedMetrics{EntriesAmount: 2, HitsTotal: 4, MissesTotal: 2, EvictionsTotal: 2}, + }, + { + name: "get or add", + maxEntries: 100, + fn: func(t *testing.T, _ *LRUCache[string, User], customerCache *LRUCache[string, User], _ *LRUCache[string, Post]) { + _, found := customerCache.GetOrAdd(john.ID, func() User { + return john + }) + require.False(t, found) + _, found = customerCache.GetOrAdd(john.ID, func() User { + return john + }) + require.True(t, found) }, + customerExpectedMetrics: expectedMetrics{EntriesAmount: 1, HitsTotal: 1, MissesTotal: 1}, }, { name: "remove entries", maxEntries: 100, - fn: func(t *testing.T, cache *LRUCache[[sha256.Size]byte]) { - fillCache(cache) - - require.False(t, cache.Remove(sha256.Sum256([]byte("user:100500")), entryTypeUser)) - require.False(t, cache.Remove(sha256.Sum256([]byte("user:42")), entryTypePost)) - require.True(t, cache.Remove(sha256.Sum256([]byte("user:42")), entryTypeUser)) - require.True(t, cache.Remove(sha256.Sum256([]byte("post:101")), entryTypePost)) - }, - wantMetrics: testMetrics{ - Amount: testMetricsPair{User: len(users) - 1, Post: len(posts) - 1}, + fn: func(t *testing.T, _ *LRUCache[string, User], customerCache *LRUCache[string, User], _ *LRUCache[string, Post]) { + customerCache.Add(john.ID, john) + customerCache.Add(piter.ID, piter) + require.True(t, customerCache.Remove(john.ID)) + require.False(t, customerCache.Remove(john.ID)) + require.False(t, customerCache.Remove("not_existing_key")) }, + customerExpectedMetrics: expectedMetrics{EntriesAmount: 1}, }, { - name: "resize, no evictions", + name: "resize", maxEntries: 100, - fn: func(t *testing.T, cache *LRUCache[[sha256.Size]byte]) { - fillCache(cache) - cache.Resize(50) - for key := range users { - _, found := cache.Get(key, entryTypeUser) - require.True(t, found) + fn: func(t *testing.T, _ *LRUCache[string, User], customerCache *LRUCache[string, User], _ *LRUCache[string, Post]) { + for _, user := range []User{bob, john, piter} { + customerCache.Add(user.ID, user) } - for key := range posts { - _, found := cache.Get(key, entryTypePost) + + // Resize without evictions. + customerCache.Resize(3) + for _, user := range []User{bob, john, piter} { + _, found := customerCache.Get(user.ID) require.True(t, found) } + + // Resize with evictions. + customerCache.Resize(2) + _, found := customerCache.Get(bob.ID) + require.False(t, found) + _, found = customerCache.Get(john.ID) + require.True(t, found) + _, found = customerCache.Get(piter.ID) + require.True(t, found) }, - wantMetrics: testMetrics{ - Amount: testMetricsPair{len(users), len(posts)}, - Hits: testMetricsPair{len(users), len(posts)}, + customerExpectedMetrics: expectedMetrics{ + EntriesAmount: 2, + HitsTotal: 5, + MissesTotal: 1, + EvictionsTotal: 1, }, }, { - name: "resize with evictions", + name: "purge", maxEntries: 100, - fn: func(t *testing.T, cache *LRUCache[[sha256.Size]byte]) { - fillCache(cache) - _, found := cache.Get(sha256.Sum256([]byte("user:42")), entryTypeUser) - require.True(t, found) - _, found = cache.Get(sha256.Sum256([]byte("user:777")), entryTypeUser) - require.True(t, found) - _, found = cache.Get(sha256.Sum256([]byte("post:777")), entryTypePost) - require.True(t, found) - - cache.Resize(2) - - _, found = cache.Get(sha256.Sum256([]byte("user:42")), entryTypeUser) + fn: func(t *testing.T, _ *LRUCache[string, User], customerCache *LRUCache[string, User], _ *LRUCache[string, Post]) { + customerCache.Add(john.ID, john) + customerCache.Add(piter.ID, piter) + customerCache.Purge() + _, found := customerCache.Get(john.ID) + require.False(t, found) + _, found = customerCache.Get(piter.ID) require.False(t, found) - _, found = cache.Get(sha256.Sum256([]byte("user:777")), entryTypeUser) - require.True(t, found) - _, found = cache.Get(sha256.Sum256([]byte("post:777")), entryTypePost) - require.True(t, found) - }, - wantMetrics: testMetrics{ - Amount: testMetricsPair{1, 1}, - Hits: testMetricsPair{3, 2}, - Misses: testMetricsPair{1, 0}, - Evictions: testMetricsPair{2, 1}, }, + customerExpectedMetrics: expectedMetrics{EntriesAmount: 0, MissesTotal: 2}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - cache, metricsCollector := makeCache(t, tt.maxEntries) - tt.fn(t, cache) - assertMetrics(t, tt.wantMetrics, metricsCollector) + userMetrics := NewPrometheusMetricsWithOpts(PrometheusMetricsOpts{Namespace: "user", CurriedLabelNames: []string{"type"}}) + userMetrics.MustRegister() + defer userMetrics.Unregister() + + postMetrics := NewPrometheusMetricsWithOpts(PrometheusMetricsOpts{Namespace: "post"}) + postMetrics.MustRegister() + defer postMetrics.Unregister() + + adminMetrics := userMetrics.MustCurryWith(map[string]string{"type": "admin"}) + adminCache, err := New[string, User](tt.maxEntries, adminMetrics) + require.NoError(t, err) + + customerMetrics := userMetrics.MustCurryWith(map[string]string{"type": "customer"}) + customerCache, err := New[string, User](tt.maxEntries, customerMetrics) + require.NoError(t, err) + + postCache, err := New[string, Post](tt.maxEntries, postMetrics) + require.NoError(t, err) + + tt.fn(t, adminCache, customerCache, postCache) + + assertPrometheusMetrics(t, tt.adminExpectedMetrics, adminMetrics) + assertPrometheusMetrics(t, tt.customerExpectedMetrics, customerMetrics) + assertPrometheusMetrics(t, tt.postExpectedMetrics, postMetrics) }) } } -const ( - entryTypeUser EntryType = iota - entryTypePost -) -const ( - metricsLabelUser = "user" - metricsLabelPost = "post" -) - type User struct { + ID string Name string } type Post struct { + ID string Title string } -type testMetricsPair struct { - User int - Post int -} - -type testMetrics struct { - Amount testMetricsPair - Hits testMetricsPair - Misses testMetricsPair - Expiration testMetricsPair - Evictions testMetricsPair -} - -func assertMetrics(t *testing.T, want testMetrics, mc *MetricsCollector) { - t.Helper() - assert.Equal(t, want.Amount.User, int(testutil.ToFloat64(mc.EntriesAmount.WithLabelValues(metricsLabelUser)))) - assert.Equal(t, want.Amount.Post, int(testutil.ToFloat64(mc.EntriesAmount.WithLabelValues(metricsLabelPost)))) - assert.Equal(t, want.Hits.User, int(testutil.ToFloat64(mc.HitsTotal.WithLabelValues(metricsLabelUser)))) - assert.Equal(t, want.Hits.Post, int(testutil.ToFloat64(mc.HitsTotal.WithLabelValues(metricsLabelPost)))) - assert.Equal(t, want.Misses.User, int(testutil.ToFloat64(mc.MissesTotal.WithLabelValues(metricsLabelUser)))) - assert.Equal(t, want.Misses.Post, int(testutil.ToFloat64(mc.MissesTotal.WithLabelValues(metricsLabelPost)))) - assert.Equal(t, want.Evictions.User, int(testutil.ToFloat64(mc.EvictionsTotal.WithLabelValues(metricsLabelUser)))) - assert.Equal(t, want.Evictions.Post, int(testutil.ToFloat64(mc.EvictionsTotal.WithLabelValues(metricsLabelPost)))) +type expectedMetrics struct { + EntriesAmount int + HitsTotal int + MissesTotal int + EvictionsTotal int } -func makeCache(t *testing.T, maxEntries int) (*LRUCache[[sha256.Size]byte], *MetricsCollector) { +func assertPrometheusMetrics(t *testing.T, expected expectedMetrics, mc *PrometheusMetrics) { t.Helper() - mc := NewMetricsCollector("") - mc.SetupEntryTypeLabels(map[EntryType]string{ - entryTypeUser: metricsLabelUser, - entryTypePost: metricsLabelPost, - }) - cache, err := New[[sha256.Size]byte](maxEntries, mc) - require.NoError(t, err) - return cache, mc + assert.Equal(t, expected.EntriesAmount, int(testutil.ToFloat64(mc.EntriesAmount.With(nil)))) + assert.Equal(t, expected.HitsTotal, int(testutil.ToFloat64(mc.HitsTotal.With(nil)))) + assert.Equal(t, expected.MissesTotal, int(testutil.ToFloat64(mc.MissesTotal.With(nil)))) + assert.Equal(t, expected.EvictionsTotal, int(testutil.ToFloat64(mc.EvictionsTotal.With(nil)))) } diff --git a/lrucache/doc.go b/lrucache/doc.go index 688f179..f850f9e 100644 --- a/lrucache/doc.go +++ b/lrucache/doc.go @@ -4,5 +4,5 @@ Copyright © 2024 Acronis International GmbH. Released under MIT license. */ -// Package lrucache provides in-memory LRU cache with expiration mechanism and collecting Prometheus metrics. +// Package lrucache provides in-memory cache with LRU eviction policy and Prometheus metrics. package lrucache diff --git a/lrucache/example_test.go b/lrucache/example_test.go index 1b39cd6..b3c5a45 100644 --- a/lrucache/example_test.go +++ b/lrucache/example_test.go @@ -12,53 +12,34 @@ import ( ) func Example() { - const metricsNamespace = "myservice" - - // There are 2 types of entries will be stored in cache: users and posts. - const ( - cacheEntryTypeUser EntryType = iota - cacheEntryTypePost - ) - type User struct { ID int Name string } - type Post struct { - ID int - Title string - } - - // Make, configure and register Prometheus metrics collector. - metricsCollector := NewMetricsCollector(metricsNamespace) - metricsCollector.SetupEntryTypeLabels(map[EntryType]string{ - cacheEntryTypeUser: "user", - cacheEntryTypePost: "post", - }) - metricsCollector.MustRegister() + // Make and register Prometheus metrics collector. + promMetrics := NewPrometheusMetricsWithOpts(PrometheusMetricsOpts{Namespace: "my_service_user"}) + promMetrics.MustRegister() // Make LRU cache for storing maximum 1000 entries - cache, err := New[string](1000, metricsCollector) + cache, err := New[string, User](1000, promMetrics) if err != nil { log.Fatal(err) } // Add entries to cache. - cache.Add("user:1", User{1, "John"}, cacheEntryTypeUser) - cache.Add("post:1", Post{1, "My first post."}, cacheEntryTypePost) + cache.Add("user:1", User{1, "Alice"}) + cache.Add("user:2", User{2, "Bob"}) // Get entries from cache. - if val, found := cache.Get("user:1", cacheEntryTypeUser); found { - user := val.(User) + if user, found := cache.Get("user:1"); found { fmt.Printf("%d, %s\n", user.ID, user.Name) } - if val, found := cache.Get("post:1", cacheEntryTypePost); found { - post := val.(Post) - fmt.Printf("%d, %s\n", post.ID, post.Title) + if user, found := cache.Get("user:2"); found { + fmt.Printf("%d, %s\n", user.ID, user.Name) } // Output: - // 1, John - // 1, My first post. + // 1, Alice + // 2, Bob } diff --git a/lrucache/metrics.go b/lrucache/metrics.go index 65b3533..763c0c0 100644 --- a/lrucache/metrics.go +++ b/lrucache/metrics.go @@ -8,105 +8,151 @@ package lrucache import "github.com/prometheus/client_golang/prometheus" -const metricsLabelEntryType = "entry_type" +// MetricsCollector represents a collector of metrics to analyze how (effectively or not) cache is used. +type MetricsCollector interface { + // SetAmount sets the total number of entries in the cache. + SetAmount(int) -type entryTypeMetrics struct { - Amount prometheus.Gauge - HitsTotal prometheus.Counter - MissesTotal prometheus.Counter - EvictionsTotal prometheus.Counter + // IncHits increments the total number of successfully found keys in the cache. + IncHits() + + // IncMisses increments the total number of not found keys in the cache. + IncMisses() + + // AddEvictions increments the total number of evicted entries. + AddEvictions(int) } -// MetricsCollector represents collector of metrics for analyze how (effectively or not) cache is used. -type MetricsCollector struct { +// PrometheusMetricsOpts represents options for PrometheusMetrics. +type PrometheusMetricsOpts struct { + // Namespace is a namespace for metrics. It will be prepended to all metric names. + Namespace string + + // ConstLabels is a set of labels that will be applied to all metrics. + ConstLabels prometheus.Labels + + // CurriedLabelNames is a list of label names that will be curried with the provided labels. + // See PrometheusMetrics.MustCurryWith method for more details. + // Keep in mind that if this list is not empty, + // PrometheusMetrics.MustCurryWith method must be called further with the same labels. + // Otherwise, the collector will panic. + CurriedLabelNames []string +} + +// PrometheusMetrics represents a Prometheus metrics for the cache. +type PrometheusMetrics struct { EntriesAmount *prometheus.GaugeVec HitsTotal *prometheus.CounterVec MissesTotal *prometheus.CounterVec EvictionsTotal *prometheus.CounterVec +} - entryTypeMetrics []*entryTypeMetrics +// NewPrometheusMetrics creates a new instance of PrometheusMetrics with default options. +func NewPrometheusMetrics() *PrometheusMetrics { + return NewPrometheusMetricsWithOpts(PrometheusMetricsOpts{}) } -// NewMetricsCollector creates a new metrics collector. -func NewMetricsCollector(namespace string) *MetricsCollector { +// NewPrometheusMetricsWithOpts creates a new instance of PrometheusMetrics with the provided options. +func NewPrometheusMetricsWithOpts(opts PrometheusMetricsOpts) *PrometheusMetrics { entriesAmount := prometheus.NewGaugeVec( prometheus.GaugeOpts{ - Namespace: namespace, - Name: "cache_entries_amount", - Help: "Total number of entries in the cache.", + Namespace: opts.Namespace, + Name: "cache_entries_amount", + Help: "Total number of entries in the cache.", + ConstLabels: opts.ConstLabels, }, - []string{metricsLabelEntryType}, + opts.CurriedLabelNames, ) hitsTotal := prometheus.NewCounterVec( prometheus.CounterOpts{ - Namespace: namespace, - Name: "cache_hits_total", - Help: "Number of successfully found keys in the cache.", + Namespace: opts.Namespace, + Name: "cache_hits_total", + Help: "Number of successfully found keys in the cache.", + ConstLabels: opts.ConstLabels, }, - []string{metricsLabelEntryType}, + opts.CurriedLabelNames, ) missesTotal := prometheus.NewCounterVec( prometheus.CounterOpts{ - Namespace: namespace, - Name: "cache_misses_total", - Help: "Number of not found keys in cache.", + Namespace: opts.Namespace, + Name: "cache_misses_total", + Help: "Number of not found keys in cache.", + ConstLabels: opts.ConstLabels, }, - []string{metricsLabelEntryType}, + opts.CurriedLabelNames, ) evictionsTotal := prometheus.NewCounterVec( prometheus.CounterOpts{ - Namespace: namespace, - Name: "cache_evictions_total", - Help: "Number of evicted entries.", + Namespace: opts.Namespace, + Name: "cache_evictions_total", + Help: "Number of evicted entries.", + ConstLabels: opts.ConstLabels, }, - []string{metricsLabelEntryType}, + opts.CurriedLabelNames, ) - mc := &MetricsCollector{ + return &PrometheusMetrics{ EntriesAmount: entriesAmount, HitsTotal: hitsTotal, MissesTotal: missesTotal, EvictionsTotal: evictionsTotal, } - mc.SetupEntryTypeLabels(map[EntryType]string{EntryTypeDefault: "default"}) - return mc +} + +// MustCurryWith curries the metrics collector with the provided labels. +func (pm *PrometheusMetrics) MustCurryWith(labels prometheus.Labels) *PrometheusMetrics { + return &PrometheusMetrics{ + EntriesAmount: pm.EntriesAmount.MustCurryWith(labels), + HitsTotal: pm.HitsTotal.MustCurryWith(labels), + MissesTotal: pm.MissesTotal.MustCurryWith(labels), + EvictionsTotal: pm.EvictionsTotal.MustCurryWith(labels), + } } // MustRegister does registration of metrics collector in Prometheus and panics if any error occurs. -func (c *MetricsCollector) MustRegister() { +func (pm *PrometheusMetrics) MustRegister() { prometheus.MustRegister( - c.EntriesAmount, - c.HitsTotal, - c.MissesTotal, - c.EvictionsTotal, + pm.EntriesAmount, + pm.HitsTotal, + pm.MissesTotal, + pm.EvictionsTotal, ) } // Unregister cancels registration of metrics collector in Prometheus. -func (c *MetricsCollector) Unregister() { - prometheus.Unregister(c.EntriesAmount) - prometheus.Unregister(c.HitsTotal) - prometheus.Unregister(c.MissesTotal) - prometheus.Unregister(c.EvictionsTotal) +func (pm *PrometheusMetrics) Unregister() { + prometheus.Unregister(pm.EntriesAmount) + prometheus.Unregister(pm.HitsTotal) + prometheus.Unregister(pm.MissesTotal) + prometheus.Unregister(pm.EvictionsTotal) } -// SetupEntryTypeLabels setups its own Prometheus metrics label for each storing cacheEntry type. -func (c *MetricsCollector) SetupEntryTypeLabels(labels map[EntryType]string) { - metrics := make([]*entryTypeMetrics, len(labels)) - for entryType, label := range labels { - metrics[entryType] = &entryTypeMetrics{ - Amount: c.EntriesAmount.WithLabelValues(label), - HitsTotal: c.HitsTotal.WithLabelValues(label), - MissesTotal: c.MissesTotal.WithLabelValues(label), - EvictionsTotal: c.EvictionsTotal.WithLabelValues(label), - } - } - c.entryTypeMetrics = metrics +// SetAmount sets the total number of entries in the cache. +func (pm *PrometheusMetrics) SetAmount(amount int) { + pm.EntriesAmount.With(nil).Set(float64(amount)) +} + +// IncHits increments the total number of successfully found keys in the cache. +func (pm *PrometheusMetrics) IncHits() { + pm.HitsTotal.With(nil).Inc() } -func (c *MetricsCollector) getEntryTypeMetrics(entryType EntryType) *entryTypeMetrics { - return c.entryTypeMetrics[entryType] +// IncMisses increments the total number of not found keys in the cache. +func (pm *PrometheusMetrics) IncMisses() { + pm.MissesTotal.With(nil).Inc() } + +// AddEvictions increments the total number of evicted entries. +func (pm *PrometheusMetrics) AddEvictions(n int) { + pm.EvictionsTotal.With(nil).Add(float64(n)) +} + +type disabledMetrics struct{} + +func (disabledMetrics) SetAmount(int) {} +func (disabledMetrics) IncHits() {} +func (disabledMetrics) IncMisses() {} +func (disabledMetrics) AddEvictions(int) {} diff --git a/restapi/response.go b/restapi/response.go index 06ab0c6..699c744 100644 --- a/restapi/response.go +++ b/restapi/response.go @@ -39,7 +39,7 @@ func RespondJSON(rw http.ResponseWriter, respData interface{}, logger log.FieldL RespondCodeAndJSON(rw, http.StatusOK, respData, logger) } -// RespondCodeAndJSON sends a response with the passed status code and sets the "Content-Type" +// RespondCodeAndJSON sends a response with the passed status code and sets the "Content-Type" // to "application/json" if it's not already set. It performs JSON marshaling of the data and // writes the result to the response's body. func RespondCodeAndJSON(rw http.ResponseWriter, statusCode int, respData interface{}, logger log.FieldLogger) { @@ -131,7 +131,7 @@ func RespondMalformedRequestOrInternalError(rw http.ResponseWriter, domain strin func logAndCollectMetricsForErrorIfNeeded(err *Error, logger log.FieldLogger) { if logger != nil { flds := []log.Field{log.String("error_code", err.Code), log.String("error_message", err.Message)} - if err.Context != nil { + if len(err.Context) != 0 { ctxLines := make([]string, 0, len(err.Context)) for k, v := range err.Context { ctxLines = append(ctxLines, fmt.Sprintf("%s: %v", k, v))