From 738ef572f76f388f3ff93d1f629f22caf12a43e5 Mon Sep 17 00:00:00 2001 From: Dmitry Verkhoturov Date: Sun, 20 Nov 2022 22:17:43 +0100 Subject: [PATCH 1/7] add expirable LRU implementation Thread-safe. It could be used in place of simplelru.LRU but shouldn't as it has built-in locks, whereas simplelru.LRU doesn't, which allows more effective locking on top of it in top-level package cache implementations. --- README.md | 67 ++++- simplelru/expirable_lru.go | 265 +++++++++++++++++ simplelru/expirable_lru_test.go | 484 ++++++++++++++++++++++++++++++++ simplelru/list.go | 15 +- simplelru/lru.go | 6 +- 5 files changed, 820 insertions(+), 17 deletions(-) create mode 100644 simplelru/expirable_lru.go create mode 100644 simplelru/expirable_lru_test.go diff --git a/README.md b/README.md index b60785f..946379a 100644 --- a/README.md +++ b/README.md @@ -9,10 +9,8 @@ Documentation Full docs are available on [Go Packages](https://pkg.go.dev/github.com/hashicorp/golang-lru/v2) -Example -======= - -Using the LRU is very simple: +LRU cache example +================= ```go package main @@ -23,12 +21,59 @@ import ( ) func main() { - l, _ := lru.New[int, any](128) - for i := 0; i < 256; i++ { - l.Add(i, nil) - } - if l.Len() != 128 { - panic(fmt.Sprintf("bad len: %v", l.Len())) - } + l, _ := lru.New[int, any](128) + for i := 0; i < 256; i++ { + l.Add(i, nil) + } + if l.Len() != 128 { + panic(fmt.Sprintf("bad len: %v", l.Len())) + } +} +``` + +Expirable LRU cache example +=========================== + +```go +package main + +import ( + "fmt" + "time" + + "github.com/hashicorp/golang-lru/v2/simplelru" +) + +func main() { + // make cache with short TTL and 3 max keys, purgeEvery time.Millisecond * 10 + cache := simplelru.NewExpirableLRU[string, string](3, nil, time.Millisecond*5, time.Millisecond*10) + // expirable cache need to be closed after used + defer cache.Close() + + // set value under key1. + cache.Add("key1", "val1") + + // get value under key1 + r, ok := cache.Get("key1") + + // check for OK value + 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") + 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. + cache.Add("key2", "val2") + + fmt.Printf("Cache len: %d", cache.Len()) + // Output: + // value before expiration is found: true, value: "val1" + // value after expiration is found: false, value: "" + // Cache len: 1 } ``` diff --git a/simplelru/expirable_lru.go b/simplelru/expirable_lru.go new file mode 100644 index 0000000..dd8ded7 --- /dev/null +++ b/simplelru/expirable_lru.go @@ -0,0 +1,265 @@ +package simplelru + +import ( + "sync" + "time" +) + +// ExpirableLRU implements a thread-safe LRU with expirable entries. +type ExpirableLRU[K comparable, V any] struct { + size int + evictList *lruList[K, V] + items map[K]*entry[K, V] + onEvict EvictCallback[K, V] + + // expirable options + mu sync.Mutex + purgeEvery time.Duration + ttl time.Duration + done chan struct{} +} + +// noEvictionTTL - very long ttl to prevent eviction +const noEvictionTTL = time.Hour * 24 * 365 * 10 +const defaultPurgeEvery = time.Minute * 5 + +// NewExpirableLRU returns a new thread-safe cache with expirable entries. +// +// Size parameter set to 0 makes cache of unlimited size, e.g. turns LRU mechanism off. +// +// Providing 0 TTL turns expiring off. +// +// Activates deleteExpired by purgeEvery duration. +// If MaxKeys and TTL are defined and PurgeEvery is zero, PurgeEvery will be set to 5 minutes. +func NewExpirableLRU[K comparable, V any](size int, onEvict EvictCallback[K, V], ttl, purgeEvery time.Duration) *ExpirableLRU[K, V] { + if size < 0 { + size = 0 + } + if ttl <= 0 { + ttl = noEvictionTTL + } + + res := ExpirableLRU[K, V]{ + items: make(map[K]*entry[K, V]), + evictList: newList[K, V](), + ttl: ttl, + purgeEvery: purgeEvery, + size: size, + onEvict: onEvict, + done: make(chan struct{}), + } + + // enable deleteExpired() running in separate goroutine for cache + // with non-zero TTL and size defined + if res.ttl != noEvictionTTL && (res.size > 0 || res.purgeEvery > 0) { + if res.purgeEvery <= 0 { + res.purgeEvery = defaultPurgeEvery // non-zero purge enforced because size is defined + } + go func(done <-chan struct{}) { + ticker := time.NewTicker(res.purgeEvery) + for { + select { + case <-done: + return + case <-ticker.C: + res.mu.Lock() + res.deleteExpired() + res.mu.Unlock() + } + } + }(res.done) + } + return &res +} + +// Purge clears the cache completely. +// onEvict is called for each evicted key. +func (c *ExpirableLRU[K, V]) Purge() { + c.mu.Lock() + defer c.mu.Unlock() + for k, v := range c.items { + if c.onEvict != nil { + c.onEvict(k, v.value) + } + delete(c.items, k) + } + c.evictList.init() +} + +// 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 *ExpirableLRU[K, V]) Add(key K, value V) (evicted bool) { + c.mu.Lock() + defer c.mu.Unlock() + now := time.Now() + + // Check for existing item + if ent, ok := c.items[key]; ok { + c.evictList.moveToFront(ent) + ent.value = value + ent.expiresAt = now.Add(c.ttl) + return false + } + + // Add new item + c.items[key] = c.evictList.pushFront(key, value, now.Add(c.ttl)) + + // Verify size not exceeded + if c.size > 0 && len(c.items) > c.size { + c.removeOldest() + return true + } + return false +} + +// Get looks up a key's value from the cache. +func (c *ExpirableLRU[K, V]) Get(key K) (value V, ok bool) { + c.mu.Lock() + defer c.mu.Unlock() + if ent, found := c.items[key]; found { + // Expired item check + if time.Now().After(ent.expiresAt) { + return + } + c.evictList.moveToFront(ent) + return ent.value, true + } + return +} + +// Contains checks if a key is in the cache, without updating the recent-ness +// or deleting it for being stale. +func (c *ExpirableLRU[K, V]) Contains(key K) (ok bool) { + c.mu.Lock() + defer c.mu.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. +func (c *ExpirableLRU[K, V]) Peek(key K) (value V, ok bool) { + c.mu.Lock() + defer c.mu.Unlock() + if ent, found := c.items[key]; found { + // Expired item check + if time.Now().After(ent.expiresAt) { + return + } + return ent.value, true + } + return +} + +// Remove removes the provided key from the cache, returning if the +// key was contained. +func (c *ExpirableLRU[K, V]) Remove(key K) bool { + c.mu.Lock() + defer c.mu.Unlock() + if ent, ok := c.items[key]; ok { + c.removeElement(ent) + return true + } + return false +} + +// RemoveOldest removes the oldest item from the cache. +func (c *ExpirableLRU[K, V]) RemoveOldest() (key K, value V, ok bool) { + c.mu.Lock() + defer c.mu.Unlock() + if ent := c.evictList.back(); ent != nil { + c.removeElement(ent) + return ent.key, ent.value, true + } + return +} + +// GetOldest returns the oldest entry +func (c *ExpirableLRU[K, V]) GetOldest() (key K, value V, ok bool) { + c.mu.Lock() + defer c.mu.Unlock() + if ent := c.evictList.back(); ent != nil { + return ent.key, ent.value, true + } + return +} + +// Keys returns a slice of the keys in the cache, from oldest to newest. +func (c *ExpirableLRU[K, V]) Keys() []K { + c.mu.Lock() + defer c.mu.Unlock() + return c.keys() +} + +// Len returns the number of items in the cache. +func (c *ExpirableLRU[K, V]) Len() int { + c.mu.Lock() + defer c.mu.Unlock() + return c.evictList.length() +} + +// Resize changes the cache size. Size of 0 doesn't resize the cache, as it means unlimited. +func (c *ExpirableLRU[K, V]) Resize(size int) (evicted int) { + if size <= 0 { + c.size = 0 + return 0 + } + c.mu.Lock() + defer c.mu.Unlock() + diff := c.evictList.length() - size + if diff < 0 { + diff = 0 + } + for i := 0; i < diff; i++ { + c.removeOldest() + } + c.size = size + return diff +} + +// Close cleans the cache and destroys running goroutines +func (c *ExpirableLRU[K, V]) Close() { + c.mu.Lock() + defer c.mu.Unlock() + select { + case <-c.done: + return + default: + } + close(c.done) +} + +// removeOldest removes the oldest item from the cache. Has to be called with lock! +func (c *ExpirableLRU[K, V]) removeOldest() { + if ent := c.evictList.back(); ent != nil { + c.removeElement(ent) + } +} + +// removeElement is used to remove a given list element from the cache. Has to be called with lock! +func (c *ExpirableLRU[K, V]) removeElement(e *entry[K, V]) { + c.evictList.remove(e) + delete(c.items, e.key) + if c.onEvict != nil { + c.onEvict(e.key, e.value) + } +} + +// deleteExpired deletes expired records. Has to be called with lock! +func (c *ExpirableLRU[K, V]) deleteExpired() { + for _, key := range c.keys() { + if time.Now().After(c.items[key].expiresAt) { + c.removeElement(c.items[key]) + } + } +} + +// keys returns a slice of the keys in the cache, from oldest to newest. Has to be called with lock! +func (c *ExpirableLRU[K, V]) keys() []K { + keys := make([]K, 0, len(c.items)) + for ent := c.evictList.back(); ent != nil; ent = ent.prevEntry() { + keys = append(keys, ent.key) + } + return keys +} diff --git a/simplelru/expirable_lru_test.go b/simplelru/expirable_lru_test.go new file mode 100644 index 0000000..48845b0 --- /dev/null +++ b/simplelru/expirable_lru_test.go @@ -0,0 +1,484 @@ +package simplelru + +import ( + "crypto/rand" + "fmt" + "math" + "math/big" + "reflect" + "sync" + "testing" + "time" +) + +func BenchmarkExpirableLRU_Rand_NoExpire(b *testing.B) { + l := NewExpirableLRU[int64, int64](8192, nil, 0, 0) + + 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 BenchmarkExpirableLRU_Freq_NoExpire(b *testing.B) { + l := NewExpirableLRU[int64, int64](8192, nil, 0, 0) + + 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 BenchmarkExpirableLRU_Rand_WithExpire(b *testing.B) { + l := NewExpirableLRU[int64, int64](8192, nil, time.Millisecond*10, time.Millisecond*50) + + 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 BenchmarkExpirableLRU_Freq_WithExpire(b *testing.B) { + l := NewExpirableLRU[int64, int64](8192, nil, time.Millisecond*10, time.Millisecond*50) + + 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 TestExpirableLRUInterface(t *testing.T) { + var _ LRUCache[int, int] = &ExpirableLRU[int, int]{} +} + +func TestExpirableLRUNoPurge(t *testing.T) { + lc := NewExpirableLRU[string, string](10, nil, 0, 0) + + lc.Add("key1", "val1") + if lc.Len() != 1 { + t.Fatalf("length differs from expected") + } + + v, ok := lc.Peek("key1") + if v != "val1" { + t.Fatalf("value differs from expected") + } + if !ok { + t.Fatalf("should be true") + } + + if !lc.Contains("key1") { + t.Fatalf("should contain key1") + } + if lc.Contains("key2") { + t.Fatalf("should not contain key2") + } + + v, ok = lc.Peek("key2") + if v != "" { + t.Fatalf("should be empty") + } + if ok { + t.Fatalf("should be false") + } + + if !reflect.DeepEqual(lc.Keys(), []string{"key1"}) { + t.Fatalf("value differs from expected") + } + + if lc.Resize(0) != 0 { + t.Fatalf("evicted count differs from expected") + } + if lc.Resize(2) != 0 { + t.Fatalf("evicted count differs from expected") + } + lc.Add("key2", "val2") + if lc.Resize(1) != 1 { + t.Fatalf("evicted count differs from expected") + } +} + +func TestExpirableMultipleClose(t *testing.T) { + lc := NewExpirableLRU[string, string](10, nil, 0, 0) + lc.Close() + // should not panic + lc.Close() +} + +func TestExpirableLRUWithPurge(t *testing.T) { + var evicted []string + lc := NewExpirableLRU(10, func(key string, value string) { evicted = append(evicted, key, value) }, 150*time.Millisecond, time.Millisecond*100) + defer lc.Close() + + k, v, ok := lc.GetOldest() + if k != "" { + t.Fatalf("should be empty") + } + if v != "" { + t.Fatalf("should be empty") + } + if ok { + t.Fatalf("should be false") + } + + lc.Add("key1", "val1") + + time.Sleep(100 * time.Millisecond) // not enough to expire + if lc.Len() != 1 { + t.Fatalf("length differs from expected") + } + + v, ok = lc.Get("key1") + if v != "val1" { + t.Fatalf("value differs from expected") + } + if !ok { + t.Fatalf("should be true") + } + + time.Sleep(200 * time.Millisecond) // expire + v, ok = lc.Get("key1") + if ok { + t.Fatalf("should be false") + } + if v != "" { + t.Fatalf("should be nil") + } + + if lc.Len() != 0 { + t.Fatalf("length differs from expected") + } + if !reflect.DeepEqual(evicted, []string{"key1", "val1"}) { + t.Fatalf("value differs from expected") + } + + // add new entry + lc.Add("key2", "val2") + if lc.Len() != 1 { + t.Fatalf("length differs from expected") + } + + k, v, ok = lc.GetOldest() + if k != "key2" { + t.Fatalf("value differs from expected") + } + if v != "val2" { + t.Fatalf("value differs from expected") + } + if !ok { + t.Fatalf("should be true") + } + + // DeleteExpired, nothing deleted + lc.deleteExpired() + if lc.Len() != 1 { + t.Fatalf("length differs from expected") + } + if !reflect.DeepEqual(evicted, []string{"key1", "val1"}) { + t.Fatalf("value differs from expected") + } + + // Purge, cache should be clean + lc.Purge() + if lc.Len() != 0 { + t.Fatalf("length differs from expected") + } + if !reflect.DeepEqual(evicted, []string{"key1", "val1", "key2", "val2"}) { + t.Fatalf("value differs from expected") + } +} + +func TestExpirableLRUWithPurgeEnforcedBySize(t *testing.T) { + lc := NewExpirableLRU[string, string](10, nil, time.Hour, 0) + defer lc.Close() + + for i := 0; i < 100; i++ { + i := i + lc.Add(fmt.Sprintf("key%d", i), fmt.Sprintf("val%d", i)) + v, ok := lc.Get(fmt.Sprintf("key%d", i)) + if v != fmt.Sprintf("val%d", i) { + t.Fatalf("value differs from expected") + } + if !ok { + t.Fatalf("should be true") + } + if lc.Len() > 20 { + t.Fatalf("length should be less than 20") + } + } + + if lc.Len() != 10 { + t.Fatalf("length differs from expected") + } +} + +func TestExpirableLRUConcurrency(t *testing.T) { + lc := NewExpirableLRU[string, string](0, nil, 0, 0) + wg := sync.WaitGroup{} + wg.Add(1000) + for i := 0; i < 1000; i++ { + go func(i int) { + lc.Add(fmt.Sprintf("key-%d", i/10), fmt.Sprintf("val-%d", i/10)) + wg.Done() + }(i) + } + wg.Wait() + if lc.Len() != 100 { + t.Fatalf("length differs from expected") + } +} + +func TestExpirableLRUInvalidateAndEvict(t *testing.T) { + var evicted int + lc := NewExpirableLRU(-1, func(_, _ string) { evicted++ }, 0, 0) + + lc.Add("key1", "val1") + lc.Add("key2", "val2") + + val, ok := lc.Get("key1") + if !ok { + t.Fatalf("should be true") + } + if val != "val1" { + t.Fatalf("value differs from expected") + } + if evicted != 0 { + t.Fatalf("value differs from expected") + } + + lc.Remove("key1") + if evicted != 1 { + t.Fatalf("value differs from expected") + } + val, ok = lc.Get("key1") + if val != "" { + t.Fatalf("should be empty") + } + if ok { + t.Fatalf("should be false") + } +} + +func TestLoadingExpired(t *testing.T) { + lc := NewExpirableLRU[string, string](0, nil, time.Millisecond*5, 0) + + lc.Add("key1", "val1") + if lc.Len() != 1 { + t.Fatalf("length differs from expected") + } + + v, ok := lc.Peek("key1") + if v != "val1" { + t.Fatalf("value differs from expected") + } + if !ok { + t.Fatalf("should be true") + } + + v, ok = lc.Get("key1") + if v != "val1" { + t.Fatalf("value differs from expected") + } + if !ok { + t.Fatalf("should be true") + } + + time.Sleep(time.Millisecond * 10) // wait for entry to expire + if lc.Len() != 1 { + t.Fatalf("length differs from expected") + } // but not purged + + v, ok = lc.Peek("key1") + if v != "" { + t.Fatalf("should be empty") + } + if ok { + t.Fatalf("should be false") + } + + v, ok = lc.Get("key1") + if v != "" { + t.Fatalf("should be empty") + } + if ok { + t.Fatalf("should be false") + } +} + +func TestExpirableLRURemoveOldest(t *testing.T) { + lc := NewExpirableLRU[string, string](2, nil, 0, 0) + + k, v, ok := lc.RemoveOldest() + if k != "" { + t.Fatalf("should be empty") + } + if v != "" { + t.Fatalf("should be empty") + } + if ok { + t.Fatalf("should be false") + } + + ok = lc.Remove("non_existent") + if ok { + t.Fatalf("should be false") + } + + lc.Add("key1", "val1") + if lc.Len() != 1 { + t.Fatalf("length differs from expected") + } + + v, ok = lc.Get("key1") + if !ok { + t.Fatalf("should be true") + } + if v != "val1" { + t.Fatalf("value differs from expected") + } + + if !reflect.DeepEqual(lc.Keys(), []string{"key1"}) { + t.Fatalf("value differs from expected") + } + if lc.Len() != 1 { + t.Fatalf("length differs from expected") + } + + lc.Add("key2", "val2") + if !reflect.DeepEqual(lc.Keys(), []string{"key1", "key2"}) { + t.Fatalf("value differs from expected") + } + if lc.Len() != 2 { + t.Fatalf("length differs from expected") + } + + k, v, ok = lc.RemoveOldest() + if k != "key1" { + t.Fatalf("value differs from expected") + } + if v != "val1" { + t.Fatalf("value differs from expected") + } + if !ok { + t.Fatalf("should be true") + } + + if !reflect.DeepEqual(lc.Keys(), []string{"key2"}) { + t.Fatalf("value differs from expected") + } + if lc.Len() != 1 { + t.Fatalf("length differs from expected") + } +} + +func ExampleExpirableLRU() { + // make cache with 5ms TTL and 3 max keys, purge every 10ms + cache := NewExpirableLRU[string, string](3, nil, time.Millisecond*5, time.Millisecond*10) + // expirable cache need to be closed after used + defer cache.Close() + + // set value under key1. + cache.Add("key1", "val1") + + // get value under key1 + r, ok := cache.Get("key1") + + // check for OK value + if ok { + fmt.Printf("value before expiration is found: %v, value: %q\n", ok, r) + } + + // wait for cache to expire + time.Sleep(time.Millisecond * 16) + + // get value under key1 after key expiration + r, ok = cache.Get("key1") + 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. + cache.Add("key2", "val2") + + fmt.Printf("Cache len: %d\n", cache.Len()) + // Output: + // value before expiration is found: true, value: "val1" + // value after expiration is found: false, value: "" + // Cache len: 1 +} + +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() +} diff --git a/simplelru/list.go b/simplelru/list.go index c39da3c..132c3f2 100644 --- a/simplelru/list.go +++ b/simplelru/list.go @@ -4,6 +4,8 @@ package simplelru +import "time" + // entry is an LRU entry type entry[K comparable, V any] struct { // Next and previous pointers in the doubly-linked list of elements. @@ -21,6 +23,9 @@ type entry[K comparable, V any] struct { // The value stored with this element. value V + + // The time this element would be cleaned up + expiresAt time.Time } // prevEntry returns the previous list element or nil. @@ -79,9 +84,9 @@ func (l *lruList[K, V]) insert(e, at *entry[K, V]) *entry[K, V] { return e } -// insertValue is a convenience wrapper for insert(&Element{Value: v}, at). -func (l *lruList[K, V]) insertValue(k K, v V, at *entry[K, V]) *entry[K, V] { - return l.insert(&entry[K, V]{value: v, key: k}, at) +// insertValue is a convenience wrapper for insert(&entry{value: v, expiresAt: expiresAt}, at). +func (l *lruList[K, V]) insertValue(k K, v V, expiresAt time.Time, at *entry[K, V]) *entry[K, V] { + return l.insert(&entry[K, V]{value: v, key: k, expiresAt: expiresAt}, at) } // remove removes e from its list, decrements l.len @@ -111,9 +116,9 @@ func (l *lruList[K, V]) move(e, at *entry[K, V]) { } // pushFront inserts a new element e with value v at the front of list l and returns e. -func (l *lruList[K, V]) pushFront(k K, v V) *entry[K, V] { +func (l *lruList[K, V]) pushFront(k K, v V, expiresAt time.Time) *entry[K, V] { l.lazyInit() - return l.insertValue(k, v, &l.root) + return l.insertValue(k, v, expiresAt, &l.root) } // moveToFront moves element e to the front of list l. diff --git a/simplelru/lru.go b/simplelru/lru.go index b165ea2..da77c80 100644 --- a/simplelru/lru.go +++ b/simplelru/lru.go @@ -5,6 +5,7 @@ package simplelru import ( "errors" + "time" ) // EvictCallback is used to get a callback when a cache entry is evicted @@ -57,7 +58,7 @@ func (c *LRU[K, V]) Add(key K, value V) (evicted bool) { } // Add new item - ent := c.evictList.pushFront(key, value) + ent := c.evictList.pushFront(key, value, time.Time{}) c.items[key] = ent evict := c.evictList.length() > c.size @@ -161,6 +162,9 @@ func (c *LRU[K, V]) Resize(size int) (evicted int) { return diff } +// Close does nothing for this type of cache. +func (c *LRU[K, V]) Close() {} + // removeOldest removes the oldest item from the cache. func (c *LRU[K, V]) removeOldest() { if ent := c.evictList.back(); ent != nil { From cd39ba41ed97414bbebe8e4d52887626cdb49d4c Mon Sep 17 00:00:00 2001 From: Dmitry Verkhoturov Date: Sun, 23 Apr 2023 01:31:25 +0200 Subject: [PATCH 2/7] switch expirable LRU from purgeEvery to 1/100th of TTL buckets That sets the memory overhead to approximately 1% mark at the cost of expiring the cache entries up to 1% faster than their TTL expires. Previously, all entries were scanned for expiration by purgeEvery interval, which created computation overhead, and after this commit, we delete entries we want to delete without checking extra ones. --- README.md | 9 ++- simplelru/expirable_lru.go | 125 +++++++++++++++++++++----------- simplelru/expirable_lru_test.go | 37 +++++----- simplelru/list.go | 13 +++- simplelru/lru.go | 3 +- 5 files changed, 118 insertions(+), 69 deletions(-) diff --git a/README.md b/README.md index 946379a..26c7a3f 100644 --- a/README.md +++ b/README.md @@ -45,8 +45,8 @@ import ( ) func main() { - // make cache with short TTL and 3 max keys, purgeEvery time.Millisecond * 10 - cache := simplelru.NewExpirableLRU[string, string](3, nil, time.Millisecond*5, time.Millisecond*10) + // make cache with 10ms TTL and 5 max keys + cache := simplelru.NewExpirableLRU[string, string](5, nil, time.Millisecond*10) // expirable cache need to be closed after used defer cache.Close() @@ -61,7 +61,8 @@ func main() { fmt.Printf("value before expiration is found: %v, value: %q\n", ok, r) } - time.Sleep(time.Millisecond * 11) + // wait for cache to expire + time.Sleep(time.Millisecond * 12) // get value under key1 after key expiration r, ok = cache.Get("key1") @@ -70,7 +71,7 @@ func main() { // set value under key2, would evict old entry because it is already expired. cache.Add("key2", "val2") - fmt.Printf("Cache len: %d", cache.Len()) + fmt.Printf("Cache len: %d\n", cache.Len()) // Output: // value before expiration is found: true, value: "val1" // value after expiration is found: false, value: "" diff --git a/simplelru/expirable_lru.go b/simplelru/expirable_lru.go index dd8ded7..8c0e04a 100644 --- a/simplelru/expirable_lru.go +++ b/simplelru/expirable_lru.go @@ -6,6 +6,10 @@ import ( ) // ExpirableLRU implements a thread-safe LRU with expirable entries. +// +// Entries can be cleaned up from cache with up to 1% of ttl unused. +// It happens because cleanup mechanism puts them 99 cleanup buckets away +// from the current moment,and then cleans them up 99% of ttl later instead of 100%. type ExpirableLRU[K comparable, V any] struct { size int evictList *lruList[K, V] @@ -13,15 +17,26 @@ type ExpirableLRU[K comparable, V any] struct { onEvict EvictCallback[K, V] // expirable options - mu sync.Mutex - purgeEvery time.Duration - ttl time.Duration - done chan struct{} + mu sync.Mutex + ttl time.Duration + done chan struct{} + + // buckets for expiration + buckets []bucket[K, V] + // uint8 because it's number between 0 and numBuckets + nextCleanupBucket uint8 +} + +// bucket is a container for holding entries to be expired +type bucket[K comparable, V any] struct { + entries map[K]*entry[K, V] } // noEvictionTTL - very long ttl to prevent eviction const noEvictionTTL = time.Hour * 24 * 365 * 10 -const defaultPurgeEvery = time.Minute * 5 + +// because of uint8 usage for nextCleanupBucket, should not exceed 256. +const numBuckets = 100 // NewExpirableLRU returns a new thread-safe cache with expirable entries. // @@ -29,9 +44,8 @@ const defaultPurgeEvery = time.Minute * 5 // // Providing 0 TTL turns expiring off. // -// Activates deleteExpired by purgeEvery duration. -// If MaxKeys and TTL are defined and PurgeEvery is zero, PurgeEvery will be set to 5 minutes. -func NewExpirableLRU[K comparable, V any](size int, onEvict EvictCallback[K, V], ttl, purgeEvery time.Duration) *ExpirableLRU[K, V] { +// Delete expired entries every 1/100th of ttl value. +func NewExpirableLRU[K comparable, V any](size int, onEvict EvictCallback[K, V], ttl time.Duration) *ExpirableLRU[K, V] { if size < 0 { size = 0 } @@ -40,23 +54,25 @@ func NewExpirableLRU[K comparable, V any](size int, onEvict EvictCallback[K, V], } res := ExpirableLRU[K, V]{ - items: make(map[K]*entry[K, V]), - evictList: newList[K, V](), - ttl: ttl, - purgeEvery: purgeEvery, - size: size, - onEvict: onEvict, - done: make(chan struct{}), + ttl: ttl, + size: size, + evictList: newList[K, V](), + items: make(map[K]*entry[K, V]), + onEvict: onEvict, + done: make(chan struct{}), + } + + // initialize the buckets + res.buckets = make([]bucket[K, V], numBuckets) + for i := 0; i < numBuckets; i++ { + res.buckets[i] = bucket[K, V]{entries: make(map[K]*entry[K, V])} } // enable deleteExpired() running in separate goroutine for cache - // with non-zero TTL and size defined - if res.ttl != noEvictionTTL && (res.size > 0 || res.purgeEvery > 0) { - if res.purgeEvery <= 0 { - res.purgeEvery = defaultPurgeEvery // non-zero purge enforced because size is defined - } + // with non-zero TTL + if res.ttl != noEvictionTTL { go func(done <-chan struct{}) { - ticker := time.NewTicker(res.purgeEvery) + ticker := time.NewTicker(res.ttl / numBuckets) for { select { case <-done: @@ -83,6 +99,11 @@ func (c *ExpirableLRU[K, V]) Purge() { } delete(c.items, k) } + for _, b := range c.buckets { + for _, ent := range b.entries { + delete(b.entries, ent.key) + } + } c.evictList.init() } @@ -97,27 +118,32 @@ func (c *ExpirableLRU[K, V]) Add(key K, value V) (evicted bool) { // Check for existing item if ent, ok := c.items[key]; ok { c.evictList.moveToFront(ent) + c.removeFromBucket(ent) // remove the entry from its current bucket as expiresAt is renewed ent.value = value ent.expiresAt = now.Add(c.ttl) + c.addToBucket(ent) return false } // Add new item - c.items[key] = c.evictList.pushFront(key, value, now.Add(c.ttl)) + ent := c.evictList.pushFrontExpirable(key, value, now.Add(c.ttl)) + c.items[key] = ent + c.addToBucket(ent) // adds the entry to the appropriate bucket and sets entry.expireBucket + evict := c.size > 0 && c.evictList.length() > c.size // Verify size not exceeded - if c.size > 0 && len(c.items) > c.size { + if evict { c.removeOldest() - return true } - return false + return evict } // Get looks up a key's value from the cache. func (c *ExpirableLRU[K, V]) Get(key K) (value V, ok bool) { c.mu.Lock() defer c.mu.Unlock() - if ent, found := c.items[key]; found { + var ent *entry[K, V] + if ent, ok = c.items[key]; ok { // Expired item check if time.Now().After(ent.expiresAt) { return @@ -142,7 +168,8 @@ func (c *ExpirableLRU[K, V]) Contains(key K) (ok bool) { func (c *ExpirableLRU[K, V]) Peek(key K) (value V, ok bool) { c.mu.Lock() defer c.mu.Unlock() - if ent, found := c.items[key]; found { + var ent *entry[K, V] + if ent, ok = c.items[key]; ok { // Expired item check if time.Now().After(ent.expiresAt) { return @@ -189,7 +216,11 @@ func (c *ExpirableLRU[K, V]) GetOldest() (key K, value V, ok bool) { func (c *ExpirableLRU[K, V]) Keys() []K { c.mu.Lock() defer c.mu.Unlock() - return c.keys() + keys := make([]K, 0, len(c.items)) + for ent := c.evictList.back(); ent != nil; ent = ent.prevEntry() { + keys = append(keys, ent.key) + } + return keys } // Len returns the number of items in the cache. @@ -199,14 +230,14 @@ func (c *ExpirableLRU[K, V]) Len() int { return c.evictList.length() } -// Resize changes the cache size. Size of 0 doesn't resize the cache, as it means unlimited. +// Resize changes the cache size. Size of 0 means unlimited. func (c *ExpirableLRU[K, V]) Resize(size int) (evicted int) { + c.mu.Lock() + defer c.mu.Unlock() if size <= 0 { c.size = 0 return 0 } - c.mu.Lock() - defer c.mu.Unlock() diff := c.evictList.length() - size if diff < 0 { diff = 0 @@ -218,7 +249,7 @@ func (c *ExpirableLRU[K, V]) Resize(size int) (evicted int) { return diff } -// Close cleans the cache and destroys running goroutines +// Close destroys cleanup goroutine. To clean up the cache, run Purge() before Close(). func (c *ExpirableLRU[K, V]) Close() { c.mu.Lock() defer c.mu.Unlock() @@ -241,25 +272,31 @@ func (c *ExpirableLRU[K, V]) removeOldest() { func (c *ExpirableLRU[K, V]) removeElement(e *entry[K, V]) { c.evictList.remove(e) delete(c.items, e.key) + c.removeFromBucket(e) if c.onEvict != nil { c.onEvict(e.key, e.value) } } -// deleteExpired deletes expired records. Has to be called with lock! +// deleteExpired deletes expired records. Doesn't check for entry.expiresAt as it could be +// TTL/numBuckets in the future, with numBuckets of 100 its 1% of wasted TTL. +// Has to be called with lock! func (c *ExpirableLRU[K, V]) deleteExpired() { - for _, key := range c.keys() { - if time.Now().After(c.items[key].expiresAt) { - c.removeElement(c.items[key]) - } + bucketIdx := c.nextCleanupBucket + for _, ent := range c.buckets[bucketIdx].entries { + c.removeElement(ent) } + c.nextCleanupBucket = (c.nextCleanupBucket + 1) % numBuckets } -// keys returns a slice of the keys in the cache, from oldest to newest. Has to be called with lock! -func (c *ExpirableLRU[K, V]) keys() []K { - keys := make([]K, 0, len(c.items)) - for ent := c.evictList.back(); ent != nil; ent = ent.prevEntry() { - keys = append(keys, ent.key) - } - return keys +// addToBucket adds entry to expire bucket so that it will be cleaned up when the time comes. Has to be called with lock! +func (c *ExpirableLRU[K, V]) addToBucket(e *entry[K, V]) { + bucketID := (numBuckets + c.nextCleanupBucket - 1) % numBuckets + e.expireBucket = bucketID + c.buckets[bucketID].entries[e.key] = e +} + +// removeFromBucket removes the entry from its corresponding bucket. Has to be called with lock! +func (c *ExpirableLRU[K, V]) removeFromBucket(e *entry[K, V]) { + delete(c.buckets[e.expireBucket].entries, e.key) } diff --git a/simplelru/expirable_lru_test.go b/simplelru/expirable_lru_test.go index 48845b0..4e4324e 100644 --- a/simplelru/expirable_lru_test.go +++ b/simplelru/expirable_lru_test.go @@ -12,7 +12,7 @@ import ( ) func BenchmarkExpirableLRU_Rand_NoExpire(b *testing.B) { - l := NewExpirableLRU[int64, int64](8192, nil, 0, 0) + l := NewExpirableLRU[int64, int64](8192, nil, 0) trace := make([]int64, b.N*2) for i := 0; i < b.N*2; i++ { @@ -37,7 +37,7 @@ func BenchmarkExpirableLRU_Rand_NoExpire(b *testing.B) { } func BenchmarkExpirableLRU_Freq_NoExpire(b *testing.B) { - l := NewExpirableLRU[int64, int64](8192, nil, 0, 0) + l := NewExpirableLRU[int64, int64](8192, nil, 0) trace := make([]int64, b.N*2) for i := 0; i < b.N*2; i++ { @@ -65,7 +65,8 @@ func BenchmarkExpirableLRU_Freq_NoExpire(b *testing.B) { } func BenchmarkExpirableLRU_Rand_WithExpire(b *testing.B) { - l := NewExpirableLRU[int64, int64](8192, nil, time.Millisecond*10, time.Millisecond*50) + l := NewExpirableLRU[int64, int64](8192, nil, time.Millisecond*10) + defer l.Close() trace := make([]int64, b.N*2) for i := 0; i < b.N*2; i++ { @@ -90,7 +91,8 @@ func BenchmarkExpirableLRU_Rand_WithExpire(b *testing.B) { } func BenchmarkExpirableLRU_Freq_WithExpire(b *testing.B) { - l := NewExpirableLRU[int64, int64](8192, nil, time.Millisecond*10, time.Millisecond*50) + l := NewExpirableLRU[int64, int64](8192, nil, time.Millisecond*10) + defer l.Close() trace := make([]int64, b.N*2) for i := 0; i < b.N*2; i++ { @@ -122,7 +124,7 @@ func TestExpirableLRUInterface(t *testing.T) { } func TestExpirableLRUNoPurge(t *testing.T) { - lc := NewExpirableLRU[string, string](10, nil, 0, 0) + lc := NewExpirableLRU[string, string](10, nil, 0) lc.Add("key1", "val1") if lc.Len() != 1 { @@ -169,7 +171,7 @@ func TestExpirableLRUNoPurge(t *testing.T) { } func TestExpirableMultipleClose(t *testing.T) { - lc := NewExpirableLRU[string, string](10, nil, 0, 0) + lc := NewExpirableLRU[string, string](10, nil, 0) lc.Close() // should not panic lc.Close() @@ -177,7 +179,7 @@ func TestExpirableMultipleClose(t *testing.T) { func TestExpirableLRUWithPurge(t *testing.T) { var evicted []string - lc := NewExpirableLRU(10, func(key string, value string) { evicted = append(evicted, key, value) }, 150*time.Millisecond, time.Millisecond*100) + lc := NewExpirableLRU(10, func(key string, value string) { evicted = append(evicted, key, value) }, 150*time.Millisecond) defer lc.Close() k, v, ok := lc.GetOldest() @@ -259,7 +261,7 @@ func TestExpirableLRUWithPurge(t *testing.T) { } func TestExpirableLRUWithPurgeEnforcedBySize(t *testing.T) { - lc := NewExpirableLRU[string, string](10, nil, time.Hour, 0) + lc := NewExpirableLRU[string, string](10, nil, time.Hour) defer lc.Close() for i := 0; i < 100; i++ { @@ -283,7 +285,7 @@ func TestExpirableLRUWithPurgeEnforcedBySize(t *testing.T) { } func TestExpirableLRUConcurrency(t *testing.T) { - lc := NewExpirableLRU[string, string](0, nil, 0, 0) + lc := NewExpirableLRU[string, string](0, nil, 0) wg := sync.WaitGroup{} wg.Add(1000) for i := 0; i < 1000; i++ { @@ -300,7 +302,7 @@ func TestExpirableLRUConcurrency(t *testing.T) { func TestExpirableLRUInvalidateAndEvict(t *testing.T) { var evicted int - lc := NewExpirableLRU(-1, func(_, _ string) { evicted++ }, 0, 0) + lc := NewExpirableLRU(-1, func(_, _ string) { evicted++ }, 0) lc.Add("key1", "val1") lc.Add("key2", "val2") @@ -330,7 +332,8 @@ func TestExpirableLRUInvalidateAndEvict(t *testing.T) { } func TestLoadingExpired(t *testing.T) { - lc := NewExpirableLRU[string, string](0, nil, time.Millisecond*5, 0) + lc := NewExpirableLRU[string, string](0, nil, time.Millisecond*5) + defer lc.Close() lc.Add("key1", "val1") if lc.Len() != 1 { @@ -354,9 +357,9 @@ func TestLoadingExpired(t *testing.T) { } time.Sleep(time.Millisecond * 10) // wait for entry to expire - if lc.Len() != 1 { + if lc.Len() != 0 { t.Fatalf("length differs from expected") - } // but not purged + } v, ok = lc.Peek("key1") if v != "" { @@ -376,7 +379,7 @@ func TestLoadingExpired(t *testing.T) { } func TestExpirableLRURemoveOldest(t *testing.T) { - lc := NewExpirableLRU[string, string](2, nil, 0, 0) + lc := NewExpirableLRU[string, string](2, nil, 0) k, v, ok := lc.RemoveOldest() if k != "" { @@ -442,8 +445,8 @@ func TestExpirableLRURemoveOldest(t *testing.T) { } func ExampleExpirableLRU() { - // make cache with 5ms TTL and 3 max keys, purge every 10ms - cache := NewExpirableLRU[string, string](3, nil, time.Millisecond*5, time.Millisecond*10) + // make cache with 10ms TTL and 5 max keys + cache := NewExpirableLRU[string, string](5, nil, time.Millisecond*10) // expirable cache need to be closed after used defer cache.Close() @@ -459,7 +462,7 @@ func ExampleExpirableLRU() { } // wait for cache to expire - time.Sleep(time.Millisecond * 16) + time.Sleep(time.Millisecond * 12) // get value under key1 after key expiration r, ok = cache.Get("key1") diff --git a/simplelru/list.go b/simplelru/list.go index 132c3f2..b85131c 100644 --- a/simplelru/list.go +++ b/simplelru/list.go @@ -24,8 +24,11 @@ type entry[K comparable, V any] struct { // The value stored with this element. value V - // The time this element would be cleaned up + // The time this element would be cleaned up, optional expiresAt time.Time + + // The expiry bucket item was put in, optional + expireBucket uint8 } // prevEntry returns the previous list element or nil. @@ -116,7 +119,13 @@ func (l *lruList[K, V]) move(e, at *entry[K, V]) { } // pushFront inserts a new element e with value v at the front of list l and returns e. -func (l *lruList[K, V]) pushFront(k K, v V, expiresAt time.Time) *entry[K, V] { +func (l *lruList[K, V]) pushFront(k K, v V) *entry[K, V] { + l.lazyInit() + return l.insertValue(k, v, time.Time{}, &l.root) +} + +// pushFrontExpirable inserts a new expirable element e with value v at the front of list l and returns e. +func (l *lruList[K, V]) pushFrontExpirable(k K, v V, expiresAt time.Time) *entry[K, V] { l.lazyInit() return l.insertValue(k, v, expiresAt, &l.root) } diff --git a/simplelru/lru.go b/simplelru/lru.go index da77c80..3870a47 100644 --- a/simplelru/lru.go +++ b/simplelru/lru.go @@ -5,7 +5,6 @@ package simplelru import ( "errors" - "time" ) // EvictCallback is used to get a callback when a cache entry is evicted @@ -58,7 +57,7 @@ func (c *LRU[K, V]) Add(key K, value V) (evicted bool) { } // Add new item - ent := c.evictList.pushFront(key, value, time.Time{}) + ent := c.evictList.pushFront(key, value) c.items[key] = ent evict := c.evictList.length() > c.size From 73f395c74a288af48016b3ea6c870ba3449ae2ce Mon Sep 17 00:00:00 2001 From: Dmitry Verkhoturov Date: Fri, 30 Jun 2023 17:36:26 +0300 Subject: [PATCH 3/7] add check for entry expire time in expirable LRU expired cleanup --- simplelru/expirable_lru.go | 45 +++++++++++++++++++++++++-------- simplelru/expirable_lru_test.go | 4 +-- 2 files changed, 37 insertions(+), 12 deletions(-) diff --git a/simplelru/expirable_lru.go b/simplelru/expirable_lru.go index 8c0e04a..7a013f5 100644 --- a/simplelru/expirable_lru.go +++ b/simplelru/expirable_lru.go @@ -6,10 +6,6 @@ import ( ) // ExpirableLRU implements a thread-safe LRU with expirable entries. -// -// Entries can be cleaned up from cache with up to 1% of ttl unused. -// It happens because cleanup mechanism puts them 99 cleanup buckets away -// from the current moment,and then cleans them up 99% of ttl later instead of 100%. type ExpirableLRU[K comparable, V any] struct { size int evictList *lruList[K, V] @@ -29,13 +25,15 @@ type ExpirableLRU[K comparable, V any] struct { // bucket is a container for holding entries to be expired type bucket[K comparable, V any] struct { - entries map[K]*entry[K, V] + entries map[K]*entry[K, V] + newestEntry time.Time } // noEvictionTTL - very long ttl to prevent eviction const noEvictionTTL = time.Hour * 24 * 365 * 10 // because of uint8 usage for nextCleanupBucket, should not exceed 256. +// casting it as uint8 explicitly requires type conversions in multiple places const numBuckets = 100 // NewExpirableLRU returns a new thread-safe cache with expirable entries. @@ -78,9 +76,7 @@ func NewExpirableLRU[K comparable, V any](size int, onEvict EvictCallback[K, V], case <-done: return case <-ticker.C: - res.mu.Lock() res.deleteExpired() - res.mu.Unlock() } } }(res.done) @@ -223,6 +219,24 @@ func (c *ExpirableLRU[K, V]) Keys() []K { return keys } +// Values returns a slice of the values in the cache, from oldest to newest. +// Expired entries are filtered out. +func (c *ExpirableLRU[K, V]) Values() []V { + c.mu.Lock() + defer c.mu.Unlock() + values := make([]V, len(c.items)) + i := 0 + now := time.Now() + for ent := c.evictList.back(); ent != nil; ent = ent.prevEntry() { + if now.After(ent.expiresAt) { + continue + } + values[i] = ent.value + i++ + } + return values +} + // Len returns the number of items in the cache. func (c *ExpirableLRU[K, V]) Len() int { c.mu.Lock() @@ -278,15 +292,23 @@ func (c *ExpirableLRU[K, V]) removeElement(e *entry[K, V]) { } } -// deleteExpired deletes expired records. Doesn't check for entry.expiresAt as it could be -// TTL/numBuckets in the future, with numBuckets of 100 its 1% of wasted TTL. -// Has to be called with lock! +// deleteExpired deletes expired records from the oldest bucket, waiting for the newest entry +// in it to expire first. func (c *ExpirableLRU[K, V]) deleteExpired() { + c.mu.Lock() bucketIdx := c.nextCleanupBucket + timeToExpire := time.Until(c.buckets[bucketIdx].newestEntry) + // wait for newest entry to expire before cleanup without holding lock + if timeToExpire > 0 { + c.mu.Unlock() + time.Sleep(timeToExpire) + c.mu.Lock() + } for _, ent := range c.buckets[bucketIdx].entries { c.removeElement(ent) } c.nextCleanupBucket = (c.nextCleanupBucket + 1) % numBuckets + c.mu.Unlock() } // addToBucket adds entry to expire bucket so that it will be cleaned up when the time comes. Has to be called with lock! @@ -294,6 +316,9 @@ func (c *ExpirableLRU[K, V]) addToBucket(e *entry[K, V]) { bucketID := (numBuckets + c.nextCleanupBucket - 1) % numBuckets e.expireBucket = bucketID c.buckets[bucketID].entries[e.key] = e + if c.buckets[bucketID].newestEntry.Before(e.expiresAt) { + c.buckets[bucketID].newestEntry = e.expiresAt + } } // removeFromBucket removes the entry from its corresponding bucket. Has to be called with lock! diff --git a/simplelru/expirable_lru_test.go b/simplelru/expirable_lru_test.go index 4e4324e..3af4441 100644 --- a/simplelru/expirable_lru_test.go +++ b/simplelru/expirable_lru_test.go @@ -119,7 +119,7 @@ func BenchmarkExpirableLRU_Freq_WithExpire(b *testing.B) { b.Logf("hit: %d miss: %d ratio: %f", hit, miss, float64(hit)/float64(hit+miss)) } -func TestExpirableLRUInterface(t *testing.T) { +func TestExpirableLRUInterface(_ *testing.T) { var _ LRUCache[int, int] = &ExpirableLRU[int, int]{} } @@ -170,7 +170,7 @@ func TestExpirableLRUNoPurge(t *testing.T) { } } -func TestExpirableMultipleClose(t *testing.T) { +func TestExpirableMultipleClose(_ *testing.T) { lc := NewExpirableLRU[string, string](10, nil, 0) lc.Close() // should not panic From f0d41e070d2ae4b14d928de84f28ccacaeefbfaf Mon Sep 17 00:00:00 2001 From: Dmitry Verkhoturov Date: Wed, 2 Aug 2023 19:16:25 +0200 Subject: [PATCH 4/7] add deferred timer close, add missing tests --- simplelru/expirable_lru.go | 1 + simplelru/expirable_lru_test.go | 35 +++++++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/simplelru/expirable_lru.go b/simplelru/expirable_lru.go index 7a013f5..b03e1d2 100644 --- a/simplelru/expirable_lru.go +++ b/simplelru/expirable_lru.go @@ -71,6 +71,7 @@ func NewExpirableLRU[K comparable, V any](size int, onEvict EvictCallback[K, V], if res.ttl != noEvictionTTL { go func(done <-chan struct{}) { ticker := time.NewTicker(res.ttl / numBuckets) + defer ticker.Stop() for { select { case <-done: diff --git a/simplelru/expirable_lru_test.go b/simplelru/expirable_lru_test.go index 3af4441..7754f74 100644 --- a/simplelru/expirable_lru_test.go +++ b/simplelru/expirable_lru_test.go @@ -170,6 +170,41 @@ func TestExpirableLRUNoPurge(t *testing.T) { } } +func TestExpirableLRUEdgeCases(t *testing.T) { + lc := NewExpirableLRU[string, *string](2, nil, 0) + + // Adding a nil value + lc.Add("key1", nil) + + value, exists := lc.Get("key1") + if value != nil || !exists { + t.Fatalf("unexpected value or existence flag for key1: value=%v, exists=%v", value, exists) + } + + // Adding an entry with the same key but different value + newVal := "val1" + lc.Add("key1", &newVal) + + value, exists = lc.Get("key1") + if value != &newVal || !exists { + t.Fatalf("unexpected value or existence flag for key1: value=%v, exists=%v", value, exists) + } +} + +func TestExpirableLRU_Values(t *testing.T) { + lc := NewExpirableLRU[string, string](3, nil, 0) + defer lc.Close() + + 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") + } +} + func TestExpirableMultipleClose(_ *testing.T) { lc := NewExpirableLRU[string, string](10, nil, 0) lc.Close() From 1154eabd8734fc7f7005e582498ee57dc6b3bc22 Mon Sep 17 00:00:00 2001 From: Dmitry Verkhoturov Date: Sat, 5 Aug 2023 16:01:27 +0200 Subject: [PATCH 5/7] move expirable LRU into separate package --- README.md | 4 +- {simplelru => expirable}/expirable_lru.go | 133 ++++++++-------- .../expirable_lru_test.go | 34 +++-- internal/list.go | 142 ++++++++++++++++++ simplelru/list.go | 142 ------------------ simplelru/lru.go | 62 ++++---- 6 files changed, 263 insertions(+), 254 deletions(-) rename {simplelru => expirable}/expirable_lru.go (67%) rename {simplelru => expirable}/expirable_lru_test.go (92%) create mode 100644 internal/list.go delete mode 100644 simplelru/list.go diff --git a/README.md b/README.md index 26c7a3f..2034094 100644 --- a/README.md +++ b/README.md @@ -41,12 +41,12 @@ import ( "fmt" "time" - "github.com/hashicorp/golang-lru/v2/simplelru" + "github.com/hashicorp/golang-lru/v2/expirable" ) func main() { // make cache with 10ms TTL and 5 max keys - cache := simplelru.NewExpirableLRU[string, string](5, nil, time.Millisecond*10) + cache := expirable.NewExpirableLRU[string, string](5, nil, time.Millisecond*10) // expirable cache need to be closed after used defer cache.Close() diff --git a/simplelru/expirable_lru.go b/expirable/expirable_lru.go similarity index 67% rename from simplelru/expirable_lru.go rename to expirable/expirable_lru.go index b03e1d2..997fdf5 100644 --- a/simplelru/expirable_lru.go +++ b/expirable/expirable_lru.go @@ -1,15 +1,20 @@ -package simplelru +package expirable import ( "sync" "time" + + "github.com/hashicorp/golang-lru/v2/internal" ) -// ExpirableLRU implements a thread-safe LRU with expirable entries. -type ExpirableLRU[K comparable, V any] struct { +// EvictCallback is used to get a callback when a cache entry is evicted +type EvictCallback[K comparable, V any] func(key K, value V) + +// LRU implements a thread-safe LRU with expirable entries. +type LRU[K comparable, V any] struct { size int - evictList *lruList[K, V] - items map[K]*entry[K, V] + evictList *internal.LruList[K, V] + items map[K]*internal.Entry[K, V] onEvict EvictCallback[K, V] // expirable options @@ -25,7 +30,7 @@ type ExpirableLRU[K comparable, V any] struct { // bucket is a container for holding entries to be expired type bucket[K comparable, V any] struct { - entries map[K]*entry[K, V] + entries map[K]*internal.Entry[K, V] newestEntry time.Time } @@ -43,7 +48,7 @@ const numBuckets = 100 // Providing 0 TTL turns expiring off. // // Delete expired entries every 1/100th of ttl value. -func NewExpirableLRU[K comparable, V any](size int, onEvict EvictCallback[K, V], ttl time.Duration) *ExpirableLRU[K, V] { +func NewExpirableLRU[K comparable, V any](size int, onEvict EvictCallback[K, V], ttl time.Duration) *LRU[K, V] { if size < 0 { size = 0 } @@ -51,11 +56,11 @@ func NewExpirableLRU[K comparable, V any](size int, onEvict EvictCallback[K, V], ttl = noEvictionTTL } - res := ExpirableLRU[K, V]{ + res := LRU[K, V]{ ttl: ttl, size: size, - evictList: newList[K, V](), - items: make(map[K]*entry[K, V]), + evictList: internal.NewList[K, V](), + items: make(map[K]*internal.Entry[K, V]), onEvict: onEvict, done: make(chan struct{}), } @@ -63,7 +68,7 @@ func NewExpirableLRU[K comparable, V any](size int, onEvict EvictCallback[K, V], // initialize the buckets res.buckets = make([]bucket[K, V], numBuckets) for i := 0; i < numBuckets; i++ { - res.buckets[i] = bucket[K, V]{entries: make(map[K]*entry[K, V])} + res.buckets[i] = bucket[K, V]{entries: make(map[K]*internal.Entry[K, V])} } // enable deleteExpired() running in separate goroutine for cache @@ -87,47 +92,47 @@ func NewExpirableLRU[K comparable, V any](size int, onEvict EvictCallback[K, V], // Purge clears the cache completely. // onEvict is called for each evicted key. -func (c *ExpirableLRU[K, V]) Purge() { +func (c *LRU[K, V]) Purge() { c.mu.Lock() defer c.mu.Unlock() for k, v := range c.items { if c.onEvict != nil { - c.onEvict(k, v.value) + c.onEvict(k, v.Value) } delete(c.items, k) } for _, b := range c.buckets { for _, ent := range b.entries { - delete(b.entries, ent.key) + delete(b.entries, ent.Key) } } - c.evictList.init() + c.evictList.Init() } // 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 *ExpirableLRU[K, V]) Add(key K, value V) (evicted bool) { +func (c *LRU[K, V]) Add(key K, value V) (evicted bool) { c.mu.Lock() defer c.mu.Unlock() now := time.Now() // Check for existing item if ent, ok := c.items[key]; ok { - c.evictList.moveToFront(ent) + c.evictList.MoveToFront(ent) c.removeFromBucket(ent) // remove the entry from its current bucket as expiresAt is renewed - ent.value = value - ent.expiresAt = now.Add(c.ttl) + ent.Value = value + ent.ExpiresAt = now.Add(c.ttl) c.addToBucket(ent) return false } // Add new item - ent := c.evictList.pushFrontExpirable(key, value, now.Add(c.ttl)) + ent := c.evictList.PushFrontExpirable(key, value, now.Add(c.ttl)) c.items[key] = ent c.addToBucket(ent) // adds the entry to the appropriate bucket and sets entry.expireBucket - evict := c.size > 0 && c.evictList.length() > c.size + evict := c.size > 0 && c.evictList.Length() > c.size // Verify size not exceeded if evict { c.removeOldest() @@ -136,24 +141,24 @@ func (c *ExpirableLRU[K, V]) Add(key K, value V) (evicted bool) { } // Get looks up a key's value from the cache. -func (c *ExpirableLRU[K, V]) Get(key K) (value V, ok bool) { +func (c *LRU[K, V]) Get(key K) (value V, ok bool) { c.mu.Lock() defer c.mu.Unlock() - var ent *entry[K, V] + var ent *internal.Entry[K, V] if ent, ok = c.items[key]; ok { // Expired item check - if time.Now().After(ent.expiresAt) { + if time.Now().After(ent.ExpiresAt) { return } - c.evictList.moveToFront(ent) - return ent.value, true + c.evictList.MoveToFront(ent) + return ent.Value, true } return } // Contains checks if a key is in the cache, without updating the recent-ness // or deleting it for being stale. -func (c *ExpirableLRU[K, V]) Contains(key K) (ok bool) { +func (c *LRU[K, V]) Contains(key K) (ok bool) { c.mu.Lock() defer c.mu.Unlock() _, ok = c.items[key] @@ -162,23 +167,23 @@ func (c *ExpirableLRU[K, V]) Contains(key K) (ok bool) { // Peek returns the key value (or undefined if not found) without updating // the "recently used"-ness of the key. -func (c *ExpirableLRU[K, V]) Peek(key K) (value V, ok bool) { +func (c *LRU[K, V]) Peek(key K) (value V, ok bool) { c.mu.Lock() defer c.mu.Unlock() - var ent *entry[K, V] + var ent *internal.Entry[K, V] if ent, ok = c.items[key]; ok { // Expired item check - if time.Now().After(ent.expiresAt) { + if time.Now().After(ent.ExpiresAt) { return } - return ent.value, true + return ent.Value, true } return } // Remove removes the provided key from the cache, returning if the // key was contained. -func (c *ExpirableLRU[K, V]) Remove(key K) bool { +func (c *LRU[K, V]) Remove(key K) bool { c.mu.Lock() defer c.mu.Unlock() if ent, ok := c.items[key]; ok { @@ -189,71 +194,71 @@ func (c *ExpirableLRU[K, V]) Remove(key K) bool { } // RemoveOldest removes the oldest item from the cache. -func (c *ExpirableLRU[K, V]) RemoveOldest() (key K, value V, ok bool) { +func (c *LRU[K, V]) RemoveOldest() (key K, value V, ok bool) { c.mu.Lock() defer c.mu.Unlock() - if ent := c.evictList.back(); ent != nil { + if ent := c.evictList.Back(); ent != nil { c.removeElement(ent) - return ent.key, ent.value, true + return ent.Key, ent.Value, true } return } // GetOldest returns the oldest entry -func (c *ExpirableLRU[K, V]) GetOldest() (key K, value V, ok bool) { +func (c *LRU[K, V]) GetOldest() (key K, value V, ok bool) { c.mu.Lock() defer c.mu.Unlock() - if ent := c.evictList.back(); ent != nil { - return ent.key, ent.value, true + if ent := c.evictList.Back(); ent != nil { + return ent.Key, ent.Value, true } return } // Keys returns a slice of the keys in the cache, from oldest to newest. -func (c *ExpirableLRU[K, V]) Keys() []K { +func (c *LRU[K, V]) Keys() []K { c.mu.Lock() defer c.mu.Unlock() keys := make([]K, 0, len(c.items)) - for ent := c.evictList.back(); ent != nil; ent = ent.prevEntry() { - keys = append(keys, ent.key) + for ent := c.evictList.Back(); ent != nil; ent = ent.PrevEntry() { + keys = append(keys, ent.Key) } return keys } // Values returns a slice of the values in the cache, from oldest to newest. // Expired entries are filtered out. -func (c *ExpirableLRU[K, V]) Values() []V { +func (c *LRU[K, V]) Values() []V { c.mu.Lock() defer c.mu.Unlock() values := make([]V, len(c.items)) i := 0 now := time.Now() - for ent := c.evictList.back(); ent != nil; ent = ent.prevEntry() { - if now.After(ent.expiresAt) { + for ent := c.evictList.Back(); ent != nil; ent = ent.PrevEntry() { + if now.After(ent.ExpiresAt) { continue } - values[i] = ent.value + values[i] = ent.Value i++ } return values } // Len returns the number of items in the cache. -func (c *ExpirableLRU[K, V]) Len() int { +func (c *LRU[K, V]) Len() int { c.mu.Lock() defer c.mu.Unlock() - return c.evictList.length() + return c.evictList.Length() } // Resize changes the cache size. Size of 0 means unlimited. -func (c *ExpirableLRU[K, V]) Resize(size int) (evicted int) { +func (c *LRU[K, V]) Resize(size int) (evicted int) { c.mu.Lock() defer c.mu.Unlock() if size <= 0 { c.size = 0 return 0 } - diff := c.evictList.length() - size + diff := c.evictList.Length() - size if diff < 0 { diff = 0 } @@ -265,7 +270,7 @@ func (c *ExpirableLRU[K, V]) Resize(size int) (evicted int) { } // Close destroys cleanup goroutine. To clean up the cache, run Purge() before Close(). -func (c *ExpirableLRU[K, V]) Close() { +func (c *LRU[K, V]) Close() { c.mu.Lock() defer c.mu.Unlock() select { @@ -277,25 +282,25 @@ func (c *ExpirableLRU[K, V]) Close() { } // removeOldest removes the oldest item from the cache. Has to be called with lock! -func (c *ExpirableLRU[K, V]) removeOldest() { - if ent := c.evictList.back(); ent != nil { +func (c *LRU[K, V]) removeOldest() { + if ent := c.evictList.Back(); ent != nil { c.removeElement(ent) } } // removeElement is used to remove a given list element from the cache. Has to be called with lock! -func (c *ExpirableLRU[K, V]) removeElement(e *entry[K, V]) { - c.evictList.remove(e) - delete(c.items, e.key) +func (c *LRU[K, V]) removeElement(e *internal.Entry[K, V]) { + c.evictList.Remove(e) + delete(c.items, e.Key) c.removeFromBucket(e) if c.onEvict != nil { - c.onEvict(e.key, e.value) + c.onEvict(e.Key, e.Value) } } // deleteExpired deletes expired records from the oldest bucket, waiting for the newest entry // in it to expire first. -func (c *ExpirableLRU[K, V]) deleteExpired() { +func (c *LRU[K, V]) deleteExpired() { c.mu.Lock() bucketIdx := c.nextCleanupBucket timeToExpire := time.Until(c.buckets[bucketIdx].newestEntry) @@ -313,16 +318,16 @@ func (c *ExpirableLRU[K, V]) deleteExpired() { } // addToBucket adds entry to expire bucket so that it will be cleaned up when the time comes. Has to be called with lock! -func (c *ExpirableLRU[K, V]) addToBucket(e *entry[K, V]) { +func (c *LRU[K, V]) addToBucket(e *internal.Entry[K, V]) { bucketID := (numBuckets + c.nextCleanupBucket - 1) % numBuckets - e.expireBucket = bucketID - c.buckets[bucketID].entries[e.key] = e - if c.buckets[bucketID].newestEntry.Before(e.expiresAt) { - c.buckets[bucketID].newestEntry = e.expiresAt + e.ExpireBucket = bucketID + c.buckets[bucketID].entries[e.Key] = e + if c.buckets[bucketID].newestEntry.Before(e.ExpiresAt) { + c.buckets[bucketID].newestEntry = e.ExpiresAt } } // removeFromBucket removes the entry from its corresponding bucket. Has to be called with lock! -func (c *ExpirableLRU[K, V]) removeFromBucket(e *entry[K, V]) { - delete(c.buckets[e.expireBucket].entries, e.key) +func (c *LRU[K, V]) removeFromBucket(e *internal.Entry[K, V]) { + delete(c.buckets[e.ExpireBucket].entries, e.Key) } diff --git a/simplelru/expirable_lru_test.go b/expirable/expirable_lru_test.go similarity index 92% rename from simplelru/expirable_lru_test.go rename to expirable/expirable_lru_test.go index 7754f74..a2aa67a 100644 --- a/simplelru/expirable_lru_test.go +++ b/expirable/expirable_lru_test.go @@ -1,4 +1,4 @@ -package simplelru +package expirable import ( "crypto/rand" @@ -9,9 +9,11 @@ import ( "sync" "testing" "time" + + "github.com/hashicorp/golang-lru/v2/simplelru" ) -func BenchmarkExpirableLRU_Rand_NoExpire(b *testing.B) { +func BenchmarkLRU_Rand_NoExpire(b *testing.B) { l := NewExpirableLRU[int64, int64](8192, nil, 0) trace := make([]int64, b.N*2) @@ -36,7 +38,7 @@ func BenchmarkExpirableLRU_Rand_NoExpire(b *testing.B) { b.Logf("hit: %d miss: %d ratio: %f", hit, miss, float64(hit)/float64(hit+miss)) } -func BenchmarkExpirableLRU_Freq_NoExpire(b *testing.B) { +func BenchmarkLRU_Freq_NoExpire(b *testing.B) { l := NewExpirableLRU[int64, int64](8192, nil, 0) trace := make([]int64, b.N*2) @@ -64,7 +66,7 @@ func BenchmarkExpirableLRU_Freq_NoExpire(b *testing.B) { b.Logf("hit: %d miss: %d ratio: %f", hit, miss, float64(hit)/float64(hit+miss)) } -func BenchmarkExpirableLRU_Rand_WithExpire(b *testing.B) { +func BenchmarkLRU_Rand_WithExpire(b *testing.B) { l := NewExpirableLRU[int64, int64](8192, nil, time.Millisecond*10) defer l.Close() @@ -90,7 +92,7 @@ func BenchmarkExpirableLRU_Rand_WithExpire(b *testing.B) { b.Logf("hit: %d miss: %d ratio: %f", hit, miss, float64(hit)/float64(hit+miss)) } -func BenchmarkExpirableLRU_Freq_WithExpire(b *testing.B) { +func BenchmarkLRU_Freq_WithExpire(b *testing.B) { l := NewExpirableLRU[int64, int64](8192, nil, time.Millisecond*10) defer l.Close() @@ -119,11 +121,11 @@ func BenchmarkExpirableLRU_Freq_WithExpire(b *testing.B) { b.Logf("hit: %d miss: %d ratio: %f", hit, miss, float64(hit)/float64(hit+miss)) } -func TestExpirableLRUInterface(_ *testing.T) { - var _ LRUCache[int, int] = &ExpirableLRU[int, int]{} +func TestLRUInterface(_ *testing.T) { + var _ simplelru.LRUCache[int, int] = &LRU[int, int]{} } -func TestExpirableLRUNoPurge(t *testing.T) { +func TestLRUNoPurge(t *testing.T) { lc := NewExpirableLRU[string, string](10, nil, 0) lc.Add("key1", "val1") @@ -170,7 +172,7 @@ func TestExpirableLRUNoPurge(t *testing.T) { } } -func TestExpirableLRUEdgeCases(t *testing.T) { +func TestLRUEdgeCases(t *testing.T) { lc := NewExpirableLRU[string, *string](2, nil, 0) // Adding a nil value @@ -191,7 +193,7 @@ func TestExpirableLRUEdgeCases(t *testing.T) { } } -func TestExpirableLRU_Values(t *testing.T) { +func TestLRU_Values(t *testing.T) { lc := NewExpirableLRU[string, string](3, nil, 0) defer lc.Close() @@ -212,7 +214,7 @@ func TestExpirableMultipleClose(_ *testing.T) { lc.Close() } -func TestExpirableLRUWithPurge(t *testing.T) { +func TestLRUWithPurge(t *testing.T) { var evicted []string lc := NewExpirableLRU(10, func(key string, value string) { evicted = append(evicted, key, value) }, 150*time.Millisecond) defer lc.Close() @@ -295,7 +297,7 @@ func TestExpirableLRUWithPurge(t *testing.T) { } } -func TestExpirableLRUWithPurgeEnforcedBySize(t *testing.T) { +func TestLRUWithPurgeEnforcedBySize(t *testing.T) { lc := NewExpirableLRU[string, string](10, nil, time.Hour) defer lc.Close() @@ -319,7 +321,7 @@ func TestExpirableLRUWithPurgeEnforcedBySize(t *testing.T) { } } -func TestExpirableLRUConcurrency(t *testing.T) { +func TestLRUConcurrency(t *testing.T) { lc := NewExpirableLRU[string, string](0, nil, 0) wg := sync.WaitGroup{} wg.Add(1000) @@ -335,7 +337,7 @@ func TestExpirableLRUConcurrency(t *testing.T) { } } -func TestExpirableLRUInvalidateAndEvict(t *testing.T) { +func TestLRUInvalidateAndEvict(t *testing.T) { var evicted int lc := NewExpirableLRU(-1, func(_, _ string) { evicted++ }, 0) @@ -413,7 +415,7 @@ func TestLoadingExpired(t *testing.T) { } } -func TestExpirableLRURemoveOldest(t *testing.T) { +func TestLRURemoveOldest(t *testing.T) { lc := NewExpirableLRU[string, string](2, nil, 0) k, v, ok := lc.RemoveOldest() @@ -479,7 +481,7 @@ func TestExpirableLRURemoveOldest(t *testing.T) { } } -func ExampleExpirableLRU() { +func ExampleLRU() { // make cache with 10ms TTL and 5 max keys cache := NewExpirableLRU[string, string](5, nil, time.Millisecond*10) // expirable cache need to be closed after used diff --git a/internal/list.go b/internal/list.go new file mode 100644 index 0000000..5cd74a0 --- /dev/null +++ b/internal/list.go @@ -0,0 +1,142 @@ +// Copyright 2009 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE_list file. + +package internal + +import "time" + +// Entry is an LRU Entry +type Entry[K comparable, V any] struct { + // Next and previous pointers in the doubly-linked list of elements. + // To simplify the implementation, internally a list l is implemented + // as a ring, such that &l.root is both the next element of the last + // list element (l.Back()) and the previous element of the first list + // element (l.Front()). + next, prev *Entry[K, V] + + // The list to which this element belongs. + list *LruList[K, V] + + // The LRU Key of this element. + Key K + + // The Value stored with this element. + Value V + + // The time this element would be cleaned up, optional + ExpiresAt time.Time + + // The expiry bucket item was put in, optional + ExpireBucket uint8 +} + +// PrevEntry returns the previous list element or nil. +func (e *Entry[K, V]) PrevEntry() *Entry[K, V] { + if p := e.prev; e.list != nil && p != &e.list.root { + return p + } + return nil +} + +// LruList represents a doubly linked list. +// The zero Value for LruList is an empty list ready to use. +type LruList[K comparable, V any] struct { + root Entry[K, V] // sentinel list element, only &root, root.prev, and root.next are used + len int // current list Length excluding (this) sentinel element +} + +// Init initializes or clears list l. +func (l *LruList[K, V]) Init() *LruList[K, V] { + l.root.next = &l.root + l.root.prev = &l.root + l.len = 0 + return l +} + +// NewList returns an initialized list. +func NewList[K comparable, V any]() *LruList[K, V] { return new(LruList[K, V]).Init() } + +// Length returns the number of elements of list l. +// The complexity is O(1). +func (l *LruList[K, V]) Length() int { return l.len } + +// Back returns the last element of list l or nil if the list is empty. +func (l *LruList[K, V]) Back() *Entry[K, V] { + if l.len == 0 { + return nil + } + return l.root.prev +} + +// lazyInit lazily initializes a zero List Value. +func (l *LruList[K, V]) lazyInit() { + if l.root.next == nil { + l.Init() + } +} + +// insert inserts e after at, increments l.len, and returns e. +func (l *LruList[K, V]) insert(e, at *Entry[K, V]) *Entry[K, V] { + e.prev = at + e.next = at.next + e.prev.next = e + e.next.prev = e + e.list = l + l.len++ + return e +} + +// insertValue is a convenience wrapper for insert(&Entry{Value: v, ExpiresAt: ExpiresAt}, at). +func (l *LruList[K, V]) insertValue(k K, v V, expiresAt time.Time, at *Entry[K, V]) *Entry[K, V] { + return l.insert(&Entry[K, V]{Value: v, Key: k, ExpiresAt: expiresAt}, at) +} + +// Remove removes e from its list, decrements l.len +func (l *LruList[K, V]) Remove(e *Entry[K, V]) V { + e.prev.next = e.next + e.next.prev = e.prev + e.next = nil // avoid memory leaks + e.prev = nil // avoid memory leaks + e.list = nil + l.len-- + + return e.Value +} + +// move moves e to next to at. +func (l *LruList[K, V]) move(e, at *Entry[K, V]) { + if e == at { + return + } + e.prev.next = e.next + e.next.prev = e.prev + + e.prev = at + e.next = at.next + e.prev.next = e + e.next.prev = e +} + +// PushFront inserts a new element e with value v at the front of list l and returns e. +func (l *LruList[K, V]) PushFront(k K, v V) *Entry[K, V] { + l.lazyInit() + return l.insertValue(k, v, time.Time{}, &l.root) +} + +// PushFrontExpirable inserts a new expirable element e with Value v at the front of list l and returns e. +func (l *LruList[K, V]) PushFrontExpirable(k K, v V, expiresAt time.Time) *Entry[K, V] { + l.lazyInit() + return l.insertValue(k, v, expiresAt, &l.root) +} + +// MoveToFront moves element e to the front of list l. +// If e is not an element of l, the list is not modified. +// The element must not be nil. +func (l *LruList[K, V]) MoveToFront(e *Entry[K, V]) { + if e.list != l || l.root.next == e { + return + } + // see comment in List.Remove about initialization of l + l.move(e, &l.root) +} diff --git a/simplelru/list.go b/simplelru/list.go deleted file mode 100644 index b85131c..0000000 --- a/simplelru/list.go +++ /dev/null @@ -1,142 +0,0 @@ -// Copyright 2009 The Go Authors. All rights reserved. -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE_list file. - -package simplelru - -import "time" - -// entry is an LRU entry -type entry[K comparable, V any] struct { - // Next and previous pointers in the doubly-linked list of elements. - // To simplify the implementation, internally a list l is implemented - // as a ring, such that &l.root is both the next element of the last - // list element (l.Back()) and the previous element of the first list - // element (l.Front()). - next, prev *entry[K, V] - - // The list to which this element belongs. - list *lruList[K, V] - - // The LRU key of this element. - key K - - // The value stored with this element. - value V - - // The time this element would be cleaned up, optional - expiresAt time.Time - - // The expiry bucket item was put in, optional - expireBucket uint8 -} - -// prevEntry returns the previous list element or nil. -func (e *entry[K, V]) prevEntry() *entry[K, V] { - if p := e.prev; e.list != nil && p != &e.list.root { - return p - } - return nil -} - -// lruList represents a doubly linked list. -// The zero value for lruList is an empty list ready to use. -type lruList[K comparable, V any] struct { - root entry[K, V] // sentinel list element, only &root, root.prev, and root.next are used - len int // current list length excluding (this) sentinel element -} - -// init initializes or clears list l. -func (l *lruList[K, V]) init() *lruList[K, V] { - l.root.next = &l.root - l.root.prev = &l.root - l.len = 0 - return l -} - -// newList returns an initialized list. -func newList[K comparable, V any]() *lruList[K, V] { return new(lruList[K, V]).init() } - -// length returns the number of elements of list l. -// The complexity is O(1). -func (l *lruList[K, V]) length() int { return l.len } - -// back returns the last element of list l or nil if the list is empty. -func (l *lruList[K, V]) back() *entry[K, V] { - if l.len == 0 { - return nil - } - return l.root.prev -} - -// lazyInit lazily initializes a zero List value. -func (l *lruList[K, V]) lazyInit() { - if l.root.next == nil { - l.init() - } -} - -// insert inserts e after at, increments l.len, and returns e. -func (l *lruList[K, V]) insert(e, at *entry[K, V]) *entry[K, V] { - e.prev = at - e.next = at.next - e.prev.next = e - e.next.prev = e - e.list = l - l.len++ - return e -} - -// insertValue is a convenience wrapper for insert(&entry{value: v, expiresAt: expiresAt}, at). -func (l *lruList[K, V]) insertValue(k K, v V, expiresAt time.Time, at *entry[K, V]) *entry[K, V] { - return l.insert(&entry[K, V]{value: v, key: k, expiresAt: expiresAt}, at) -} - -// remove removes e from its list, decrements l.len -func (l *lruList[K, V]) remove(e *entry[K, V]) V { - e.prev.next = e.next - e.next.prev = e.prev - e.next = nil // avoid memory leaks - e.prev = nil // avoid memory leaks - e.list = nil - l.len-- - - return e.value -} - -// move moves e to next to at. -func (l *lruList[K, V]) move(e, at *entry[K, V]) { - if e == at { - return - } - e.prev.next = e.next - e.next.prev = e.prev - - e.prev = at - e.next = at.next - e.prev.next = e - e.next.prev = e -} - -// pushFront inserts a new element e with value v at the front of list l and returns e. -func (l *lruList[K, V]) pushFront(k K, v V) *entry[K, V] { - l.lazyInit() - return l.insertValue(k, v, time.Time{}, &l.root) -} - -// pushFrontExpirable inserts a new expirable element e with value v at the front of list l and returns e. -func (l *lruList[K, V]) pushFrontExpirable(k K, v V, expiresAt time.Time) *entry[K, V] { - l.lazyInit() - return l.insertValue(k, v, expiresAt, &l.root) -} - -// moveToFront moves element e to the front of list l. -// If e is not an element of l, the list is not modified. -// The element must not be nil. -func (l *lruList[K, V]) moveToFront(e *entry[K, V]) { - if e.list != l || l.root.next == e { - return - } - // see comment in List.Remove about initialization of l - l.move(e, &l.root) -} diff --git a/simplelru/lru.go b/simplelru/lru.go index 3870a47..1fd24de 100644 --- a/simplelru/lru.go +++ b/simplelru/lru.go @@ -5,6 +5,8 @@ package simplelru import ( "errors" + + "github.com/hashicorp/golang-lru/v2/internal" ) // EvictCallback is used to get a callback when a cache entry is evicted @@ -13,8 +15,8 @@ type EvictCallback[K comparable, V any] func(key K, value V) // LRU implements a non-thread safe fixed size LRU cache type LRU[K comparable, V any] struct { size int - evictList *lruList[K, V] - items map[K]*entry[K, V] + evictList *internal.LruList[K, V] + items map[K]*internal.Entry[K, V] onEvict EvictCallback[K, V] } @@ -26,8 +28,8 @@ func NewLRU[K comparable, V any](size int, onEvict EvictCallback[K, V]) (*LRU[K, c := &LRU[K, V]{ size: size, - evictList: newList[K, V](), - items: make(map[K]*entry[K, V]), + evictList: internal.NewList[K, V](), + items: make(map[K]*internal.Entry[K, V]), onEvict: onEvict, } return c, nil @@ -37,30 +39,30 @@ func NewLRU[K comparable, V any](size int, onEvict EvictCallback[K, V]) (*LRU[K, func (c *LRU[K, V]) Purge() { for k, v := range c.items { if c.onEvict != nil { - c.onEvict(k, v.value) + c.onEvict(k, v.Value) } delete(c.items, k) } - c.evictList.init() + c.evictList.Init() } // Add adds a value to the cache. Returns true if an eviction occurred. func (c *LRU[K, V]) Add(key K, value V) (evicted bool) { // Check for existing item if ent, ok := c.items[key]; ok { - c.evictList.moveToFront(ent) + c.evictList.MoveToFront(ent) if c.onEvict != nil { - c.onEvict(key, ent.value) + c.onEvict(key, ent.Value) } - ent.value = value + ent.Value = value return false } // Add new item - ent := c.evictList.pushFront(key, value) + ent := c.evictList.PushFront(key, value) c.items[key] = ent - evict := c.evictList.length() > c.size + evict := c.evictList.Length() > c.size // Verify size not exceeded if evict { c.removeOldest() @@ -71,8 +73,8 @@ func (c *LRU[K, V]) Add(key K, value V) (evicted bool) { // Get looks up a key's value from the cache. func (c *LRU[K, V]) Get(key K) (value V, ok bool) { if ent, ok := c.items[key]; ok { - c.evictList.moveToFront(ent) - return ent.value, true + c.evictList.MoveToFront(ent) + return ent.Value, true } return } @@ -87,9 +89,9 @@ func (c *LRU[K, V]) Contains(key K) (ok bool) { // Peek returns the key value (or undefined if not found) without updating // the "recently used"-ness of the key. func (c *LRU[K, V]) Peek(key K) (value V, ok bool) { - var ent *entry[K, V] + var ent *internal.Entry[K, V] if ent, ok = c.items[key]; ok { - return ent.value, true + return ent.Value, true } return } @@ -106,27 +108,27 @@ func (c *LRU[K, V]) Remove(key K) (present bool) { // RemoveOldest removes the oldest item from the cache. func (c *LRU[K, V]) RemoveOldest() (key K, value V, ok bool) { - if ent := c.evictList.back(); ent != nil { + if ent := c.evictList.Back(); ent != nil { c.removeElement(ent) - return ent.key, ent.value, true + return ent.Key, ent.Value, true } return } // GetOldest returns the oldest entry func (c *LRU[K, V]) GetOldest() (key K, value V, ok bool) { - if ent := c.evictList.back(); ent != nil { - return ent.key, ent.value, true + if ent := c.evictList.Back(); ent != nil { + return ent.Key, ent.Value, true } return } // Keys returns a slice of the keys in the cache, from oldest to newest. func (c *LRU[K, V]) Keys() []K { - keys := make([]K, c.evictList.length()) + keys := make([]K, c.evictList.Length()) i := 0 - for ent := c.evictList.back(); ent != nil; ent = ent.prevEntry() { - keys[i] = ent.key + for ent := c.evictList.Back(); ent != nil; ent = ent.PrevEntry() { + keys[i] = ent.Key i++ } return keys @@ -136,8 +138,8 @@ func (c *LRU[K, V]) Keys() []K { func (c *LRU[K, V]) Values() []V { values := make([]V, len(c.items)) i := 0 - for ent := c.evictList.back(); ent != nil; ent = ent.prevEntry() { - values[i] = ent.value + for ent := c.evictList.Back(); ent != nil; ent = ent.PrevEntry() { + values[i] = ent.Value i++ } return values @@ -145,7 +147,7 @@ func (c *LRU[K, V]) Values() []V { // Len returns the number of items in the cache. func (c *LRU[K, V]) Len() int { - return c.evictList.length() + return c.evictList.Length() } // Resize changes the cache size. @@ -166,16 +168,16 @@ func (c *LRU[K, V]) Close() {} // removeOldest removes the oldest item from the cache. func (c *LRU[K, V]) removeOldest() { - if ent := c.evictList.back(); ent != nil { + if ent := c.evictList.Back(); ent != nil { c.removeElement(ent) } } // removeElement is used to remove a given list element from the cache -func (c *LRU[K, V]) removeElement(e *entry[K, V]) { - c.evictList.remove(e) - delete(c.items, e.key) +func (c *LRU[K, V]) removeElement(e *internal.Entry[K, V]) { + c.evictList.Remove(e) + delete(c.items, e.Key) if c.onEvict != nil { - c.onEvict(e.key, e.value) + c.onEvict(e.Key, e.Value) } } From f2e3b297d109c71e21be7fa57becafa809bf2171 Mon Sep 17 00:00:00 2001 From: Dmitry Verkhoturov Date: Mon, 7 Aug 2023 17:18:39 +0200 Subject: [PATCH 6/7] rename NewExpirableLRU to NewLRU --- README.md | 2 +- expirable/expirable_lru.go | 4 ++-- expirable/expirable_lru_test.go | 30 +++++++++++++++--------------- 3 files changed, 18 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 2034094..922bc7d 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ import ( func main() { // make cache with 10ms TTL and 5 max keys - cache := expirable.NewExpirableLRU[string, string](5, nil, time.Millisecond*10) + cache := expirable.NewLRU[string, string](5, nil, time.Millisecond*10) // expirable cache need to be closed after used defer cache.Close() diff --git a/expirable/expirable_lru.go b/expirable/expirable_lru.go index 997fdf5..6b9221f 100644 --- a/expirable/expirable_lru.go +++ b/expirable/expirable_lru.go @@ -41,14 +41,14 @@ const noEvictionTTL = time.Hour * 24 * 365 * 10 // casting it as uint8 explicitly requires type conversions in multiple places const numBuckets = 100 -// NewExpirableLRU returns a new thread-safe cache with expirable entries. +// NewLRU returns a new thread-safe cache with expirable entries. // // Size parameter set to 0 makes cache of unlimited size, e.g. turns LRU mechanism off. // // Providing 0 TTL turns expiring off. // // Delete expired entries every 1/100th of ttl value. -func NewExpirableLRU[K comparable, V any](size int, onEvict EvictCallback[K, V], ttl time.Duration) *LRU[K, V] { +func NewLRU[K comparable, V any](size int, onEvict EvictCallback[K, V], ttl time.Duration) *LRU[K, V] { if size < 0 { size = 0 } diff --git a/expirable/expirable_lru_test.go b/expirable/expirable_lru_test.go index a2aa67a..58529d7 100644 --- a/expirable/expirable_lru_test.go +++ b/expirable/expirable_lru_test.go @@ -14,7 +14,7 @@ import ( ) func BenchmarkLRU_Rand_NoExpire(b *testing.B) { - l := NewExpirableLRU[int64, int64](8192, nil, 0) + l := NewLRU[int64, int64](8192, nil, 0) trace := make([]int64, b.N*2) for i := 0; i < b.N*2; i++ { @@ -39,7 +39,7 @@ func BenchmarkLRU_Rand_NoExpire(b *testing.B) { } func BenchmarkLRU_Freq_NoExpire(b *testing.B) { - l := NewExpirableLRU[int64, int64](8192, nil, 0) + l := NewLRU[int64, int64](8192, nil, 0) trace := make([]int64, b.N*2) for i := 0; i < b.N*2; i++ { @@ -67,7 +67,7 @@ func BenchmarkLRU_Freq_NoExpire(b *testing.B) { } func BenchmarkLRU_Rand_WithExpire(b *testing.B) { - l := NewExpirableLRU[int64, int64](8192, nil, time.Millisecond*10) + l := NewLRU[int64, int64](8192, nil, time.Millisecond*10) defer l.Close() trace := make([]int64, b.N*2) @@ -93,7 +93,7 @@ func BenchmarkLRU_Rand_WithExpire(b *testing.B) { } func BenchmarkLRU_Freq_WithExpire(b *testing.B) { - l := NewExpirableLRU[int64, int64](8192, nil, time.Millisecond*10) + l := NewLRU[int64, int64](8192, nil, time.Millisecond*10) defer l.Close() trace := make([]int64, b.N*2) @@ -126,7 +126,7 @@ func TestLRUInterface(_ *testing.T) { } func TestLRUNoPurge(t *testing.T) { - lc := NewExpirableLRU[string, string](10, nil, 0) + lc := NewLRU[string, string](10, nil, 0) lc.Add("key1", "val1") if lc.Len() != 1 { @@ -173,7 +173,7 @@ func TestLRUNoPurge(t *testing.T) { } func TestLRUEdgeCases(t *testing.T) { - lc := NewExpirableLRU[string, *string](2, nil, 0) + lc := NewLRU[string, *string](2, nil, 0) // Adding a nil value lc.Add("key1", nil) @@ -194,7 +194,7 @@ func TestLRUEdgeCases(t *testing.T) { } func TestLRU_Values(t *testing.T) { - lc := NewExpirableLRU[string, string](3, nil, 0) + lc := NewLRU[string, string](3, nil, 0) defer lc.Close() lc.Add("key1", "val1") @@ -208,7 +208,7 @@ func TestLRU_Values(t *testing.T) { } func TestExpirableMultipleClose(_ *testing.T) { - lc := NewExpirableLRU[string, string](10, nil, 0) + lc := NewLRU[string, string](10, nil, 0) lc.Close() // should not panic lc.Close() @@ -216,7 +216,7 @@ func TestExpirableMultipleClose(_ *testing.T) { func TestLRUWithPurge(t *testing.T) { var evicted []string - lc := NewExpirableLRU(10, func(key string, value string) { evicted = append(evicted, key, value) }, 150*time.Millisecond) + lc := NewLRU(10, func(key string, value string) { evicted = append(evicted, key, value) }, 150*time.Millisecond) defer lc.Close() k, v, ok := lc.GetOldest() @@ -298,7 +298,7 @@ func TestLRUWithPurge(t *testing.T) { } func TestLRUWithPurgeEnforcedBySize(t *testing.T) { - lc := NewExpirableLRU[string, string](10, nil, time.Hour) + lc := NewLRU[string, string](10, nil, time.Hour) defer lc.Close() for i := 0; i < 100; i++ { @@ -322,7 +322,7 @@ func TestLRUWithPurgeEnforcedBySize(t *testing.T) { } func TestLRUConcurrency(t *testing.T) { - lc := NewExpirableLRU[string, string](0, nil, 0) + lc := NewLRU[string, string](0, nil, 0) wg := sync.WaitGroup{} wg.Add(1000) for i := 0; i < 1000; i++ { @@ -339,7 +339,7 @@ func TestLRUConcurrency(t *testing.T) { func TestLRUInvalidateAndEvict(t *testing.T) { var evicted int - lc := NewExpirableLRU(-1, func(_, _ string) { evicted++ }, 0) + lc := NewLRU(-1, func(_, _ string) { evicted++ }, 0) lc.Add("key1", "val1") lc.Add("key2", "val2") @@ -369,7 +369,7 @@ func TestLRUInvalidateAndEvict(t *testing.T) { } func TestLoadingExpired(t *testing.T) { - lc := NewExpirableLRU[string, string](0, nil, time.Millisecond*5) + lc := NewLRU[string, string](0, nil, time.Millisecond*5) defer lc.Close() lc.Add("key1", "val1") @@ -416,7 +416,7 @@ func TestLoadingExpired(t *testing.T) { } func TestLRURemoveOldest(t *testing.T) { - lc := NewExpirableLRU[string, string](2, nil, 0) + lc := NewLRU[string, string](2, nil, 0) k, v, ok := lc.RemoveOldest() if k != "" { @@ -483,7 +483,7 @@ func TestLRURemoveOldest(t *testing.T) { func ExampleLRU() { // make cache with 10ms TTL and 5 max keys - cache := NewExpirableLRU[string, string](5, nil, time.Millisecond*10) + cache := NewLRU[string, string](5, nil, time.Millisecond*10) // expirable cache need to be closed after used defer cache.Close() From 575866d8eb82f80dec8c15600f2fbf57a3806929 Mon Sep 17 00:00:00 2001 From: Dmitry Verkhoturov Date: Mon, 7 Aug 2023 20:23:56 +0200 Subject: [PATCH 7/7] delete expirable LRU Close method --- README.md | 3 +-- expirable/expirable_lru.go | 28 +++++++++++++++------------- expirable/expirable_lru_test.go | 20 ++++++-------------- simplelru/lru.go | 3 --- 4 files changed, 22 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 922bc7d..a942eb5 100644 --- a/README.md +++ b/README.md @@ -47,8 +47,7 @@ import ( func main() { // make cache with 10ms TTL and 5 max keys cache := expirable.NewLRU[string, string](5, nil, time.Millisecond*10) - // expirable cache need to be closed after used - defer cache.Close() + // set value under key1. cache.Add("key1", "val1") diff --git a/expirable/expirable_lru.go b/expirable/expirable_lru.go index 6b9221f..b1612b9 100644 --- a/expirable/expirable_lru.go +++ b/expirable/expirable_lru.go @@ -47,7 +47,7 @@ const numBuckets = 100 // // Providing 0 TTL turns expiring off. // -// Delete expired entries every 1/100th of ttl value. +// Delete expired entries every 1/100th of ttl value. Goroutine which deletes expired entries runs indefinitely. func NewLRU[K comparable, V any](size int, onEvict EvictCallback[K, V], ttl time.Duration) *LRU[K, V] { if size < 0 { size = 0 @@ -71,8 +71,10 @@ func NewLRU[K comparable, V any](size int, onEvict EvictCallback[K, V], ttl time res.buckets[i] = bucket[K, V]{entries: make(map[K]*internal.Entry[K, V])} } - // enable deleteExpired() running in separate goroutine for cache - // with non-zero TTL + // enable deleteExpired() running in separate goroutine for cache with non-zero TTL + // + // Important: done channel is never closed, so deleteExpired() goroutine will never exit, + // it's decided to add functionality to close it in the version later than v2. if res.ttl != noEvictionTTL { go func(done <-chan struct{}) { ticker := time.NewTicker(res.ttl / numBuckets) @@ -270,16 +272,16 @@ func (c *LRU[K, V]) Resize(size int) (evicted int) { } // Close destroys cleanup goroutine. To clean up the cache, run Purge() before Close(). -func (c *LRU[K, V]) Close() { - c.mu.Lock() - defer c.mu.Unlock() - select { - case <-c.done: - return - default: - } - close(c.done) -} +// func (c *LRU[K, V]) Close() { +// c.mu.Lock() +// defer c.mu.Unlock() +// select { +// case <-c.done: +// return +// default: +// } +// close(c.done) +// } // removeOldest removes the oldest item from the cache. Has to be called with lock! func (c *LRU[K, V]) removeOldest() { diff --git a/expirable/expirable_lru_test.go b/expirable/expirable_lru_test.go index 58529d7..55eb34a 100644 --- a/expirable/expirable_lru_test.go +++ b/expirable/expirable_lru_test.go @@ -68,7 +68,6 @@ func BenchmarkLRU_Freq_NoExpire(b *testing.B) { func BenchmarkLRU_Rand_WithExpire(b *testing.B) { l := NewLRU[int64, int64](8192, nil, time.Millisecond*10) - defer l.Close() trace := make([]int64, b.N*2) for i := 0; i < b.N*2; i++ { @@ -94,7 +93,6 @@ func BenchmarkLRU_Rand_WithExpire(b *testing.B) { func BenchmarkLRU_Freq_WithExpire(b *testing.B) { l := NewLRU[int64, int64](8192, nil, time.Millisecond*10) - defer l.Close() trace := make([]int64, b.N*2) for i := 0; i < b.N*2; i++ { @@ -195,7 +193,6 @@ func TestLRUEdgeCases(t *testing.T) { func TestLRU_Values(t *testing.T) { lc := NewLRU[string, string](3, nil, 0) - defer lc.Close() lc.Add("key1", "val1") lc.Add("key2", "val2") @@ -207,17 +204,16 @@ func TestLRU_Values(t *testing.T) { } } -func TestExpirableMultipleClose(_ *testing.T) { - lc := NewLRU[string, string](10, nil, 0) - lc.Close() - // should not panic - lc.Close() -} +// func TestExpirableMultipleClose(_ *testing.T) { +// lc := NewLRU[string, string](10, nil, 0) +// lc.Close() +// // should not panic +// lc.Close() +// } func TestLRUWithPurge(t *testing.T) { var evicted []string lc := NewLRU(10, func(key string, value string) { evicted = append(evicted, key, value) }, 150*time.Millisecond) - defer lc.Close() k, v, ok := lc.GetOldest() if k != "" { @@ -299,7 +295,6 @@ func TestLRUWithPurge(t *testing.T) { func TestLRUWithPurgeEnforcedBySize(t *testing.T) { lc := NewLRU[string, string](10, nil, time.Hour) - defer lc.Close() for i := 0; i < 100; i++ { i := i @@ -370,7 +365,6 @@ func TestLRUInvalidateAndEvict(t *testing.T) { func TestLoadingExpired(t *testing.T) { lc := NewLRU[string, string](0, nil, time.Millisecond*5) - defer lc.Close() lc.Add("key1", "val1") if lc.Len() != 1 { @@ -484,8 +478,6 @@ func TestLRURemoveOldest(t *testing.T) { func ExampleLRU() { // make cache with 10ms TTL and 5 max keys cache := NewLRU[string, string](5, nil, time.Millisecond*10) - // expirable cache need to be closed after used - defer cache.Close() // set value under key1. cache.Add("key1", "val1") diff --git a/simplelru/lru.go b/simplelru/lru.go index 1fd24de..408239c 100644 --- a/simplelru/lru.go +++ b/simplelru/lru.go @@ -163,9 +163,6 @@ func (c *LRU[K, V]) Resize(size int) (evicted int) { return diff } -// Close does nothing for this type of cache. -func (c *LRU[K, V]) Close() {} - // removeOldest removes the oldest item from the cache. func (c *LRU[K, V]) removeOldest() { if ent := c.evictList.Back(); ent != nil {