Skip to content
Open
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
66 changes: 32 additions & 34 deletions v3/cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,16 +17,19 @@ 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
options[K, V]
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)
Expand All @@ -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)
Expand All @@ -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).
Expand All @@ -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)
}
}
}

Expand All @@ -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
Expand All @@ -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()
Expand All @@ -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)
}
}
Expand Down Expand Up @@ -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)
}
}
Expand Down Expand Up @@ -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
}
29 changes: 0 additions & 29 deletions v3/cache_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import (
"testing"
"time"

"github.com/hashicorp/golang-lru/v2/simplelru"
"github.com/stretchr/testify/assert"
)

Expand Down Expand Up @@ -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]()

Expand Down Expand Up @@ -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)

Expand Down
7 changes: 4 additions & 3 deletions v3/go.mod
Original file line number Diff line number Diff line change
@@ -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
)
2 changes: 2 additions & 0 deletions v3/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
2 changes: 1 addition & 1 deletion v3/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down