diff --git a/v3/cache.go b/v3/cache.go index 4b6d9b4..301ce48 100644 --- a/v3/cache.go +++ b/v3/cache.go @@ -17,8 +17,12 @@ import ( "fmt" "sync" "time" + _ "unsafe" ) +//go:linkname nanotime runtime.nanotime +func nanotime() int64 + // Cache defines cache interface type Cache[K comparable, V any] interface { fmt.Stringer @@ -26,7 +30,6 @@ type Cache[K comparable, V any] interface { Add(key K, value V) bool Set(key K, value V, ttl time.Duration) Get(key K) (V, bool) - GetExpiration(key K) (time.Time, bool) GetOldest() (K, V, bool) Contains(key K) (ok bool) Peek(key K) (V, bool) @@ -51,7 +54,7 @@ type Stats struct { // cacheImpl provides Cache interface implementation. type cacheImpl[K comparable, V any] struct { - ttl time.Duration + ttl int64 // TTL in nanoseconds maxKeys int isLRU bool onEvicted func(key K, value V) @@ -62,8 +65,8 @@ type cacheImpl[K comparable, V any] struct { evictList *list.List } -// noEvictionTTL - very long ttl to prevent eviction -const noEvictionTTL = time.Hour * 24 * 365 * 10 +// noEvictionTTL - very long ttl to prevent eviction (10 years in nanoseconds) +const noEvictionTTL = int64(time.Hour * 24 * 365 * 10) // NewCache returns a new Cache. // Default MaxKeys is unlimited (0). @@ -87,38 +90,40 @@ func (c *cacheImpl[K, V]) Add(key K, value V) (evicted bool) { // Set key, ttl of 0 would use cache-wide TTL func (c *cacheImpl[K, V]) Set(key K, value V, ttl time.Duration) { - c.addWithTTL(key, value, ttl) + c.addWithTTL(key, value, int64(ttl)) } // Returns true if an eviction occurred. // Returns false if there was no eviction: the item was already in the cache, // or the size was not exceeded. -func (c *cacheImpl[K, V]) addWithTTL(key K, value V, ttl time.Duration) (evicted bool) { +func (c *cacheImpl[K, V]) addWithTTL(key K, value V, ttl int64) (evicted bool) { if ttl == 0 { ttl = c.ttl } - now := time.Now() + now := nanotime() c.Lock() defer c.Unlock() // Check for existing item if ent, ok := c.items[key]; ok { c.evictList.MoveToFront(ent) - ent.Value.(*cacheItem[K, V]).value = value - ent.Value.(*cacheItem[K, V]).expiresAt = now.Add(ttl) + item := ent.Value.(*cacheItem[K, V]) // Single type assertion + item.value = value + item.expiresAt = now + ttl return false } // Add new item - ent := &cacheItem[K, V]{key: key, value: value, expiresAt: now.Add(ttl)} + ent := &cacheItem[K, V]{key: key, value: value, expiresAt: now + ttl} entry := c.evictList.PushFront(ent) c.items[key] = entry c.stat.Added++ // Remove the oldest entry if it is expired, only in case of non-default TTL. if c.ttl != noEvictionTTL || ttl != noEvictionTTL { - ent := c.evictList.Back() - if ent != nil && now.After(ent.Value.(*cacheItem[K, V]).expiresAt) { - c.removeElement(ent) + if oldEnt := c.evictList.Back(); oldEnt != nil { + if now > oldEnt.Value.(*cacheItem[K, V]).expiresAt { + c.removeElement(oldEnt) + } } } @@ -136,16 +141,17 @@ func (c *cacheImpl[K, V]) Get(key K) (V, bool) { c.Lock() defer c.Unlock() if ent, ok := c.items[key]; ok { + item := ent.Value.(*cacheItem[K, V]) // Expired item check - if time.Now().After(ent.Value.(*cacheItem[K, V]).expiresAt) { + if nanotime() > item.expiresAt { c.stat.Misses++ - return ent.Value.(*cacheItem[K, V]).value, false + return item.value, false } if c.isLRU { c.evictList.MoveToFront(ent) } c.stat.Hits++ - return ent.Value.(*cacheItem[K, V]).value, true + return item.value, true } c.stat.Misses++ return def, false @@ -167,28 +173,19 @@ func (c *cacheImpl[K, V]) Peek(key K) (V, bool) { c.Lock() defer c.Unlock() if ent, ok := c.items[key]; ok { + item := ent.Value.(*cacheItem[K, V]) // Single type assertion // Expired item check - if time.Now().After(ent.Value.(*cacheItem[K, V]).expiresAt) { + if nanotime() > item.expiresAt { c.stat.Misses++ - return ent.Value.(*cacheItem[K, V]).value, false + return item.value, false } c.stat.Hits++ - return ent.Value.(*cacheItem[K, V]).value, true + return item.value, true } c.stat.Misses++ return def, false } -// GetExpiration returns the expiration time of the key. Non-existing key returns zero time. -func (c *cacheImpl[K, V]) GetExpiration(key K) (time.Time, bool) { - c.Lock() - defer c.Unlock() - if ent, ok := c.items[key]; ok { - return ent.Value.(*cacheItem[K, V]).expiresAt, true - } - return time.Time{}, false -} - // Keys returns a slice of the keys in the cache, from oldest to newest. func (c *cacheImpl[K, V]) Keys() []K { c.Lock() @@ -200,11 +197,11 @@ func (c *cacheImpl[K, V]) Keys() []K { // Expired entries are filtered out. func (c *cacheImpl[K, V]) Values() []V { values := make([]V, 0, len(c.items)) - now := time.Now() + now := nanotime() c.Lock() defer c.Unlock() for ent := c.evictList.Back(); ent != nil; ent = ent.Prev() { - if !now.After(ent.Value.(*cacheItem[K, V]).expiresAt) { + if now <= ent.Value.(*cacheItem[K, V]).expiresAt { values = append(values, ent.Value.(*cacheItem[K, V]).value) } } @@ -292,13 +289,13 @@ func (c *cacheImpl[K, V]) GetOldest() (key K, value V, ok bool) { // DeleteExpired clears cache of expired items func (c *cacheImpl[K, V]) DeleteExpired() { - now := time.Now() + now := nanotime() c.Lock() defer c.Unlock() var nextEnt *list.Element for ent := c.evictList.Back(); ent != nil; ent = nextEnt { nextEnt = ent.Prev() - if now.After(ent.Value.(*cacheItem[K, V]).expiresAt) { + if now > ent.Value.(*cacheItem[K, V]).expiresAt { c.removeElement(ent) } } @@ -360,8 +357,9 @@ func (c *cacheImpl[K, V]) removeElement(e *list.Element) { } // cacheItem is used to hold a value in the evictList +// Uses int64 nanotime instead of time.Time for 16 bytes memory savings per item type cacheItem[K comparable, V any] struct { - expiresAt time.Time + expiresAt int64 // nanotime() value - monotonic nanoseconds key K value V } diff --git a/v3/cache_test.go b/v3/cache_test.go index ea56d48..fea9100 100644 --- a/v3/cache_test.go +++ b/v3/cache_test.go @@ -10,7 +10,6 @@ import ( "testing" "time" - "github.com/hashicorp/golang-lru/v2/simplelru" "github.com/stretchr/testify/assert" ) @@ -128,10 +127,6 @@ func BenchmarkLRU_Freq_WithExpire(b *testing.B) { b.Logf("hit: %d miss: %d ratio: %f", hit, miss, float64(hit)/float64(hit+miss)) } -func TestSimpleLRUInterface(_ *testing.T) { - var _ simplelru.LRUCache[int, int] = NewCache[int, int]() -} - func TestCacheNoPurge(t *testing.T) { lc := NewCache[string, string]() @@ -331,30 +326,6 @@ func TestCacheExpired(t *testing.T) { assert.Empty(t, lc.Values()) } -func TestCache_GetExpiration(t *testing.T) { - lc := NewCache[string, string]().WithTTL(time.Second * 5) - - lc.Set("key1", "val1", time.Second*5) - assert.Equal(t, 1, lc.Len()) - - exp, ok := lc.GetExpiration("key1") - assert.True(t, ok) - assert.True(t, exp.After(time.Now().Add(time.Second*4))) - assert.True(t, exp.Before(time.Now().Add(time.Second*6))) - - lc.Set("key2", "val2", time.Second*10) - assert.Equal(t, 2, lc.Len()) - - exp, ok = lc.GetExpiration("key2") - assert.True(t, ok) - assert.True(t, exp.After(time.Now().Add(time.Second*9))) - assert.True(t, exp.Before(time.Now().Add(time.Second*11))) - - exp, ok = lc.GetExpiration("non-existing-key") - assert.False(t, ok) - assert.Zero(t, exp) -} - func TestCacheRemoveOldest(t *testing.T) { lc := NewCache[string, string]().WithLRU().WithMaxKeys(2) diff --git a/v3/go.mod b/v3/go.mod index 558dc78..d91fd8c 100644 --- a/v3/go.mod +++ b/v3/go.mod @@ -1,12 +1,13 @@ module github.com/go-pkgz/expirable-cache/v3 -go 1.20 +go 1.25 -require github.com/stretchr/testify v1.10.0 +require ( + github.com/stretchr/testify v1.11.1 +) require ( github.com/davecgh/go-spew v1.1.1 // indirect - github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/v3/go.sum b/v3/go.sum index bba5334..1dabeb5 100644 --- a/v3/go.sum +++ b/v3/go.sum @@ -8,6 +8,8 @@ github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcU github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/v3/options.go b/v3/options.go index b5fe65d..70ca797 100644 --- a/v3/options.go +++ b/v3/options.go @@ -12,7 +12,7 @@ type options[K comparable, V any] interface { // WithTTL functional option defines TTL for all cache entries. // By default, it is set to 10 years, sane option for expirable cache might be 5 minutes. func (c *cacheImpl[K, V]) WithTTL(ttl time.Duration) Cache[K, V] { - c.ttl = ttl + c.ttl = int64(ttl) // Convert time.Duration to int64 nanoseconds return c }