diff --git a/cache_test.go b/cache_test.go new file mode 100644 index 0000000..d39f09d --- /dev/null +++ b/cache_test.go @@ -0,0 +1,323 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package lru + +import ( + "testing" +) + +func Benchmark_Rand(b *testing.B) { + var fn = func(b *testing.B, l *Cache[int64, int64]) { + 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)) + } + + b.Run("Benchmark with LRU ", func(b *testing.B) { + l, err := New[int64, int64](8192) + if err != nil { + b.Fatalf("err: %v", err) + } + + fn(b, l) + }) + + b.Run("Benchmark with Sieve ", func(b *testing.B) { + l, err := NewWithOpts[int64, int64](8192, WithSieve[int64, int64]()) + if err != nil { + b.Fatalf("err: %v", err) + } + + fn(b, l) + }) +} + +func BenchmarkLRU_Freq(b *testing.B) { + var fn = func(b *testing.B, l *Cache[int64, int64]) { + 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)) + } + + b.Run("Benchmark with LRU ", func(b *testing.B) { + l, err := New[int64, int64](8192) + if err != nil { + b.Fatalf("err: %v", err) + } + + fn(b, l) + }) + + b.Run("Benchmark with Sieve ", func(b *testing.B) { + l, err := NewWithOpts[int64, int64](8192, WithSieve[int64, int64]()) + if err != nil { + b.Fatalf("err: %v", err) + } + + fn(b, l) + }) +} + +// test that Add returns true/false if an eviction occurred +func TestAdd(t *testing.T) { + var evictCounter = 0 + var add = func(t *testing.T, c *Cache[int, int]) { + if c.Add(1, 1) == true || evictCounter != 0 { + t.Errorf("should not have an eviction") + } + if c.Add(2, 2) == false || evictCounter != 1 { + t.Errorf("should have an eviction") + } + } + + onEvicted := func(k int, v int) { + evictCounter++ + } + + l, err := NewWithEvict(1, onEvicted) + if err != nil { + t.Fatalf("err: %v", err) + } + + t.Run("LRU add", func(t1 *testing.T) { + evictCounter = 0 + add(t1, l) + }) + + l, err = NewWithOpts[int, int](1, WithSieve[int, int](), WithCallback[int, int](onEvicted)) + if err != nil { + t.Fatalf("err: %v", err) + } + + t.Run("Sieve add", func(t1 *testing.T) { + evictCounter = 0 + add(t1, l) + }) +} + +// test that ContainsOrAdd doesn't update recent-ness +func TestContainsOrAdd(t *testing.T) { + var containsOrAdd = func(t *testing.T, l *Cache[int, int]) { + l.Add(1, 1) + l.Add(2, 2) + contains, evict := l.ContainsOrAdd(1, 1) + if !contains { + t.Errorf("1 should be contained") + } + if evict { + t.Errorf("nothing should be evicted here") + } + + l.Add(3, 3) + contains, evict = l.ContainsOrAdd(1, 1) + if contains { + t.Errorf("1 should not have been contained") + } + if !evict { + t.Errorf("an eviction should have occurred") + } + if !l.Contains(1) { + t.Errorf("now 1 should be contained") + } + } + + t.Run(" LRU ContainsOrAdd ", func(t *testing.T) { + l, err := New[int, int](2) + if err != nil { + t.Fatalf("err: %v", err) + } + + containsOrAdd(t, l) + }) + + t.Run(" Sieve ContainsOrAdd ", func(t *testing.T) { + l, err := NewWithOpts[int, int](2, WithSieve[int, int]()) + if err != nil { + t.Fatalf("err: %v", err) + } + + containsOrAdd(t, l) + }) +} + +// test that PeekOrAdd doesn't update recent-ness +func TestPeekOrAdd(t *testing.T) { + var peekOrAdd = func(t *testing.T, l *Cache[int, int]) { + l.Add(1, 1) + l.Add(2, 2) + previous, contains, evict := l.PeekOrAdd(1, 1) + if !contains { + t.Errorf("1 should be contained") + } + if evict { + t.Errorf("nothing should be evicted here") + } + if previous != 1 { + t.Errorf("previous is not equal to 1") + } + + l.Add(3, 3) + contains, evict = l.ContainsOrAdd(1, 1) + if contains { + t.Errorf("1 should not have been contained") + } + if !evict { + t.Errorf("an eviction should have occurred") + } + if !l.Contains(1) { + t.Errorf("now 1 should be contained") + } + } + + t.Run("LRU PeekOrAdd", func(t *testing.T) { + l, err := New[int, int](2) + if err != nil { + t.Fatalf("err: %v", err) + } + + peekOrAdd(t, l) + }) + + t.Run("Sieve PeekOrAdd", func(t *testing.T) { + l, err := NewWithOpts[int, int](2) + if err != nil { + t.Fatalf("err: %v", err) + } + + peekOrAdd(t, l) + }) +} + +// test that Peek doesn't update recent-ness +func TestPeek(t *testing.T) { + var peek = func(t *testing.T, l *Cache[int, int]) { + l.Add(1, 1) + l.Add(2, 2) + if v, ok := l.Peek(1); !ok || v != 1 { + t.Errorf("1 should be set to 1: %v, %v", v, ok) + } + + l.Add(3, 3) + if l.Contains(1) { + t.Errorf("should not have updated recent-ness of 1") + } + } + + t.Run("LRU Peek", func(t *testing.T) { + l, err := New[int, int](2) + if err != nil { + t.Fatalf("err: %v", err) + } + + peek(t, l) + }) + + t.Run("Sieve Peek", func(t *testing.T) { + l, err := NewWithOpts[int, int](2) + if err != nil { + t.Fatalf("err: %v", err) + } + + peek(t, l) + }) +} + +// test that Resize can upsize and downsize +func TestResize(t *testing.T) { + var onEvictCounter int + var resize = func(t *testing.T, l *Cache[int, int]) { + // Downsize + l.Add(1, 1) + l.Add(2, 2) + evicted := l.Resize(1) + if evicted != 1 { + t.Errorf("1 element should have been evicted: %v", evicted) + } + if onEvictCounter != 1 { + t.Errorf("onEvicted should have been called 1 time: %v", onEvictCounter) + } + + l.Add(3, 3) + if l.Contains(1) { + t.Errorf("Element 1 should have been evicted") + } + + // Upsize + evicted = l.Resize(2) + if evicted != 0 { + t.Errorf("0 elements should have been evicted: %v", evicted) + } + + l.Add(4, 4) + if !l.Contains(3) || !l.Contains(4) { + t.Errorf("Cache should have contained 2 elements") + } + + } + + t.Run("LRU resize", func(t *testing.T) { + onEvictCounter = 0 + onEvicted := func(k int, v int) { + onEvictCounter++ + } + l, err := NewWithEvict(2, onEvicted) + if err != nil { + t.Fatalf("err: %v", err) + } + + resize(t, l) + }) + + t.Run("Sieve resize", func(t *testing.T) { + onEvictCounter = 0 + onEvicted := func(k int, v int) { + onEvictCounter++ + } + + l, err := NewWithOpts[int, int](2, WithSieve[int, int](), WithCallback[int, int](onEvicted)) + if err != nil { + t.Fatalf("err: %v", err) + } + + resize(t, l) + }) +} diff --git a/go.mod b/go.mod index 8aaa473..3605003 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,3 @@ module github.com/hashicorp/golang-lru/v2 -go 1.18 +go 1.18 \ No newline at end of file diff --git a/internal/list.go b/internal/list.go index 5cd74a0..8886102 100644 --- a/internal/list.go +++ b/internal/list.go @@ -29,6 +29,9 @@ type Entry[K comparable, V any] struct { // The expiry bucket item was put in, optional ExpireBucket uint8 + + // Visited to keep track if an entry has been recently accessed, used for sieve-eviction + Visited bool } // PrevEntry returns the previous list element or nil. diff --git a/lru.go b/lru.go index 2bb07fd..a967a93 100644 --- a/lru.go +++ b/lru.go @@ -21,6 +21,7 @@ type Cache[K comparable, V any] struct { evictedVals []V onEvictedCB func(k K, v V) lock sync.RWMutex + sieveOpt bool } // New creates an LRU of the given size. @@ -35,14 +36,56 @@ func NewWithEvict[K comparable, V any](size int, onEvicted func(key K, value V)) c = &Cache[K, V]{ onEvictedCB: onEvicted, } + if onEvicted != nil { c.initEvictBuffers() onEvicted = c.onEvicted } + c.lru, err = simplelru.NewLRU(size, onEvicted) return } +// WithCallback returns a Option with eviction callback. +func WithCallback[K comparable, V any](onEvicted func(key K, value V)) Option[K, V] { + return func(c *Cache[K, V]) { + c.onEvictedCB = onEvicted + + if onEvicted != nil { + c.initEvictBuffers() + } + } +} + +// WithSieve returns a Option that enables sieve +func WithSieve[K comparable, V any]() Option[K, V] { + return func(c *Cache[K, V]) { + c.sieveOpt = true + } +} + +// Option is used to set options for the cache. +type Option[K comparable, V any] func(*Cache[K, V]) + +// NewWithOpts helps create a LRU cache with option with options. +func NewWithOpts[K comparable, V any](size int, opts ...Option[K, V]) (c *Cache[K, V], err error) { + // create a cache with default settings + c = &Cache[K, V]{ + onEvictedCB: nil, + } + + for _, opt := range opts { + opt(c) + } + + if c.sieveOpt { + c.lru, err = simplelru.NewSieve(size, c.onEvicted) + } else { + c.lru, err = simplelru.NewLRU(size, c.onEvicted) + } + return c, err +} + func (c *Cache[K, V]) initEvictBuffers() { c.evictedKeys = make([]K, 0, DefaultEvictedBufferSize) c.evictedVals = make([]V, 0, DefaultEvictedBufferSize) diff --git a/lru_test.go b/lru_test.go index 7ecc7ae..2455ad3 100644 --- a/lru_test.go +++ b/lru_test.go @@ -8,65 +8,6 @@ import ( "testing" ) -func BenchmarkLRU_Rand(b *testing.B) { - l, err := New[int64, int64](8192) - if err != nil { - b.Fatalf("err: %v", err) - } - - 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(b *testing.B) { - l, err := New[int64, int64](8192) - if err != nil { - b.Fatalf("err: %v", err) - } - - 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 TestLRU(t *testing.T) { evictCounter := 0 onEvicted := func(k int, v int) { @@ -75,6 +16,7 @@ func TestLRU(t *testing.T) { } evictCounter++ } + l, err := NewWithEvict(128, onEvicted) if err != nil { t.Fatalf("err: %v", err) @@ -138,26 +80,6 @@ func TestLRU(t *testing.T) { } } -// test that Add returns true/false if an eviction occurred -func TestLRUAdd(t *testing.T) { - evictCounter := 0 - onEvicted := func(k int, v int) { - evictCounter++ - } - - l, err := NewWithEvict(1, onEvicted) - if err != nil { - t.Fatalf("err: %v", err) - } - - if l.Add(1, 1) == true || evictCounter != 0 { - t.Errorf("should not have an eviction") - } - if l.Add(2, 2) == false || evictCounter != 1 { - t.Errorf("should have an eviction") - } -} - // test that Contains doesn't update recent-ness func TestLRUContains(t *testing.T) { l, err := New[int, int](2) @@ -177,127 +99,6 @@ func TestLRUContains(t *testing.T) { } } -// test that ContainsOrAdd doesn't update recent-ness -func TestLRUContainsOrAdd(t *testing.T) { - l, err := New[int, int](2) - if err != nil { - t.Fatalf("err: %v", err) - } - - l.Add(1, 1) - l.Add(2, 2) - contains, evict := l.ContainsOrAdd(1, 1) - if !contains { - t.Errorf("1 should be contained") - } - if evict { - t.Errorf("nothing should be evicted here") - } - - l.Add(3, 3) - contains, evict = l.ContainsOrAdd(1, 1) - if contains { - t.Errorf("1 should not have been contained") - } - if !evict { - t.Errorf("an eviction should have occurred") - } - if !l.Contains(1) { - t.Errorf("now 1 should be contained") - } -} - -// test that PeekOrAdd doesn't update recent-ness -func TestLRUPeekOrAdd(t *testing.T) { - l, err := New[int, int](2) - if err != nil { - t.Fatalf("err: %v", err) - } - - l.Add(1, 1) - l.Add(2, 2) - previous, contains, evict := l.PeekOrAdd(1, 1) - if !contains { - t.Errorf("1 should be contained") - } - if evict { - t.Errorf("nothing should be evicted here") - } - if previous != 1 { - t.Errorf("previous is not equal to 1") - } - - l.Add(3, 3) - contains, evict = l.ContainsOrAdd(1, 1) - if contains { - t.Errorf("1 should not have been contained") - } - if !evict { - t.Errorf("an eviction should have occurred") - } - if !l.Contains(1) { - t.Errorf("now 1 should be contained") - } -} - -// test that Peek doesn't update recent-ness -func TestLRUPeek(t *testing.T) { - l, err := New[int, int](2) - if err != nil { - t.Fatalf("err: %v", err) - } - - l.Add(1, 1) - l.Add(2, 2) - if v, ok := l.Peek(1); !ok || v != 1 { - t.Errorf("1 should be set to 1: %v, %v", v, ok) - } - - l.Add(3, 3) - if l.Contains(1) { - t.Errorf("should not have updated recent-ness of 1") - } -} - -// test that Resize can upsize and downsize -func TestLRUResize(t *testing.T) { - onEvictCounter := 0 - onEvicted := func(k int, v int) { - onEvictCounter++ - } - l, err := NewWithEvict(2, onEvicted) - if err != nil { - t.Fatalf("err: %v", err) - } - - // Downsize - l.Add(1, 1) - l.Add(2, 2) - evicted := l.Resize(1) - if evicted != 1 { - t.Errorf("1 element should have been evicted: %v", evicted) - } - if onEvictCounter != 1 { - t.Errorf("onEvicted should have been called 1 time: %v", onEvictCounter) - } - - l.Add(3, 3) - if l.Contains(1) { - t.Errorf("Element 1 should have been evicted") - } - - // Upsize - evicted = l.Resize(2) - if evicted != 0 { - t.Errorf("0 elements should have been evicted: %v", evicted) - } - - l.Add(4, 4) - if !l.Contains(3) || !l.Contains(4) { - t.Errorf("Cache should have contained 2 elements") - } -} - func (c *Cache[K, V]) wantKeys(t *testing.T, want []K) { t.Helper() got := c.Keys() diff --git a/sieve_test.go b/sieve_test.go new file mode 100644 index 0000000..5b391f4 --- /dev/null +++ b/sieve_test.go @@ -0,0 +1,234 @@ +package lru + +import ( + "fmt" + "reflect" + "testing" +) + +func TestSieve(t *testing.T) { + evictCounter := 0 + onEvicted := func(k int, v int) { + if k != v { + t.Fatalf("Evict values not equal (%v!=%v)", k, v) + } + evictCounter++ + } + + l, err := NewWithOpts[int, int](128, WithSieve[int, int](), WithCallback[int, int](onEvicted)) + if err != nil { + t.Fatalf("err: %v", err) + } + + for i := 0; i < 256; i++ { + l.Add(i, i) + } + if l.Len() != 128 { + t.Fatalf("bad len: %v", l.Len()) + } + if l.Cap() != 128 { + t.Fatalf("expect %d, but %d", 128, l.Cap()) + } + + if evictCounter != 128 { + t.Fatalf("bad evict count: %v", evictCounter) + } + + for i, k := range l.Keys() { + if v, ok := l.Get(k); !ok || v != k || v != i+128 { + t.Fatalf("bad key: %v", k) + } + } + for i, v := range l.Values() { + if v != i+128 { + t.Fatalf("bad value: %v", v) + } + } + for i := 0; i < 128; i++ { + if _, ok := l.Get(i); ok { + t.Fatalf("should be evicted") + } + } + for i := 128; i < 256; i++ { + if _, ok := l.Get(i); !ok { + t.Fatalf("should not be evicted") + } + } + for i := 128; i < 192; i++ { + l.Remove(i) + if _, ok := l.Get(i); ok { + t.Fatalf("should be deleted") + } + } + + l.GetOldest() + l.Get(192) // expect 192 to be last key in l.Keys() + l.RemoveOldest() // Remove oldest will set the 192 as visited, which would have not be removed. + + if _, ok := l.Get(192); !ok { + t.Fatalf("should not have been evcited") + } + + l.Purge() + if l.Len() != 0 { + t.Fatalf("bad len: %v", l.Len()) + } + + if _, ok := l.Get(200); ok { + t.Fatalf("should contain nothing") + } +} + +func (c *Cache[K, V]) checkKeys(t *testing.T, want []K) { + t.Helper() + got := c.Keys() + + existingKeys := make(map[K]struct{}) + for _, k := range got { + existingKeys[k] = struct{}{} + } + + if len(want) != len(existingKeys) { + t.Errorf("Expected Size: %d Actual: %d", len(want), len(existingKeys)) + } + + for _, wnt := range want { + if _, ok := existingKeys[wnt]; !ok { + t.Errorf("Expected %s to be present. ", fmt.Sprint(wnt)) + } + } +} + +func TestSieve_EvictionSameKey(t *testing.T) { + t.Run("Add", func(t *testing.T) { + var evictedKeys []int + + cache, _ := NewWithOpts[int, struct{}](2, WithSieve[int, struct{}](), WithCallback[int, struct{}](func(key int, _ struct{}) { + evictedKeys = append(evictedKeys, key) + })) + + if evicted := cache.Add(1, struct{}{}); evicted { + t.Error("First 1: got unexpected eviction") + } + cache.checkKeys(t, []int{1}) + + if evicted := cache.Add(2, struct{}{}); evicted { + t.Error("2: got unexpected eviction") + } + cache.checkKeys(t, []int{1, 2}) + + if evicted := cache.Add(1, struct{}{}); evicted { + t.Error("Second 1: got unexpected eviction") + } + cache.checkKeys(t, []int{2, 1}) + + if evicted := cache.Add(3, struct{}{}); !evicted { + t.Error("3: did not get expected eviction") + } + cache.checkKeys(t, []int{1, 3}) + + want := []int{2} + if !reflect.DeepEqual(evictedKeys, want) { + t.Errorf("evictedKeys got: %v want: %v", evictedKeys, want) + } + }) + + t.Run("ContainsOrAdd", func(t *testing.T) { + var evictedKeys []int + + cache, _ := NewWithOpts[int, struct{}](2, WithSieve[int, struct{}](), WithCallback[int, struct{}](func(key int, _ struct{}) { + evictedKeys = append(evictedKeys, key) + })) + + contained, evicted := cache.ContainsOrAdd(1, struct{}{}) + if contained { + t.Error("First 1: got unexpected contained") + } + if evicted { + t.Error("First 1: got unexpected eviction") + } + cache.checkKeys(t, []int{1}) + + contained, evicted = cache.ContainsOrAdd(2, struct{}{}) + if contained { + t.Error("2: got unexpected contained") + } + if evicted { + t.Error("2: got unexpected eviction") + } + cache.checkKeys(t, []int{1, 2}) + + contained, evicted = cache.ContainsOrAdd(1, struct{}{}) + if !contained { + t.Error("Second 1: did not get expected contained") + } + if evicted { + t.Error("Second 1: got unexpected eviction") + } + cache.checkKeys(t, []int{1, 2}) + + contained, evicted = cache.ContainsOrAdd(3, struct{}{}) + if contained { + t.Error("3: got unexpected contained") + } + if !evicted { + t.Error("3: did not get expected eviction") + } + cache.checkKeys(t, []int{2, 3}) + + want := []int{1} + if !reflect.DeepEqual(evictedKeys, want) { + t.Errorf("evictedKeys got: %v want: %v", evictedKeys, want) + } + }) + + t.Run("PeekOrAdd", func(t *testing.T) { + var evictedKeys []int + + cache, _ := NewWithOpts[int, struct{}](2, WithSieve[int, struct{}](), WithCallback[int, struct{}](func(key int, _ struct{}) { + evictedKeys = append(evictedKeys, key) + })) + + _, contained, evicted := cache.PeekOrAdd(1, struct{}{}) + if contained { + t.Error("First 1: got unexpected contained") + } + if evicted { + t.Error("First 1: got unexpected eviction") + } + cache.checkKeys(t, []int{1}) + + _, contained, evicted = cache.PeekOrAdd(2, struct{}{}) + if contained { + t.Error("2: got unexpected contained") + } + if evicted { + t.Error("2: got unexpected eviction") + } + cache.checkKeys(t, []int{1, 2}) + + _, contained, evicted = cache.PeekOrAdd(1, struct{}{}) + if !contained { + t.Error("Second 1: did not get expected contained") + } + if evicted { + t.Error("Second 1: got unexpected eviction") + } + cache.checkKeys(t, []int{1, 2}) + + _, contained, evicted = cache.PeekOrAdd(3, struct{}{}) + if contained { + t.Error("3: got unexpected contained") + } + if !evicted { + t.Error("3: did not get expected eviction") + } + cache.checkKeys(t, []int{2, 3}) + + want := []int{1} + if !reflect.DeepEqual(evictedKeys, want) { + t.Errorf("evictedKeys got: %v want: %v", evictedKeys, want) + } + }) + +} diff --git a/simplelru/lru.go b/simplelru/lru.go index 8f45d2e..8e4560f 100644 --- a/simplelru/lru.go +++ b/simplelru/lru.go @@ -12,12 +12,14 @@ import ( // 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 non-thread safe fixed size LRU cache +// LRU implements a non-thread safe fixed cache with LRU and SIEVE eviction (https://cachemon.github.io/SIEVE-website/) type LRU[K comparable, V any] struct { size int evictList *internal.LruList[K, V] items map[K]*internal.Entry[K, V] onEvict EvictCallback[K, V] + hand *internal.Entry[K, V] + useSieve bool } // NewLRU constructs an LRU of the given size @@ -32,6 +34,25 @@ func NewLRU[K comparable, V any](size int, onEvict EvictCallback[K, V]) (*LRU[K, items: make(map[K]*internal.Entry[K, V]), onEvict: onEvict, } + + return c, nil +} + +// NewSieve constructs a SIEVE of the given size +func NewSieve[K comparable, V any](size int, onEvict EvictCallback[K, V]) (*LRU[K, V], error) { + if size <= 0 { + return nil, errors.New("must provide a positive size") + } + + c := &LRU[K, V]{ + size: size, + evictList: internal.NewList[K, V](), + items: make(map[K]*internal.Entry[K, V]), + onEvict: onEvict, + hand: nil, + useSieve: true, + } + return c, nil } @@ -50,11 +71,28 @@ func (c *LRU[K, V]) Purge() { 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) + if c.useSieve { + ent.Visited = true + } else { + c.evictList.MoveToFront(ent) + } + ent.Value = value return false } + if c.useSieve { + if c.evictList.Length() >= c.size { + c.performSieveEviction() + evicted = true + } + + ent := c.evictList.PushFront(key, value) + ent.Visited = false + c.items[key] = ent + return + } + // Add new item ent := c.evictList.PushFront(key, value) c.items[key] = ent @@ -70,7 +108,12 @@ 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) + if c.useSieve { + ent.Visited = true + } else { + c.evictList.MoveToFront(ent) + } + return ent.Value, true } return @@ -93,6 +136,16 @@ func (c *LRU[K, V]) Peek(key K) (value V, ok bool) { return } +// visited returns if the key is visited +func (c *LRU[K, V]) visited(key K) (present bool, visited bool) { + var ent *internal.Entry[K, V] + if ent, present = c.items[key]; present { + visited = ent.Visited + } + + return +} + // Remove removes the provided key from the cache, returning if the // key was contained. func (c *LRU[K, V]) Remove(key K) (present bool) { @@ -105,6 +158,10 @@ 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 c.useSieve { + return c.performSieveEviction() + } + if ent := c.evictList.Back(); ent != nil { c.removeElement(ent) return ent.Key, ent.Value, true @@ -114,6 +171,15 @@ func (c *LRU[K, V]) RemoveOldest() (key K, value V, ok bool) { // GetOldest returns the oldest entry func (c *LRU[K, V]) GetOldest() (key K, value V, ok bool) { + if c.useSieve { + c.getSieveCandidate() + if c.hand != nil { + return c.hand.Key, c.hand.Value, true + } + + return + } + if ent := c.evictList.Back(); ent != nil { return ent.Key, ent.Value, true } @@ -159,14 +225,56 @@ func (c *LRU[K, V]) Resize(size int) (evicted int) { diff = 0 } for i := 0; i < diff; i++ { - c.removeOldest() + if c.useSieve { + c.performSieveEviction() + } else { + c.removeOldest() + } } + c.size = size return diff } +// performSieveEviction - runs a eviction by running Sieve Algorithm and returns the evicted value. +func (c *LRU[K, V]) performSieveEviction() (key K, value V, ok bool) { + c.getSieveCandidate() + if c.hand != nil { + candidate := c.hand + c.hand = c.hand.PrevEntry() + c.removeElement(candidate) + return candidate.Key, candidate.Value, true + } + + return +} + +// getSieveCandidate evicts an entry based on sieve algorithm. +func (c *LRU[K, V]) getSieveCandidate() { + if c.Len() == 0 { + return + } + + if c.hand == nil { + c.hand = c.evictList.Back() + } + + for c.hand != nil && c.hand.Visited { + c.hand.Visited = false + c.hand = c.hand.PrevEntry() + if c.hand == nil { + c.hand = c.evictList.Back() + } + } +} + // removeOldest removes the oldest item from the cache. func (c *LRU[K, V]) removeOldest() { + if c.useSieve { + c.performSieveEviction() + return + } + if ent := c.evictList.Back(); ent != nil { c.removeElement(ent) } diff --git a/simplelru/sieve_test.go b/simplelru/sieve_test.go new file mode 100644 index 0000000..214ee34 --- /dev/null +++ b/simplelru/sieve_test.go @@ -0,0 +1,298 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package simplelru + +import ( + "reflect" + "testing" +) + +func TestSieve(t *testing.T) { + evictCounter := 0 + onEvicted := func(k int, v int) { + if k != v { + t.Fatalf("Evict values not equal (%v!=%v)", k, v) + } + evictCounter++ + } + l, err := NewSieve(128, onEvicted) + if err != nil { + t.Fatalf("err: %v", err) + } + + for i := 0; i < 256; i++ { + l.Add(i, i) + } + if l.Len() != 128 { + t.Fatalf("bad len: %v", l.Len()) + } + if l.Cap() != 128 { + t.Fatalf("expect %d, but %d", 128, l.Cap()) + } + + if evictCounter != 128 { + t.Fatalf("bad evict count: %v", evictCounter) + } + + for i, k := range l.Keys() { + if v, ok := l.Get(k); !ok || v != k || v != i+128 { + t.Fatalf("bad key: %v", k) + } + } + for i, v := range l.Values() { + if v != i+128 { + t.Fatalf("bad value: %v", v) + } + } + for i := 0; i < 128; i++ { + if _, ok := l.Get(i); ok { + t.Fatalf("should be evicted") + } + } + for i := 128; i < 256; i++ { + if _, ok := l.Get(i); !ok { + t.Fatalf("should not be evicted") + } + } + for i := 128; i < 192; i++ { + if ok := l.Remove(i); !ok { + t.Fatalf("should be contained") + } + if ok := l.Remove(i); ok { + t.Fatalf("should not be contained") + } + if _, ok := l.Get(i); ok { + t.Fatalf("should be deleted") + } + } + + l.Get(192) // expect 192 to be last key in l.Keys() + + l.Purge() + if l.Len() != 0 { + t.Fatalf("bad len: %v", l.Len()) + } + if _, ok := l.Get(200); ok { + t.Fatalf("should contain nothing") + } +} + +func TestSieve_GetOldest_RemoveOldest(t *testing.T) { + l, err := NewSieve[int, int](128, nil) + if err != nil { + t.Fatalf("err: %v", err) + } + for i := 0; i < 256; i++ { + l.Add(i, i) + } + k, _, ok := l.GetOldest() + if !ok { + t.Fatalf("missing") + } + if k != 128 { + t.Fatalf("bad: %v", k) + } + + k, _, ok = l.RemoveOldest() + if !ok { + t.Fatalf("missing") + } + if k != 128 { + t.Fatalf("bad: %v", k) + } + + k, _, ok = l.RemoveOldest() + if !ok { + t.Fatalf("missing") + } + if k != 129 { + t.Fatalf("bad: %v", k) + } +} + +// Test that Add returns true/false if an eviction occurred +func TestSieve_Add(t *testing.T) { + evictCounter := 0 + onEvicted := func(k int, v int) { + evictCounter++ + } + + l, err := NewSieve(1, onEvicted) + if err != nil { + t.Fatalf("err: %v", err) + } + + if l.Add(1, 1) == true || evictCounter != 0 { + t.Errorf("should not have an eviction") + } + if l.Add(2, 2) == false || evictCounter != 1 { + t.Errorf("should have an eviction") + } +} + +// Test that Contains doesn't update recent-ness +func TestSieve_Contains(t *testing.T) { + l, err := NewSieve[int, int](2, nil) + if err != nil { + t.Fatalf("err: %v", err) + } + + l.Add(1, 1) + l.Add(2, 2) + if !l.Contains(1) { + t.Errorf("1 should be contained") + } + + l.Add(3, 3) + if l.Contains(1) { + t.Errorf("Contains should not have updated recent-ness of 1") + } +} + +// Test that Peek doesn't update recent-ness +func TestSieve_Peek(t *testing.T) { + l, err := NewSieve[int, int](2, nil) + if err != nil { + t.Fatalf("err: %v", err) + } + + l.Add(1, 1) + l.Add(2, 2) + if v, ok := l.Peek(1); !ok || v != 1 { + t.Errorf("1 should be set to 1: %v, %v", v, ok) + } + + l.Add(3, 3) + if l.Contains(1) { + t.Errorf("should not have updated recent-ness of 1") + } +} + +// Test that Resize can upsize and downsize +func TestSieve_Resize(t *testing.T) { + onEvictCounter := 0 + onEvicted := func(k int, v int) { + onEvictCounter++ + } + l, err := NewSieve(2, onEvicted) + if err != nil { + t.Fatalf("err: %v", err) + } + + // Downsize + l.Add(1, 1) + l.Add(2, 2) + evicted := l.Resize(1) + if evicted != 1 { + t.Errorf("1 element should have been evicted: %v", evicted) + } + if onEvictCounter != 1 { + t.Errorf("onEvicted should have been called 1 time: %v", onEvictCounter) + } + + l.Add(3, 3) + if l.Contains(1) { + t.Errorf("Element 1 should have been evicted") + } + + // Upsize + evicted = l.Resize(2) + if evicted != 0 { + t.Errorf("0 elements should have been evicted: %v", evicted) + } + + l.Add(4, 4) + if !l.Contains(3) || !l.Contains(4) { + t.Errorf("Cache should have contained 2 elements") + } +} + +func TestSieve_EvictionSameKey(t *testing.T) { + var evictedKeys []int + + cache, _ := NewSieve( + 2, + func(key int, _ struct{}) { + evictedKeys = append(evictedKeys, key) + }) + + if evicted := cache.Add(1, struct{}{}); evicted { + t.Error("First 1: got unexpected eviction") + } + cache.wantKeys(t, []int{1}) + + if evicted := cache.Add(2, struct{}{}); evicted { + t.Error("2: got unexpected eviction") + } + cache.wantKeys(t, []int{1, 2}) + + for _, k := range []int{1, 2} { + present, visited := cache.visited(k) + if !present || visited { + t.Errorf("Expecting both the keys to be present without visited being set present:%v visited:%v", present, visited) + } + } + + if evicted := cache.Add(1, struct{}{}); evicted { + t.Error("Second 1: got unexpected eviction") + } + + present, visited := cache.visited(1) + if !present || !visited { + t.Errorf("Expecting the key to be visited and present , Actual present:%v visited:%v", present, visited) + } + + present, visited = cache.visited(2) + if !present || visited { + t.Errorf("Expecting the key to be not visited and present , Actual present:%v visited:%v", present, visited) + } + + if evicted := cache.Add(2, struct{}{}); evicted { + t.Error("Second 1: got unexpected eviction") + } + + for _, k := range []int{2, 1} { + present, visited := cache.visited(k) + if !present || !visited { + t.Errorf("Expecting both the keys to be present and visited, Actual present:%v visited:%v", present, visited) + } + } + + if evicted := cache.Add(3, struct{}{}); !evicted { + t.Error("3: did not get expected eviction") + } + + cache.wantKeys(t, []int{2, 3}) + if evicted := cache.Add(4, struct{}{}); !evicted { + t.Error("4: did not get expected eviction") + } + + cache.wantKeys(t, []int{3, 4}) + if _, ok := cache.Get(3); !ok { + t.Errorf("3 should be present in cache") + } + + present, visited = cache.visited(3) + if !present || !visited { + t.Errorf("Expecting the key to be present and visited, Actual present:%v visited:%v", present, visited) + } + + present, visited = cache.visited(4) + if !present || visited { + t.Errorf("Expecting the key to be present and not visited, Actual present:%v visited:%v", present, visited) + } + + if evicted := cache.Add(1, struct{}{}); !evicted { + t.Error("1: did not get expected eviction") + } + + cache.wantKeys(t, []int{3, 1}) + + want := []int{1, 2, 4} + if !reflect.DeepEqual(evictedKeys, want) { + t.Errorf("evictedKeys got: %v want: %v", evictedKeys, want) + } +} + +