Skip to content

Commit

Permalink
add expiration per entry for expirable LRU
Browse files Browse the repository at this point in the history
  • Loading branch information
paskal committed Apr 18, 2023
1 parent ea5e108 commit dd3daea
Show file tree
Hide file tree
Showing 5 changed files with 67 additions and 90 deletions.
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,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 5ms TTL and 3 max keys
cache := simplelru.NewExpirableLRU[string, string](3, nil, time.Millisecond*5)
// expirable cache need to be closed after used
defer cache.Close()

Expand All @@ -63,7 +63,7 @@ func main() {
fmt.Printf("value before expiration is found: %v, value: %q\n", ok, r)
}

time.Sleep(time.Millisecond * 11)
time.Sleep(time.Millisecond * 10)

// get value under key1 after key expiration
r, ok = cache.Get("key1")
Expand Down
93 changes: 36 additions & 57 deletions simplelru/expirable_lru.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,27 +11,22 @@ type ExpirableLRU[K comparable, V any] struct {
evictList *lruList[K, V]
items map[K]*entry[K, V]
onEvict EvictCallback[K, V]
timers map[K]*time.Timer

// expirable options
mu sync.Mutex
purgeEvery time.Duration
ttl time.Duration
done chan struct{}
mu sync.Mutex
ttl time.Duration
}

// 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] {
func NewExpirableLRU[K comparable, V any](size int, onEvict EvictCallback[K, V], ttl time.Duration) *ExpirableLRU[K, V] {
if size < 0 {
size = 0
}
Expand All @@ -40,35 +35,14 @@ 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{}),
items: make(map[K]*entry[K, V]),
evictList: newList[K, V](),
ttl: ttl,
size: size,
onEvict: onEvict,
timers: make(map[K]*time.Timer),
}

// 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
}

Expand All @@ -81,6 +55,7 @@ func (c *ExpirableLRU[K, V]) Purge() {
if c.onEvict != nil {
c.onEvict(k, v.value)
}
c.deleteTimer(k)
delete(c.items, k)
}
c.evictList.init()
Expand All @@ -92,19 +67,21 @@ func (c *ExpirableLRU[K, V]) Purge() {
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)
c.deleteTimer(key)
c.createTimer(key, c.ttl)
return false
}

// Add new item
c.items[key] = c.evictList.pushFront(key, value, now.Add(c.ttl))
c.items[key] = c.evictList.pushFront(key, value)

// Create a timer for the new item
c.createTimer(key, c.ttl)
// Verify size not exceeded
if c.size > 0 && len(c.items) > c.size {
c.removeOldest()
Expand All @@ -118,10 +95,6 @@ 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
}
Expand All @@ -143,10 +116,6 @@ 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
Expand Down Expand Up @@ -222,12 +191,9 @@ func (c *ExpirableLRU[K, V]) Resize(size int) (evicted int) {
func (c *ExpirableLRU[K, V]) Close() {
c.mu.Lock()
defer c.mu.Unlock()
select {
case <-c.done:
return
default:
for _, e := range c.items {
c.removeElement(e)
}
close(c.done)
}

// removeOldest removes the oldest item from the cache. Has to be called with lock!
Expand All @@ -240,18 +206,31 @@ func (c *ExpirableLRU[K, V]) removeOldest() {
// 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)
c.deleteTimer(e.key)
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])
// createTimer creates a timer for the given key with the provided duration.
// When the timer expires, the key will be removed from the cache.
func (c *ExpirableLRU[K, V]) createTimer(key K, duration time.Duration) {
timer := time.AfterFunc(duration, func() {
c.mu.Lock()
defer c.mu.Unlock()
if ent, ok := c.items[key]; ok {
c.removeElement(ent)
}
})
c.timers[key] = timer
}

// deleteTimer deletes the timer associated with the given key.
func (c *ExpirableLRU[K, V]) deleteTimer(key K) {
if timer, ok := c.timers[key]; ok {
timer.Stop()
delete(c.timers, key)
}
}

Expand Down
40 changes: 22 additions & 18 deletions simplelru/expirable_lru_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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++ {
Expand All @@ -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++ {
Expand Down Expand Up @@ -65,7 +65,7 @@ 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)

trace := make([]int64, b.N*2)
for i := 0; i < b.N*2; i++ {
Expand All @@ -90,7 +90,7 @@ 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)

trace := make([]int64, b.N*2)
for i := 0; i < b.N*2; i++ {
Expand All @@ -117,12 +117,12 @@ 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]{}
}

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 {
Expand Down Expand Up @@ -168,16 +168,16 @@ func TestExpirableLRUNoPurge(t *testing.T) {
}
}

func TestExpirableMultipleClose(t *testing.T) {
lc := NewExpirableLRU[string, string](10, nil, 0, 0)
func TestExpirableMultipleClose(_ *testing.T) {
lc := NewExpirableLRU[string, string](10, nil, 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)
lc := NewExpirableLRU(10, func(key string, value string) { evicted = append(evicted, key, value) }, 150*time.Millisecond)
defer lc.Close()

k, v, ok := lc.GetOldest()
Expand Down Expand Up @@ -240,7 +240,6 @@ func TestExpirableLRUWithPurge(t *testing.T) {
}

// DeleteExpired, nothing deleted
lc.deleteExpired()
if lc.Len() != 1 {
t.Fatalf("length differs from expected")
}
Expand All @@ -259,7 +258,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++ {
Expand All @@ -283,7 +282,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++ {
Expand All @@ -300,7 +299,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")
Expand Down Expand Up @@ -330,7 +329,7 @@ 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)

lc.Add("key1", "val1")
if lc.Len() != 1 {
Expand All @@ -353,10 +352,15 @@ func TestLoadingExpired(t *testing.T) {
t.Fatalf("should be true")
}

time.Sleep(time.Millisecond * 10) // wait for entry to expire
time.Sleep(time.Millisecond * 3) // entry should be still in cache
if lc.Len() != 1 {
t.Fatalf("length differs from expected")
} // but not purged
}

time.Sleep(time.Millisecond * 5) // wait for entry to expire
if lc.Len() != 0 {
t.Fatalf("length differs from expected")
}

v, ok = lc.Peek("key1")
if v != "" {
Expand All @@ -376,7 +380,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 != "" {
Expand Down Expand Up @@ -443,7 +447,7 @@ 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)
cache := NewExpirableLRU[string, string](3, nil, time.Millisecond*5)
// expirable cache need to be closed after used
defer cache.Close()

Expand Down
15 changes: 5 additions & 10 deletions simplelru/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,6 @@

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.
Expand All @@ -23,9 +21,6 @@ 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.
Expand Down Expand Up @@ -84,9 +79,9 @@ func (l *lruList[K, V]) insert(e, at *entry[K, V]) *entry[K, V] {
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)
// 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)
}

// remove removes e from its list, decrements l.len
Expand Down Expand Up @@ -116,9 +111,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, 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, expiresAt, &l.root)
return l.insertValue(k, v, &l.root)
}

// moveToFront moves element e to the front of list l.
Expand Down
Loading

0 comments on commit dd3daea

Please sign in to comment.