diff --git a/.github/workflows/ci-v2.yml b/.github/workflows/ci-v2.yml index c3d280f..99a82bc 100644 --- a/.github/workflows/ci-v2.yml +++ b/.github/workflows/ci-v2.yml @@ -18,13 +18,13 @@ jobs: steps: - name: set up go - uses: actions/setup-go@v3 + uses: actions/setup-go@5 with: go-version: "1.20" id: go - name: checkout - uses: actions/checkout@v3 + uses: actions/checkout@4 - name: build and test run: | @@ -33,7 +33,7 @@ jobs: working-directory: v2 - name: golangci-lint - uses: golangci/golangci-lint-action@v3 + uses: golangci/golangci-lint-action@v4 with: version: latest working-directory: v2 diff --git a/.github/workflows/ci-v3.yml b/.github/workflows/ci-v3.yml new file mode 100644 index 0000000..898024c --- /dev/null +++ b/.github/workflows/ci-v3.yml @@ -0,0 +1,47 @@ +name: build-v3 + +on: + push: + branches: + tags: + paths: + - ".github/workflows/ci-v3.yml" + - "v3/**" + pull_request: + paths: + - ".github/workflows/ci-v3.yml" + - "v3/**" + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: set up go + uses: actions/setup-go@5 + with: + go-version: "1.20" + id: go + + - name: checkout + uses: actions/checkout@4 + + - name: build and test + run: | + go test -timeout=60s -race -covermode=atomic -coverprofile=$GITHUB_WORKSPACE/profile.cov + go build -race + working-directory: v3 + + - name: golangci-lint + uses: golangci/golangci-lint-action@v4 + with: + version: latest + working-directory: v3 + + - name: install goveralls, submit coverage + run: | + go install github.com/mattn/goveralls@latest + goveralls -service="github" -coverprofile=$GITHUB_WORKSPACE/profile.cov + env: + COVERALLS_TOKEN: ${{ secrets.GITHUB_TOKEN }} + working-directory: v3 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 85086fa..2cde7ba 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,11 +6,15 @@ on: tags: paths-ignore: - ".github/workflows/ci-v2.yml" + - ".github/workflows/ci-v3.yml" - "v2/**" + - "v3/**" pull_request: paths-ignore: - ".github/workflows/ci-v2.yml" + - ".github/workflows/ci-v3.yml" - "v2/**" + - "v3/**" jobs: build: @@ -18,13 +22,13 @@ jobs: steps: - name: set up go - uses: actions/setup-go@v3 + uses: actions/setup-go@5 with: go-version: "1.20" id: go - name: checkout - uses: actions/checkout@v3 + uses: actions/checkout@4 - name: build and test run: | @@ -32,7 +36,7 @@ jobs: go build -race - name: golangci-lint - uses: golangci/golangci-lint-action@v3 + uses: golangci/golangci-lint-action@v4 with: version: latest diff --git a/README.md b/README.md index ebf9371..4468452 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ either using LRC or LRU eviction. run cache.DeleteExpired periodically using [time.Ticker](https://golang.org/pkg/time/#Ticker), advisable period is 1/2 of TTL. -This cache is heavily inspired by [hashicorp/golang-lru](https://github.com/hashicorp/golang-lru) _simplelru_ implementation. Key differences are: +This cache is heavily inspired by [hashicorp/golang-lru](https://github.com/hashicorp/golang-lru) _simplelru_ implementation. v3 implements `simplelru.LRUCache` interface, so if you use a subset of functions, so you can use all functions except for that interchangeable and switch between this package and `github.com/hashicorp/golang-lru/v2/simplelru` or `github.com/hashicorp/golang-lru/v2/expirable` without any changes in your code. Key differences are: - Support LRC (Least Recently Created) in addition to LRU and TTL-based eviction - Supports per-key TTL setting diff --git a/v3/.golangci.yml b/v3/.golangci.yml new file mode 100644 index 0000000..3143c29 --- /dev/null +++ b/v3/.golangci.yml @@ -0,0 +1,44 @@ +linters-settings: + govet: + check-shadowing: true + gocyclo: + min-complexity: 15 + misspell: + locale: US + gocritic: + enabled-tags: + - performance + - style + - experimental + disabled-checks: + - wrapperFunc + +linters: + enable: + - gocritic + - megacheck + - revive + - govet + - unconvert + - megacheck + - gas + - gocyclo + - dupl + - misspell + - unused + - typecheck + - ineffassign + - stylecheck + - gochecknoinits + - exportloopref + - nakedret + - gosimple + - prealloc + fast: false + disable-all: true + +run: + output: + format: tab + skip-dirs: + - vendor \ No newline at end of file diff --git a/v3/cache.go b/v3/cache.go new file mode 100644 index 0000000..2927a3d --- /dev/null +++ b/v3/cache.go @@ -0,0 +1,372 @@ +// Package cache implements Cache similar to hashicorp/golang-lru +// +// Support LRC, LRU and TTL-based eviction. +// Package is thread-safe and doesn't spawn any goroutines. +// On every Set() call, cache deletes single oldest entry in case it's expired. +// In case MaxSize is set, cache deletes the oldest entry disregarding its expiration date to maintain the size, +// either using LRC or LRU eviction. +// In case of default TTL (10 years) and default MaxSize (0, unlimited) the cache will be truly unlimited +// and will never delete entries from itself automatically. +// +// Important: only reliable way of not having expired entries stuck in a cache is to +// run cache.DeleteExpired periodically using time.Ticker, advisable period is 1/2 of TTL. +package cache + +import ( + "container/list" + "fmt" + "sync" + "time" +) + +// 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 + GetOldest() (K, V, bool) + Contains(key K) (ok bool) + Peek(key K) (V, bool) + Values() []V + Keys() []K + Len() int + Remove(key K) bool + Invalidate(key K) + InvalidateFn(fn func(key K) bool) + RemoveOldest() (K, V, bool) + DeleteExpired() + Purge() + Resize(int) int + Stat() Stats +} + +// Stats provides statistics for cache +type Stats struct { + Hits, Misses int // cache effectiveness + Added, Evicted int // number of added and evicted records +} + +// cacheImpl provides Cache interface implementation. +type cacheImpl[K comparable, V any] struct { + ttl time.Duration + maxKeys int + isLRU bool + onEvicted func(key K, value V) + + sync.Mutex + stat Stats + items map[K]*list.Element + evictList *list.List +} + +// noEvictionTTL - very long ttl to prevent eviction +const noEvictionTTL = time.Hour * 24 * 365 * 10 + +// NewCache returns a new Cache. +// Default MaxKeys is unlimited (0). +// Default TTL is 10 years, sane value for expirable cache is 5 minutes. +// Default eviction mode is LRC, appropriate option allow to change it to LRU. +func NewCache[K comparable, V any]() Cache[K, V] { + return &cacheImpl[K, V]{ + items: map[K]*list.Element{}, + evictList: list.New(), + ttl: noEvictionTTL, + maxKeys: 0, + } +} + +// Add adds a value to the cache. 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]) Add(key K, value V) (evicted bool) { + return c.addWithTTL(key, value, c.ttl) +} + +// 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) +} + +// 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) { + c.Lock() + defer c.Unlock() + now := time.Now() + if ttl == 0 { + ttl = c.ttl + } + + // 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) + return false + } + + // Add new item + ent := &cacheItem[K, V]{key: key, value: value, expiresAt: now.Add(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 { + c.removeOldestIfExpired() + } + + evict := c.maxKeys > 0 && len(c.items) > c.maxKeys + // Verify size not exceeded + if evict { + c.removeOldest() + } + return evict +} + +// Get returns the key value if it's not expired +func (c *cacheImpl[K, V]) Get(key K) (V, bool) { + def := *new(V) + c.Lock() + defer c.Unlock() + if ent, ok := c.items[key]; ok { + // Expired item check + if time.Now().After(ent.Value.(*cacheItem[K, V]).expiresAt) { + c.stat.Misses++ + return ent.Value.(*cacheItem[K, V]).value, false + } + if c.isLRU { + c.evictList.MoveToFront(ent) + } + c.stat.Hits++ + return ent.Value.(*cacheItem[K, V]).value, true + } + c.stat.Misses++ + return def, false +} + +// Contains checks if a key is in the cache, without updating the recent-ness +// or deleting it for being stale. +func (c *cacheImpl[K, V]) Contains(key K) (ok bool) { + c.Lock() + defer c.Unlock() + _, ok = c.items[key] + return ok +} + +// Peek returns the key value (or undefined if not found) without updating the "recently used"-ness of the key. +// Works exactly the same as Get in case of LRC mode (default one). +func (c *cacheImpl[K, V]) Peek(key K) (V, bool) { + def := *new(V) + c.Lock() + defer c.Unlock() + if ent, ok := c.items[key]; ok { + // Expired item check + if time.Now().After(ent.Value.(*cacheItem[K, V]).expiresAt) { + c.stat.Misses++ + return ent.Value.(*cacheItem[K, V]).value, false + } + c.stat.Hits++ + return ent.Value.(*cacheItem[K, V]).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 { + c.Lock() + defer c.Unlock() + if ent, ok := c.items[key]; ok { + return ent.Value.(*cacheItem[K, V]).expiresAt + } + return time.Time{} +} + +// Keys returns a slice of the keys in the cache, from oldest to newest. +func (c *cacheImpl[K, V]) Keys() []K { + c.Lock() + defer c.Unlock() + return c.keys() +} + +// Values returns a slice of the values in the cache, from oldest to newest. +// Expired entries are filtered out. +func (c *cacheImpl[K, V]) Values() []V { + c.Lock() + defer c.Unlock() + values := make([]V, 0, len(c.items)) + now := time.Now() + for ent := c.evictList.Back(); ent != nil; ent = ent.Prev() { + if now.After(ent.Value.(*cacheItem[K, V]).expiresAt) { + continue + } + values = append(values, ent.Value.(*cacheItem[K, V]).value) + } + return values +} + +// Len return count of items in cache, including expired +func (c *cacheImpl[K, V]) Len() int { + c.Lock() + defer c.Unlock() + return c.evictList.Len() +} + +// Resize changes the cache size. Size of 0 means unlimited. +func (c *cacheImpl[K, V]) Resize(size int) int { + c.Lock() + defer c.Unlock() + if size <= 0 { + c.maxKeys = 0 + return 0 + } + diff := c.evictList.Len() - size + if diff < 0 { + diff = 0 + } + for i := 0; i < diff; i++ { + c.removeOldest() + } + c.maxKeys = size + return diff +} + +// Invalidate key (item) from the cache +func (c *cacheImpl[K, V]) Invalidate(key K) { + c.Lock() + defer c.Unlock() + if ent, ok := c.items[key]; ok { + c.removeElement(ent) + } + +} + +// InvalidateFn deletes multiple keys if predicate is true +func (c *cacheImpl[K, V]) InvalidateFn(fn func(key K) bool) { + c.Lock() + defer c.Unlock() + for key, ent := range c.items { + if fn(key) { + c.removeElement(ent) + } + } +} + +// Remove removes the provided key from the cache, returning if the +// key was contained. +func (c *cacheImpl[K, V]) Remove(key K) bool { + c.Lock() + defer c.Unlock() + if ent, ok := c.items[key]; ok { + c.removeElement(ent) + return true + } + return false +} + +// RemoveOldest remove the oldest element in the cache +func (c *cacheImpl[K, V]) RemoveOldest() (key K, value V, ok bool) { + c.Lock() + defer c.Unlock() + if ent := c.evictList.Back(); ent != nil { + c.removeElement(ent) + return ent.Value.(*cacheItem[K, V]).key, ent.Value.(*cacheItem[K, V]).value, true + } + return +} + +// GetOldest returns the oldest entry +func (c *cacheImpl[K, V]) GetOldest() (key K, value V, ok bool) { + c.Lock() + defer c.Unlock() + if ent := c.evictList.Back(); ent != nil { + return ent.Value.(*cacheItem[K, V]).key, ent.Value.(*cacheItem[K, V]).value, true + } + return +} + +// DeleteExpired clears cache of expired items +func (c *cacheImpl[K, V]) DeleteExpired() { + c.Lock() + defer c.Unlock() + for _, key := range c.keys() { + if time.Now().After(c.items[key].Value.(*cacheItem[K, V]).expiresAt) { + c.removeElement(c.items[key]) + } + } +} + +// Purge clears the cache completely. +func (c *cacheImpl[K, V]) Purge() { + c.Lock() + defer c.Unlock() + for k, v := range c.items { + delete(c.items, k) + c.stat.Evicted++ + if c.onEvicted != nil { + c.onEvicted(k, v.Value.(*cacheItem[K, V]).value) + } + } + c.evictList.Init() +} + +// Stat gets the current stats for cache +func (c *cacheImpl[K, V]) Stat() Stats { + c.Lock() + defer c.Unlock() + return c.stat +} + +func (c *cacheImpl[K, V]) String() string { + stats := c.Stat() + size := c.Len() + return fmt.Sprintf("Size: %d, Stats: %+v (%0.1f%%)", size, stats, 100*float64(stats.Hits)/float64(stats.Hits+stats.Misses)) +} + +// Keys returns a slice of the keys in the cache, from oldest to newest. Has to be called with lock! +func (c *cacheImpl[K, V]) keys() []K { + keys := make([]K, 0, len(c.items)) + for ent := c.evictList.Back(); ent != nil; ent = ent.Prev() { + keys = append(keys, ent.Value.(*cacheItem[K, V]).key) + } + return keys +} + +// removeOldest removes the oldest item from the cache. Has to be called with lock! +func (c *cacheImpl[K, V]) removeOldest() { + ent := c.evictList.Back() + if ent != nil { + c.removeElement(ent) + } +} + +// removeOldest removes the oldest item from the cache in case it's already expired. Has to be called with lock! +func (c *cacheImpl[K, V]) removeOldestIfExpired() { + ent := c.evictList.Back() + if ent != nil && time.Now().After(ent.Value.(*cacheItem[K, V]).expiresAt) { + c.removeElement(ent) + } +} + +// removeElement is used to remove a given list element from the cache. Has to be called with lock! +func (c *cacheImpl[K, V]) removeElement(e *list.Element) { + c.evictList.Remove(e) + kv := e.Value.(*cacheItem[K, V]) + delete(c.items, kv.key) + c.stat.Evicted++ + if c.onEvicted != nil { + c.onEvicted(kv.key, kv.value) + } +} + +// cacheItem is used to hold a value in the evictList +type cacheItem[K comparable, V any] struct { + expiresAt time.Time + key K + value V +} diff --git a/v3/cache_test.go b/v3/cache_test.go new file mode 100644 index 0000000..5439c63 --- /dev/null +++ b/v3/cache_test.go @@ -0,0 +1,384 @@ +package cache + +import ( + "crypto/rand" + "fmt" + "math" + "math/big" + "reflect" + "sync" + "testing" + "time" + + "github.com/hashicorp/golang-lru/v2/simplelru" + "github.com/stretchr/testify/assert" +) + +func getRand(tb testing.TB) int64 { + out, err := rand.Int(rand.Reader, big.NewInt(math.MaxInt64)) + if err != nil { + tb.Fatal(err) + } + return out.Int64() +} + +func BenchmarkLRU_Rand_NoExpire(b *testing.B) { + l := NewCache[int64, int64]().WithLRU().WithMaxKeys(8192) + + trace := make([]int64, b.N*2) + for i := 0; i < b.N*2; i++ { + trace[i] = getRand(b) % 32768 + } + + b.ResetTimer() + + var hit, miss int + for i := 0; i < 2*b.N; i++ { + if i%2 == 0 { + l.Add(trace[i], trace[i]) + } else { + if _, ok := l.Get(trace[i]); ok { + hit++ + } else { + miss++ + } + } + } + b.Logf("hit: %d miss: %d ratio: %f", hit, miss, float64(hit)/float64(hit+miss)) +} + +func BenchmarkLRU_Freq_NoExpire(b *testing.B) { + l := NewCache[int64, int64]().WithLRU().WithMaxKeys(8192) + + trace := make([]int64, b.N*2) + for i := 0; i < b.N*2; i++ { + if i%2 == 0 { + trace[i] = getRand(b) % 16384 + } else { + trace[i] = getRand(b) % 32768 + } + } + + b.ResetTimer() + + for i := 0; i < b.N; i++ { + l.Add(trace[i], trace[i]) + } + var hit, miss int + for i := 0; i < b.N; i++ { + if _, ok := l.Get(trace[i]); ok { + hit++ + } else { + miss++ + } + } + b.Logf("hit: %d miss: %d ratio: %f", hit, miss, float64(hit)/float64(hit+miss)) +} + +func BenchmarkLRU_Rand_WithExpire(b *testing.B) { + l := NewCache[int64, int64]().WithLRU().WithMaxKeys(8192).WithTTL(time.Millisecond * 10) + + trace := make([]int64, b.N*2) + for i := 0; i < b.N*2; i++ { + trace[i] = getRand(b) % 32768 + } + + b.ResetTimer() + + var hit, miss int + for i := 0; i < 2*b.N; i++ { + if i%2 == 0 { + l.Add(trace[i], trace[i]) + } else { + if _, ok := l.Get(trace[i]); ok { + hit++ + } else { + miss++ + } + } + } + b.Logf("hit: %d miss: %d ratio: %f", hit, miss, float64(hit)/float64(hit+miss)) +} + +func BenchmarkLRU_Freq_WithExpire(b *testing.B) { + l := NewCache[int64, int64]().WithLRU().WithMaxKeys(8192).WithTTL(time.Millisecond * 10) + + trace := make([]int64, b.N*2) + for i := 0; i < b.N*2; i++ { + if i%2 == 0 { + trace[i] = getRand(b) % 16384 + } else { + trace[i] = getRand(b) % 32768 + } + } + + b.ResetTimer() + + for i := 0; i < b.N; i++ { + l.Add(trace[i], trace[i]) + } + var hit, miss int + for i := 0; i < b.N; i++ { + if _, ok := l.Get(trace[i]); ok { + hit++ + } else { + miss++ + } + } + 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]() + + lc.Add("key1", "val1") + assert.Equal(t, 1, lc.Len()) + assert.True(t, lc.Contains("key1")) + assert.False(t, lc.Contains("key2")) + + v, ok := lc.Peek("key1") + assert.Equal(t, "val1", v) + assert.True(t, ok) + + v, ok = lc.Peek("key2") + assert.Empty(t, v) + assert.False(t, ok) + + assert.Equal(t, []string{"key1"}, lc.Keys()) +} + +func TestCacheWithDeleteExpired(t *testing.T) { + var evicted []string + lc := NewCache[string, string]().WithTTL(150 * time.Millisecond).WithOnEvicted( + func(key string, value string) { + evicted = append(evicted, key, value) + }) + + lc.Set("key1", "val1", 0) + + time.Sleep(100 * time.Millisecond) // not enough to expire + lc.DeleteExpired() + assert.Equal(t, 1, lc.Len()) + + v, ok := lc.Get("key1") + assert.Equal(t, "val1", v) + assert.True(t, ok) + + time.Sleep(200 * time.Millisecond) // expire + lc.DeleteExpired() + v, ok = lc.Get("key1") + assert.False(t, ok) + assert.Equal(t, "", v) + + assert.Equal(t, 0, lc.Len()) + assert.Equal(t, []string{"key1", "val1"}, evicted) + + // add new entry + lc.Set("key2", "val2", 0) + assert.Equal(t, 1, lc.Len()) + + // nothing deleted + lc.DeleteExpired() + assert.Equal(t, 1, lc.Len()) + assert.Equal(t, []string{"key1", "val1"}, evicted) + + // Purge, cache should be clean + lc.Purge() + assert.Equal(t, 0, lc.Len()) + assert.Equal(t, []string{"key1", "val1", "key2", "val2"}, evicted) +} + +func TestCache_Values(t *testing.T) { + lc := NewCache[string, string]().WithMaxKeys(3) + + lc.Add("key1", "val1") + lc.Add("key2", "val2") + lc.Add("key3", "val3") + + values := lc.Values() + if !reflect.DeepEqual(values, []string{"val1", "val2", "val3"}) { + t.Fatalf("values differs from expected") + } + + assert.Equal(t, 0, lc.Resize(0)) + assert.Equal(t, 1, lc.Resize(2)) + assert.Equal(t, 1, lc.Resize(1)) +} + +func TestCacheWithPurgeEnforcedBySize(t *testing.T) { + lc := NewCache[string, string]().WithTTL(time.Hour).WithMaxKeys(10) + + for i := 0; i < 100; i++ { + i := i + lc.Set(fmt.Sprintf("key%d", i), fmt.Sprintf("val%d", i), 0) + v, ok := lc.Get(fmt.Sprintf("key%d", i)) + assert.Equal(t, fmt.Sprintf("val%d", i), v) + assert.True(t, ok) + assert.True(t, lc.Len() < 20) + } + + assert.Equal(t, 10, lc.Len()) +} + +func TestCacheConcurrency(t *testing.T) { + lc := NewCache[string, string]() + wg := sync.WaitGroup{} + wg.Add(1000) + for i := 0; i < 1000; i++ { + go func(i int) { + lc.Set(fmt.Sprintf("key-%d", i/10), fmt.Sprintf("val-%d", i/10), 0) + wg.Done() + }(i) + } + wg.Wait() + assert.Equal(t, 100, lc.Len()) +} + +func TestCacheInvalidateAndEvict(t *testing.T) { + var evicted int + lc := NewCache[string, string]().WithLRU().WithOnEvicted(func(_ string, _ string) { evicted++ }) + + lc.Set("key1", "val1", 0) + lc.Set("key2", "val2", 0) + lc.Set("key3", "val3", 0) + + val, ok := lc.Get("key1") + assert.True(t, ok) + assert.Equal(t, "val1", val) + assert.Equal(t, 0, evicted) + + lc.Invalidate("key1") + assert.Equal(t, 1, evicted) + val, ok = lc.Get("key1") + assert.Empty(t, val) + assert.False(t, ok) + + val, ok = lc.Get("key2") + assert.True(t, ok) + assert.Equal(t, "val2", val) + + lc.InvalidateFn(func(key string) bool { + return key == "key2" + }) + assert.Equal(t, 2, evicted) + _, ok = lc.Get("key2") + assert.False(t, ok) + assert.Equal(t, 1, lc.Len()) + + assert.True(t, lc.Remove("key3")) + assert.Equal(t, 3, evicted) + val, ok = lc.Get("key3") + assert.Empty(t, val) + assert.False(t, ok) + assert.False(t, lc.Remove("key3")) + assert.Zero(t, lc.Len()) +} + +func TestCacheExpired(t *testing.T) { + lc := NewCache[string, string]().WithTTL(time.Millisecond * 5) + + lc.Set("key1", "val1", 0) + assert.Equal(t, 1, lc.Len()) + + v, ok := lc.Peek("key1") + assert.Equal(t, v, "val1") + assert.True(t, ok) + + v, ok = lc.Get("key1") + assert.Equal(t, v, "val1") + assert.True(t, ok) + + time.Sleep(time.Millisecond * 10) // wait for entry to expire + assert.Equal(t, 1, lc.Len()) // but not purged + + v, ok = lc.Peek("key1") + assert.Equal(t, "val1", v, "expired and marked as such, but value is available") + assert.False(t, ok) + + v, ok = lc.Get("key1") + assert.Equal(t, "val1", v, "expired and marked as such, but value is available") + assert.False(t, ok) +} + +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 := lc.GetExpiration("key1") + 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 = lc.GetExpiration("key2") + assert.True(t, exp.After(time.Now().Add(time.Second*9))) + assert.True(t, exp.Before(time.Now().Add(time.Second*11))) + + exp = lc.GetExpiration("non-existing-key") + assert.Zero(t, exp) +} + +func TestCacheRemoveOldest(t *testing.T) { + lc := NewCache[string, string]().WithLRU().WithMaxKeys(2) + + lc.Set("key1", "val1", 0) + assert.Equal(t, 1, lc.Len()) + + v, ok := lc.Get("key1") + assert.True(t, ok) + assert.Equal(t, "val1", v) + + assert.Equal(t, []string{"key1"}, lc.Keys()) + assert.Equal(t, 1, lc.Len()) + + lc.Set("key2", "val2", 0) + assert.Equal(t, []string{"key1", "key2"}, lc.Keys()) + assert.Equal(t, 2, lc.Len()) + + lc.RemoveOldest() + + assert.Equal(t, []string{"key2"}, lc.Keys()) + assert.Equal(t, 1, lc.Len()) +} + +func ExampleCache() { + // make cache with short TTL and 3 max keys + cache := NewCache[string, string]().WithMaxKeys(3).WithTTL(time.Millisecond * 10) + + // set value under key1. + // with 0 ttl (last parameter) will use cache-wide setting instead (10ms). + cache.Set("key1", "val1", 0) + + // get value under key1 + r, ok := cache.Get("key1") + + // check for OK value, because otherwise return would be nil and + // type conversion will panic + if ok { + fmt.Printf("value before expiration is found: %v, value: %q\n", ok, r) + } + + time.Sleep(time.Millisecond * 11) + + // get value under key1 after key expiration + r, ok = cache.Get("key1") + // don't convert to string as with ok == false value would be nil + fmt.Printf("value after expiration is found: %v, value: %q\n", ok, r) + + // set value under key2, would evict old entry because it is already expired. + // ttl (last parameter) overrides cache-wide ttl. + cache.Set("key2", "val2", time.Minute*5) + + fmt.Printf("%+v\n", cache) + // Output: + // value before expiration is found: true, value: "val1" + // value after expiration is found: false, value: "val1" + // Size: 1, Stats: {Hits:1 Misses:1 Added:2 Evicted:1} (50.0%) +} diff --git a/v3/go.mod b/v3/go.mod new file mode 100644 index 0000000..fad54a3 --- /dev/null +++ b/v3/go.mod @@ -0,0 +1,12 @@ +module github.com/go-pkgz/expirable-cache/v3 + +go 1.20 + +require github.com/stretchr/testify v1.8.4 + +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 new file mode 100644 index 0000000..e22664c --- /dev/null +++ b/v3/go.sum @@ -0,0 +1,12 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +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= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/v3/options.go b/v3/options.go new file mode 100644 index 0000000..b5fe65d --- /dev/null +++ b/v3/options.go @@ -0,0 +1,36 @@ +package cache + +import "time" + +type options[K comparable, V any] interface { + WithTTL(ttl time.Duration) Cache[K, V] + WithMaxKeys(maxKeys int) Cache[K, V] + WithLRU() Cache[K, V] + WithOnEvicted(fn func(key K, value V)) Cache[K, V] +} + +// 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 + return c +} + +// WithMaxKeys functional option defines how many keys to keep. +// By default, it is 0, which means unlimited. +func (c *cacheImpl[K, V]) WithMaxKeys(maxKeys int) Cache[K, V] { + c.maxKeys = maxKeys + return c +} + +// WithLRU sets cache to LRU (Least Recently Used) eviction mode. +func (c *cacheImpl[K, V]) WithLRU() Cache[K, V] { + c.isLRU = true + return c +} + +// WithOnEvicted defined function which would be called automatically for automatically and manually deleted entries +func (c *cacheImpl[K, V]) WithOnEvicted(fn func(key K, value V)) Cache[K, V] { + c.onEvicted = fn + return c +}