From 93318fbae99a647bfea75c4ee214608e81efdf44 Mon Sep 17 00:00:00 2001 From: Leonid Titov Date: Fri, 28 Oct 2022 11:59:34 +0300 Subject: [PATCH] Transaction() and external locking added --- cache-private.go | 169 +++++++++++++++++++++++++++++ cache.go | 277 ++++++++++++----------------------------------- cache_test.go | 112 +++++++++---------- options.go | 19 +++- 4 files changed, 312 insertions(+), 265 deletions(-) create mode 100644 cache-private.go diff --git a/cache-private.go b/cache-private.go new file mode 100644 index 0000000..1bed79e --- /dev/null +++ b/cache-private.go @@ -0,0 +1,169 @@ +package ttlcache + +import ( + "container/list" + "time" +) + +// updateExpirations updates the expiration queue and notifies +// the cache auto cleaner if needed. +// Not concurrently safe. +func (c *Cache[K, V]) updateExpirations(fresh bool, elem *list.Element) { + var oldExpiresAt time.Time + + if !c.CacheItems.expQueue.isEmpty() { + oldExpiresAt = c.CacheItems.expQueue[0].Value.(*Item[K, V]).expiresAt + } + + if fresh { + c.CacheItems.expQueue.push(elem) + } else { + c.CacheItems.expQueue.update(elem) + } + + newExpiresAt := c.CacheItems.expQueue[0].Value.(*Item[K, V]).expiresAt + + // check if the closest/soonest expiration timestamp changed + if newExpiresAt.IsZero() || (!oldExpiresAt.IsZero() && !newExpiresAt.Before(oldExpiresAt)) { + return + } + + d := time.Until(newExpiresAt) + + // It's possible that the auto cleaner isn't active or + // is busy, so we need to drain the channel before + // sending a new value. + // Also, since this method is called after locking the items' mutex, + // we can be sure that there is no other concurrent call of this + // method + if len(c.CacheItems.timerCh) > 0 { + // we need to drain this channel in a select with a default + // case because it's possible that the auto cleaner + // read this channel just after we entered this if + select { + case d1 := <-c.CacheItems.timerCh: + if d1 < d { + d = d1 + } + default: + } + } + + // since the channel has a size 1 buffer, we can be sure + // that the line below won't block (we can't overfill the buffer + // because we just drained it) + c.CacheItems.timerCh <- d +} + +// set creates a new item, adds it to the cache and then returns it. +// Not concurrently safe. +func (c *Cache[K, V]) set(key K, value V, ttl time.Duration, touch bool) *Item[K, V] { + if ttl == DefaultTTL { + ttl = c.options.ttl + } + + elem := c.get(key, false) + if elem != nil { + // update/overwrite an existing item + item := elem.Value.(*Item[K, V]) + item.update(value, ttl) + if touch { + c.updateExpirations(false, elem) + } + + return item + } + + if c.options.capacity != 0 && uint64(len(c.CacheItems.values)) >= c.options.capacity { + // delete the oldest item + c.evict(EvictionReasonCapacityReached, c.CacheItems.lru.Back()) + } + + // create a new item + item := newItem(key, value, ttl) + elem = c.CacheItems.lru.PushFront(item) + c.CacheItems.values[key] = elem + c.updateExpirations(true, elem) + + c.metricsMu.Lock() + c.metrics.Insertions++ + c.metricsMu.Unlock() + + c.events.insertion.mu.RLock() + for _, fn := range c.events.insertion.fns { + fn(item) + } + c.events.insertion.mu.RUnlock() + + return item +} + +// get retrieves an item from the cache and extends its expiration +// time if 'touch' is set to true. +// It returns nil if the item is not found or is expired. +// Not concurrently safe. +func (c *Cache[K, V]) get(key K, touch bool) *list.Element { + elem := c.CacheItems.values[key] + if elem == nil { + return nil + } + + item := elem.Value.(*Item[K, V]) + if item.isExpiredUnsafe() { + return nil + } + + c.CacheItems.lru.MoveToFront(elem) + + if touch && item.ttl > 0 { + item.touch() + c.updateExpirations(false, elem) + } + + return elem +} + +// evict deletes items from the cache. +// If no items are provided, all currently present cache items +// are evicted. +// Not concurrently safe. +func (c *Cache[K, V]) evict(reason EvictionReason, elems ...*list.Element) { + if len(elems) > 0 { + c.metricsMu.Lock() + c.metrics.Evictions += uint64(len(elems)) + c.metricsMu.Unlock() + + c.events.eviction.mu.RLock() + for i := range elems { + item := elems[i].Value.(*Item[K, V]) + delete(c.CacheItems.values, item.key) + c.CacheItems.lru.Remove(elems[i]) + c.CacheItems.expQueue.remove(elems[i]) + + for _, fn := range c.events.eviction.fns { + fn(reason, item) + } + } + c.events.eviction.mu.RUnlock() + + return + } + + c.metricsMu.Lock() + c.metrics.Evictions += uint64(len(c.CacheItems.values)) + c.metricsMu.Unlock() + + c.events.eviction.mu.RLock() + for _, elem := range c.CacheItems.values { + item := elem.Value.(*Item[K, V]) + + for _, fn := range c.events.eviction.fns { + fn(reason, item) + } + } + c.events.eviction.mu.RUnlock() + + c.CacheItems.values = make(map[K]*list.Element) + c.CacheItems.lru.Init() + c.CacheItems.expQueue = newExpirationQueue[K, V]() +} diff --git a/cache.go b/cache.go index b0deb1e..cd20d19 100644 --- a/cache.go +++ b/cache.go @@ -24,8 +24,8 @@ type EvictionReason int // Cache is a synchronised map of items that are automatically removed // when they expire or the capacity is reached. type Cache[K comparable, V any] struct { - items struct { - mu sync.RWMutex + CacheItems struct { + Mu sync.RWMutex values map[K]*list.Element // a generic doubly linked list would be more convenient @@ -62,10 +62,10 @@ func New[K comparable, V any](opts ...Option[K, V]) *Cache[K, V] { c := &Cache[K, V]{ stopCh: make(chan struct{}), } - c.items.values = make(map[K]*list.Element) - c.items.lru = list.New() - c.items.expQueue = newExpirationQueue[K, V]() - c.items.timerCh = make(chan time.Duration, 1) // buffer is important + c.CacheItems.values = make(map[K]*list.Element) + c.CacheItems.lru = list.New() + c.CacheItems.expQueue = newExpirationQueue[K, V]() + c.CacheItems.timerCh = make(chan time.Duration, 1) // buffer is important c.events.insertion.fns = make(map[uint64]func(*Item[K, V])) c.events.eviction.fns = make(map[uint64]func(EvictionReason, *Item[K, V])) @@ -74,175 +74,14 @@ func New[K comparable, V any](opts ...Option[K, V]) *Cache[K, V] { return c } -// updateExpirations updates the expiration queue and notifies -// the cache auto cleaner if needed. -// Not concurrently safe. -func (c *Cache[K, V]) updateExpirations(fresh bool, elem *list.Element) { - var oldExpiresAt time.Time - - if !c.items.expQueue.isEmpty() { - oldExpiresAt = c.items.expQueue[0].Value.(*Item[K, V]).expiresAt - } - - if fresh { - c.items.expQueue.push(elem) - } else { - c.items.expQueue.update(elem) - } - - newExpiresAt := c.items.expQueue[0].Value.(*Item[K, V]).expiresAt - - // check if the closest/soonest expiration timestamp changed - if newExpiresAt.IsZero() || (!oldExpiresAt.IsZero() && !newExpiresAt.Before(oldExpiresAt)) { - return - } - - d := time.Until(newExpiresAt) - - // It's possible that the auto cleaner isn't active or - // is busy, so we need to drain the channel before - // sending a new value. - // Also, since this method is called after locking the items' mutex, - // we can be sure that there is no other concurrent call of this - // method - if len(c.items.timerCh) > 0 { - // we need to drain this channel in a select with a default - // case because it's possible that the auto cleaner - // read this channel just after we entered this if - select { - case d1 := <-c.items.timerCh: - if d1 < d { - d = d1 - } - default: - } - } - - // since the channel has a size 1 buffer, we can be sure - // that the line below won't block (we can't overfill the buffer - // because we just drained it) - c.items.timerCh <- d -} - -// set creates a new item, adds it to the cache and then returns it. -// Not concurrently safe. -func (c *Cache[K, V]) set(key K, value V, ttl time.Duration, touch bool) *Item[K, V] { - if ttl == DefaultTTL { - ttl = c.options.ttl - } - - elem := c.get(key, false) - if elem != nil { - // update/overwrite an existing item - item := elem.Value.(*Item[K, V]) - item.update(value, ttl) - if touch { - c.updateExpirations(false, elem) - } - - return item - } - - if c.options.capacity != 0 && uint64(len(c.items.values)) >= c.options.capacity { - // delete the oldest item - c.evict(EvictionReasonCapacityReached, c.items.lru.Back()) - } - - // create a new item - item := newItem(key, value, ttl) - elem = c.items.lru.PushFront(item) - c.items.values[key] = elem - c.updateExpirations(true, elem) - - c.metricsMu.Lock() - c.metrics.Insertions++ - c.metricsMu.Unlock() - - c.events.insertion.mu.RLock() - for _, fn := range c.events.insertion.fns { - fn(item) - } - c.events.insertion.mu.RUnlock() - - return item -} - -// get retrieves an item from the cache and extends its expiration -// time if 'touch' is set to true. -// It returns nil if the item is not found or is expired. -// Not concurrently safe. -func (c *Cache[K, V]) get(key K, touch bool) *list.Element { - elem := c.items.values[key] - if elem == nil { - return nil - } - - item := elem.Value.(*Item[K, V]) - if item.isExpiredUnsafe() { - return nil - } - - c.items.lru.MoveToFront(elem) - - if touch && item.ttl > 0 { - item.touch() - c.updateExpirations(false, elem) - } - - return elem -} - -// evict deletes items from the cache. -// If no items are provided, all currently present cache items -// are evicted. -// Not concurrently safe. -func (c *Cache[K, V]) evict(reason EvictionReason, elems ...*list.Element) { - if len(elems) > 0 { - c.metricsMu.Lock() - c.metrics.Evictions += uint64(len(elems)) - c.metricsMu.Unlock() - - c.events.eviction.mu.RLock() - for i := range elems { - item := elems[i].Value.(*Item[K, V]) - delete(c.items.values, item.key) - c.items.lru.Remove(elems[i]) - c.items.expQueue.remove(elems[i]) - - for _, fn := range c.events.eviction.fns { - fn(reason, item) - } - } - c.events.eviction.mu.RUnlock() - - return - } - - c.metricsMu.Lock() - c.metrics.Evictions += uint64(len(c.items.values)) - c.metricsMu.Unlock() - - c.events.eviction.mu.RLock() - for _, elem := range c.items.values { - item := elem.Value.(*Item[K, V]) - - for _, fn := range c.events.eviction.fns { - fn(reason, item) - } - } - c.events.eviction.mu.RUnlock() - - c.items.values = make(map[K]*list.Element) - c.items.lru.Init() - c.items.expQueue = newExpirationQueue[K, V]() -} - // Set creates a new item from the provided key and value, adds // it to the cache and then returns it. If an item associated with the // provided key already exists, the new item overwrites the existing one. func (c *Cache[K, V]) Set(key K, value V, ttl time.Duration) *Item[K, V] { - c.items.mu.Lock() - defer c.items.mu.Unlock() + if !c.options.lockingFromOutside { + c.CacheItems.Mu.Lock() + defer c.CacheItems.Mu.Unlock() + } return c.set(key, value, ttl, true) } @@ -252,8 +91,10 @@ func (c *Cache[K, V]) Set(key K, value V, ttl time.Duration) *Item[K, V] { // provided key already exists, the new item overwrites the existing one. // DOES NOT UPDATE EXPIRATIONS func (c *Cache[K, V]) SetDontTouch(key K, value V, ttl time.Duration) *Item[K, V] { - c.items.mu.Lock() - defer c.items.mu.Unlock() + if !c.options.lockingFromOutside { + c.CacheItems.Mu.Lock() + defer c.CacheItems.Mu.Unlock() + } return c.set(key, value, ttl, false) } @@ -270,9 +111,13 @@ func (c *Cache[K, V]) Get(key K, opts ...Option[K, V]) *Item[K, V] { applyOptions(&getOpts, opts...) - c.items.mu.Lock() + if !c.options.lockingFromOutside { + c.CacheItems.Mu.Lock() + } elem := c.get(key, !getOpts.disableTouchOnHit) - c.items.mu.Unlock() + if !c.options.lockingFromOutside { + c.CacheItems.Mu.Unlock() + } if elem == nil { c.metricsMu.Lock() @@ -293,13 +138,25 @@ func (c *Cache[K, V]) Get(key K, opts ...Option[K, V]) *Item[K, V] { return elem.Value.(*Item[K, V]) } +func (c *Cache[K, V]) Transaction(key K, f func(c *Cache[K, V])) { + if !c.options.lockingFromOutside { + c.CacheItems.Mu.Lock() + } + f(c) + if !c.options.lockingFromOutside { + c.CacheItems.Mu.Unlock() + } +} + // Delete deletes an item from the cache. If the item associated with // the key is not found, the method is no-op. func (c *Cache[K, V]) Delete(key K) { - c.items.mu.Lock() - defer c.items.mu.Unlock() + if !c.options.lockingFromOutside { + c.CacheItems.Mu.Lock() + defer c.CacheItems.Mu.Unlock() + } - elem := c.items.values[key] + elem := c.CacheItems.values[key] if elem == nil { return } @@ -309,30 +166,36 @@ func (c *Cache[K, V]) Delete(key K) { // DeleteAll deletes all items from the cache. func (c *Cache[K, V]) DeleteAll() { - c.items.mu.Lock() + if !c.options.lockingFromOutside { + c.CacheItems.Mu.Lock() + } c.evict(EvictionReasonDeleted) - c.items.mu.Unlock() + if !c.options.lockingFromOutside { + c.CacheItems.Mu.Unlock() + } } // DeleteExpired deletes all expired items from the cache. func (c *Cache[K, V]) DeleteExpired() { - c.items.mu.Lock() - defer c.items.mu.Unlock() + if !c.options.lockingFromOutside { + c.CacheItems.Mu.Lock() + defer c.CacheItems.Mu.Unlock() + } - if c.items.expQueue.isEmpty() { + if c.CacheItems.expQueue.isEmpty() { return } - e := c.items.expQueue[0] + e := c.CacheItems.expQueue[0] for e.Value.(*Item[K, V]).isExpiredUnsafe() { c.evict(EvictionReasonExpired, e) - if c.items.expQueue.isEmpty() { + if c.CacheItems.expQueue.isEmpty() { break } // expiration queue has a new root - e = c.items.expQueue[0] + e = c.CacheItems.expQueue[0] } } @@ -340,26 +203,30 @@ func (c *Cache[K, V]) DeleteExpired() { // Its main purpose is to extend an item's expiration timestamp. // If the item is not found, the method is no-op. func (c *Cache[K, V]) Touch(key K) { - c.items.mu.Lock() + if !c.options.lockingFromOutside { + c.CacheItems.Mu.Lock() + } c.get(key, true) - c.items.mu.Unlock() + if !c.options.lockingFromOutside { + c.CacheItems.Mu.Unlock() + } } // Len returns the number of items in the cache. func (c *Cache[K, V]) Len() int { - c.items.mu.RLock() - defer c.items.mu.RUnlock() + c.CacheItems.Mu.RLock() + defer c.CacheItems.Mu.RUnlock() - return len(c.items.values) + return len(c.CacheItems.values) } // Keys returns all keys currently present in the cache. func (c *Cache[K, V]) Keys() []K { - c.items.mu.RLock() - defer c.items.mu.RUnlock() + c.CacheItems.Mu.RLock() + defer c.CacheItems.Mu.RUnlock() - res := make([]K, 0, len(c.items.values)) - for k := range c.items.values { + res := make([]K, 0, len(c.CacheItems.values)) + for k := range c.CacheItems.values { res = append(res, k) } @@ -369,11 +236,11 @@ func (c *Cache[K, V]) Keys() []K { // Items returns a copy of all items in the cache. // It does not update any expiration timestamps. func (c *Cache[K, V]) Items() map[K]*Item[K, V] { - c.items.mu.RLock() - defer c.items.mu.RUnlock() + c.CacheItems.Mu.RLock() + defer c.CacheItems.Mu.RUnlock() - items := make(map[K]*Item[K, V], len(c.items.values)) - for k := range c.items.values { + items := make(map[K]*Item[K, V], len(c.CacheItems.values)) + for k := range c.CacheItems.values { item := c.get(k, false) if item != nil { items[k] = item.Value.(*Item[K, V]) @@ -396,12 +263,12 @@ func (c *Cache[K, V]) Metrics() Metrics { // It blocks until Stop is called. func (c *Cache[K, V]) Start() { waitDur := func() time.Duration { - c.items.mu.RLock() - defer c.items.mu.RUnlock() + c.CacheItems.Mu.RLock() + defer c.CacheItems.Mu.RUnlock() - if !c.items.expQueue.isEmpty() && - !c.items.expQueue[0].Value.(*Item[K, V]).expiresAt.IsZero() { - d := time.Until(c.items.expQueue[0].Value.(*Item[K, V]).expiresAt) + if !c.CacheItems.expQueue.isEmpty() && + !c.CacheItems.expQueue[0].Value.(*Item[K, V]).expiresAt.IsZero() { + d := time.Until(c.CacheItems.expQueue[0].Value.(*Item[K, V]).expiresAt) if d <= 0 { // execute immediately return time.Microsecond @@ -434,7 +301,7 @@ func (c *Cache[K, V]) Start() { select { case <-c.stopCh: return - case d := <-c.items.timerCh: + case d := <-c.CacheItems.timerCh: stop() timer.Reset(d) case <-timer.C: diff --git a/cache_test.go b/cache_test.go index aa36aa1..c627114 100644 --- a/cache_test.go +++ b/cache_test.go @@ -25,10 +25,10 @@ func Test_New(t *testing.T) { ) require.NotNil(t, c) assert.NotNil(t, c.stopCh) - assert.NotNil(t, c.items.values) - assert.NotNil(t, c.items.lru) - assert.NotNil(t, c.items.expQueue) - assert.NotNil(t, c.items.timerCh) + assert.NotNil(t, c.CacheItems.values) + assert.NotNil(t, c.CacheItems.lru) + assert.NotNil(t, c.CacheItems.expQueue) + assert.NotNil(t, c.CacheItems.timerCh) assert.NotNil(t, c.events.insertion.fns) assert.NotNil(t, c.events.eviction.fns) assert.Equal(t, time.Hour, c.options.ttl) @@ -125,7 +125,7 @@ func Test_Cache_updateExpirations(t *testing.T) { cache := prepCache(time.Hour) if c.TimerChValue > 0 { - cache.items.timerCh <- c.TimerChValue + cache.CacheItems.timerCh <- c.TimerChValue } elem := &list.Element{ @@ -135,7 +135,7 @@ func Test_Cache_updateExpirations(t *testing.T) { } if !c.EmptyQueue { - cache.items.expQueue.push(&list.Element{ + cache.CacheItems.expQueue.push(&list.Element{ Value: &Item[string, string]{ expiresAt: c.OldExpiresAt, }, @@ -147,7 +147,7 @@ func Test_Cache_updateExpirations(t *testing.T) { expiresAt: c.OldExpiresAt, }, } - cache.items.expQueue.push(elem) + cache.CacheItems.expQueue.push(elem) elem.Value.(*Item[string, string]).expiresAt = c.NewExpiresAt } @@ -158,7 +158,7 @@ func Test_Cache_updateExpirations(t *testing.T) { var res time.Duration select { - case res = <-cache.items.timerCh: + case res = <-cache.CacheItems.timerCh: default: } @@ -275,30 +275,30 @@ func Test_Cache_set(t *testing.T) { } } - assert.Same(t, cache.items.values[c.Key].Value.(*Item[string, string]), item) - assert.Len(t, cache.items.values, total) + assert.Same(t, cache.CacheItems.values[c.Key].Value.(*Item[string, string]), item) + assert.Len(t, cache.CacheItems.values, total) assert.Equal(t, c.Key, item.key) assert.Equal(t, "value123", item.value) - assert.Equal(t, c.Key, cache.items.lru.Front().Value.(*Item[string, string]).key) + assert.Equal(t, c.Key, cache.CacheItems.lru.Front().Value.(*Item[string, string]).key) assert.Equal(t, c.Metrics, cache.metrics) if c.Capacity > 0 && c.Capacity < 4 { - assert.NotEqual(t, evictedKey, cache.items.lru.Back().Value.(*Item[string, string]).key) + assert.NotEqual(t, evictedKey, cache.CacheItems.lru.Back().Value.(*Item[string, string]).key) } switch { case c.TTL == DefaultTTL: assert.Equal(t, cache.options.ttl, item.ttl) assert.WithinDuration(t, time.Now(), item.expiresAt, cache.options.ttl) - assert.Equal(t, c.Key, cache.items.expQueue[0].Value.(*Item[string, string]).key) + assert.Equal(t, c.Key, cache.CacheItems.expQueue[0].Value.(*Item[string, string]).key) case c.TTL > DefaultTTL: assert.Equal(t, c.TTL, item.ttl) assert.WithinDuration(t, time.Now(), item.expiresAt, c.TTL) - assert.Equal(t, c.Key, cache.items.expQueue[0].Value.(*Item[string, string]).key) + assert.Equal(t, c.Key, cache.CacheItems.expQueue[0].Value.(*Item[string, string]).key) default: assert.Equal(t, c.TTL, item.ttl) assert.Zero(t, item.expiresAt) - assert.NotEqual(t, c.Key, cache.items.expQueue[0].Value.(*Item[string, string]).key) + assert.NotEqual(t, c.Key, cache.CacheItems.expQueue[0].Value.(*Item[string, string]).key) } }) } @@ -342,7 +342,7 @@ func Test_Cache_get(t *testing.T) { addToCache(cache, time.Nanosecond, expiredKey) time.Sleep(time.Millisecond) // force expiration - oldItem := cache.items.values[existingKey].Value.(*Item[string, string]) + oldItem := cache.CacheItems.values[existingKey].Value.(*Item[string, string]) oldQueueIndex := oldItem.queueIndex oldExpiresAt := oldItem.expiresAt @@ -360,7 +360,7 @@ func Test_Cache_get(t *testing.T) { } if c.Key == expiredKey { - assert.True(t, time.Now().After(cache.items.values[expiredKey].Value.(*Item[string, string]).expiresAt)) + assert.True(t, time.Now().After(cache.CacheItems.values[expiredKey].Value.(*Item[string, string]).expiresAt)) assert.Nil(t, elem) return } @@ -376,7 +376,7 @@ func Test_Cache_get(t *testing.T) { assert.Equal(t, oldQueueIndex, item.queueIndex) } - assert.Equal(t, c.Key, cache.items.lru.Front().Value.(*Item[string, string]).key) + assert.Equal(t, c.Key, cache.CacheItems.lru.Front().Value.(*Item[string, string]).key) }) } } @@ -406,15 +406,15 @@ func Test_Cache_evict(t *testing.T) { cache.events.eviction.fns[2] = cache.events.eviction.fns[1] // delete only specified - cache.evict(EvictionReasonDeleted, cache.items.lru.Back(), cache.items.lru.Back().Prev()) + cache.evict(EvictionReasonDeleted, cache.CacheItems.lru.Back(), cache.CacheItems.lru.Back().Prev()) assert.Equal(t, 2, key1FnsCalls) assert.Equal(t, 2, key2FnsCalls) assert.Zero(t, key3FnsCalls) assert.Zero(t, key4FnsCalls) - assert.Len(t, cache.items.values, 2) - assert.NotContains(t, cache.items.values, "1") - assert.NotContains(t, cache.items.values, "2") + assert.Len(t, cache.CacheItems.values, 2) + assert.NotContains(t, cache.CacheItems.values, "1") + assert.NotContains(t, cache.CacheItems.values, "2") assert.Equal(t, uint64(2), cache.metrics.Evictions) // delete all @@ -427,9 +427,9 @@ func Test_Cache_evict(t *testing.T) { assert.Zero(t, key2FnsCalls) assert.Equal(t, 2, key3FnsCalls) assert.Equal(t, 2, key4FnsCalls) - assert.Empty(t, cache.items.values) - assert.NotContains(t, cache.items.values, "3") - assert.NotContains(t, cache.items.values, "4") + assert.Empty(t, cache.CacheItems.values) + assert.NotContains(t, cache.CacheItems.values, "3") + assert.NotContains(t, cache.CacheItems.values, "4") assert.Equal(t, uint64(2), cache.metrics.Evictions) } @@ -437,11 +437,11 @@ func Test_Cache_Set(t *testing.T) { cache := prepCache(time.Hour, "test1", "test2", "test3") item := cache.Set("hello", "value123", time.Minute) require.NotNil(t, item) - assert.Same(t, item, cache.items.values["hello"].Value) + assert.Same(t, item, cache.CacheItems.values["hello"].Value) item = cache.Set("test1", "value123", time.Minute) require.NotNil(t, item) - assert.Same(t, item, cache.items.values["test1"].Value) + assert.Same(t, item, cache.CacheItems.values["test1"].Value) } func Test_Cache_Get(t *testing.T) { @@ -548,14 +548,14 @@ func Test_Cache_Get(t *testing.T) { t.Parallel() cache := prepCache(time.Minute, foundKey, "test2", "test3") - oldExpiresAt := cache.items.values[foundKey].Value.(*Item[string, string]).expiresAt + oldExpiresAt := cache.CacheItems.values[foundKey].Value.(*Item[string, string]).expiresAt cache.options = c.DefaultOptions res := cache.Get(c.Key, c.CallOptions...) if c.Key == foundKey { - c.Result = cache.items.values[foundKey].Value.(*Item[string, string]) - assert.Equal(t, foundKey, cache.items.lru.Front().Value.(*Item[string, string]).key) + c.Result = cache.CacheItems.values[foundKey].Value.(*Item[string, string]) + assert.Equal(t, foundKey, cache.CacheItems.lru.Front().Value.(*Item[string, string]).key) } assert.Equal(t, c.Metrics, cache.metrics) @@ -590,13 +590,13 @@ func Test_Cache_Delete(t *testing.T) { // not found cache.Delete("1234") assert.Zero(t, fnsCalls) - assert.Len(t, cache.items.values, 4) + assert.Len(t, cache.CacheItems.values, 4) // success cache.Delete("1") assert.Equal(t, 2, fnsCalls) - assert.Len(t, cache.items.values, 3) - assert.NotContains(t, cache.items.values, "1") + assert.Len(t, cache.CacheItems.values, 3) + assert.NotContains(t, cache.CacheItems.values, "1") } func Test_Cache_DeleteAll(t *testing.T) { @@ -624,7 +624,7 @@ func Test_Cache_DeleteAll(t *testing.T) { cache.events.eviction.fns[2] = cache.events.eviction.fns[1] cache.DeleteAll() - assert.Empty(t, cache.items.values) + assert.Empty(t, cache.CacheItems.values) assert.Equal(t, 2, key1FnsCalls) assert.Equal(t, 2, key2FnsCalls) assert.Equal(t, 2, key3FnsCalls) @@ -653,15 +653,15 @@ func Test_Cache_DeleteExpired(t *testing.T) { addToCache(cache, time.Nanosecond, "5") cache.DeleteExpired() - assert.Empty(t, cache.items.values) - assert.NotContains(t, cache.items.values, "5") + assert.Empty(t, cache.CacheItems.values) + assert.NotContains(t, cache.CacheItems.values, "5") assert.Equal(t, 2, key1FnsCalls) key1FnsCalls = 0 // empty cache.DeleteExpired() - assert.Empty(t, cache.items.values) + assert.Empty(t, cache.CacheItems.values) // non empty addToCache(cache, time.Hour, "1", "2", "3", "4") @@ -670,22 +670,22 @@ func Test_Cache_DeleteExpired(t *testing.T) { time.Sleep(time.Millisecond) // force expiration cache.DeleteExpired() - assert.Len(t, cache.items.values, 4) - assert.NotContains(t, cache.items.values, "5") - assert.NotContains(t, cache.items.values, "6") + assert.Len(t, cache.CacheItems.values, 4) + assert.NotContains(t, cache.CacheItems.values, "5") + assert.NotContains(t, cache.CacheItems.values, "6") assert.Equal(t, 2, key1FnsCalls) assert.Equal(t, 2, key2FnsCalls) } func Test_Cache_Touch(t *testing.T) { cache := prepCache(time.Hour, "1", "2") - oldExpiresAt := cache.items.values["1"].Value.(*Item[string, string]).expiresAt + oldExpiresAt := cache.CacheItems.values["1"].Value.(*Item[string, string]).expiresAt cache.Touch("1") - newExpiresAt := cache.items.values["1"].Value.(*Item[string, string]).expiresAt + newExpiresAt := cache.CacheItems.values["1"].Value.(*Item[string, string]).expiresAt assert.True(t, newExpiresAt.After(oldExpiresAt)) - assert.Equal(t, "1", cache.items.lru.Front().Value.(*Item[string, string]).key) + assert.Equal(t, "1", cache.CacheItems.lru.Front().Value.(*Item[string, string]).key) } func Test_Cache_Len(t *testing.T) { @@ -736,17 +736,17 @@ func Test_Cache_Start(t *testing.T) { switch v { case 1: - cache.items.mu.Lock() + cache.CacheItems.Mu.Lock() addToCache(cache, time.Nanosecond, "2") - cache.items.mu.Unlock() + cache.CacheItems.Mu.Unlock() cache.options.ttl = time.Hour - cache.items.timerCh <- time.Millisecond + cache.CacheItems.timerCh <- time.Millisecond case 2: - cache.items.mu.Lock() + cache.CacheItems.Mu.Lock() addToCache(cache, time.Second, "3") addToCache(cache, NoTTL, "4") - cache.items.mu.Unlock() - cache.items.timerCh <- time.Millisecond + cache.CacheItems.Mu.Unlock() + cache.CacheItems.timerCh <- time.Millisecond default: close(cache.stopCh) } @@ -1027,10 +1027,10 @@ func Test_SuppressedLoader_Load(t *testing.T) { func prepCache(ttl time.Duration, keys ...string) *Cache[string, string] { c := &Cache[string, string]{} c.options.ttl = ttl - c.items.values = make(map[string]*list.Element) - c.items.lru = list.New() - c.items.expQueue = newExpirationQueue[string, string]() - c.items.timerCh = make(chan time.Duration, 1) + c.CacheItems.values = make(map[string]*list.Element) + c.CacheItems.lru = list.New() + c.CacheItems.expQueue = newExpirationQueue[string, string]() + c.CacheItems.timerCh = make(chan time.Duration, 1) c.events.eviction.fns = make(map[uint64]func(EvictionReason, *Item[string, string])) c.events.insertion.fns = make(map[uint64]func(*Item[string, string])) @@ -1046,8 +1046,8 @@ func addToCache(c *Cache[string, string], ttl time.Duration, keys ...string) { fmt.Sprint("value of", key), ttl+time.Duration(i)*time.Minute, ) - elem := c.items.lru.PushFront(item) - c.items.values[key] = elem - c.items.expQueue.push(elem) + elem := c.CacheItems.lru.PushFront(item) + c.CacheItems.values[key] = elem + c.CacheItems.expQueue.push(elem) } } diff --git a/options.go b/options.go index 3acf88d..b01594e 100644 --- a/options.go +++ b/options.go @@ -17,10 +17,11 @@ func (fn optionFunc[K, V]) apply(opts *options[K, V]) { // options holds all available cache configuration options. type options[K comparable, V any] struct { - capacity uint64 - ttl time.Duration - loader Loader[K, V] - disableTouchOnHit bool + capacity uint64 + ttl time.Duration + loader Loader[K, V] + disableTouchOnHit bool + lockingFromOutside bool } // applyOptions applies the provided option values to the option struct. @@ -65,3 +66,13 @@ func WithDisableTouchOnHit[K comparable, V any]() Option[K, V] { opts.disableTouchOnHit = true }) } + +// With this set, the cache becomes thread-unsafe, and must be locked +// explicitly from outside. This is useful when external goroutine wrapper +// need to do atomic read-update transactions on values. +// NOTE: though you can use this, maybe you need to instead use the Transaction() method. +func WithLockingFromOutside[K comparable, V any]() Option[K, V] { + return optionFunc[K, V](func(opts *options[K, V]) { + opts.lockingFromOutside = true + }) +}