Skip to content

Commit

Permalink
add expirable LRU implementation
Browse files Browse the repository at this point in the history
  • Loading branch information
paskal committed Nov 14, 2022
1 parent 6ce8a74 commit a1ae7b4
Show file tree
Hide file tree
Showing 5 changed files with 704 additions and 9 deletions.
74 changes: 65 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,73 @@ 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
l, _ := New(128)
for i := 0; i < 256; i++ {
l.Add(i, nil)
package main

import (
"fmt"

"github.com/hashicorp/golang-lru/v2"
)

func main() {
l, _ := lru.New[int, *string](128)
for i := 0; i < 256; i++ {
l.Add(i, nil)
}

if l.Len() != 128 {
panic(fmt.Sprintf("bad len: %v", l.Len()))
}
}
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*4, 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\n", cache.Len())
// Output:
// value before expiration is found: true, value: "val1"
// value after expiration is found: false, value: ""
// Cache len: 1
}
```
276 changes: 276 additions & 0 deletions simplelru/expirable_lru.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,276 @@
package simplelru

import (
"container/list"
"sync"
"time"
)

// ExpirableLRU implements a thread safe LRU with expirable entries.
type ExpirableLRU[K comparable, V any] struct {
size int
purgeEvery time.Duration
ttl time.Duration
done chan struct{}
onEvicted EvictCallback[K, V]

sync.Mutex
items map[K]*list.Element
evictList *list.List
}

// noEvictionTTL - very long ttl to prevent eviction
const noEvictionTTL = time.Hour * 24 * 365 * 10

// NewExpirableLRU returns a new cache with expirable entries.
//
// Size parameter set to 0 makes cache of unlimited size.
//
// 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: map[K]*list.Element{},
evictList: list.New(),
ttl: ttl,
purgeEvery: purgeEvery,
size: size,
onEvicted: 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 = time.Minute * 5 // non-zero purge enforced because size defined
}
go func(done <-chan struct{}) {
ticker := time.NewTicker(res.purgeEvery)
for {
select {
case <-done:
return
case <-ticker.C:
res.Lock()
res.deleteExpired()
res.Unlock()
}
}
}(res.done)
}
return &res
}

// Add key
func (c *ExpirableLRU[K, V]) Add(key K, value V) (evicted bool) {
c.Lock()
defer c.Unlock()
now := time.Now()

// Check for existing item
if ent, ok := c.items[key]; ok {
c.evictList.MoveToFront(ent)
ent.Value.(*expirableEntry[K, V]).value = value
ent.Value.(*expirableEntry[K, V]).expiresAt = now.Add(c.ttl)
return false
}

// Add new item
ent := &expirableEntry[K, V]{key: key, value: value, expiresAt: now.Add(c.ttl)}
entry := c.evictList.PushFront(ent)
c.items[key] = entry

// Verify size not exceeded
if c.size > 0 && len(c.items) > c.size {
c.removeOldest()
return true
}
return false
}

// Get returns the key value
func (c *ExpirableLRU[K, V]) Get(key K) (value V, ok bool) {
c.Lock()
defer c.Unlock()
if ent, found := c.items[key]; found {
// Expired item check
if time.Now().After(ent.Value.(*expirableEntry[K, V]).expiresAt) {
return
}
c.evictList.MoveToFront(ent)
return ent.Value.(*expirableEntry[K, V]).value, true
}
return
}

// 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.Lock()
defer c.Unlock()
if ent, found := c.items[key]; found {
// Expired item check
if time.Now().After(ent.Value.(*expirableEntry[K, V]).expiresAt) {
return
}
return ent.Value.(*expirableEntry[K, V]).value, true
}
return
}

// GetOldest returns the oldest entry
func (c *ExpirableLRU[K, V]) GetOldest() (key K, value V, ok bool) {
c.Lock()
defer c.Unlock()
ent := c.evictList.Back()
if ent != nil {
kv := ent.Value.(*expirableEntry[K, V])
return kv.key, kv.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.Lock()
defer c.Unlock()
_, ok = c.items[key]
return ok
}

// Remove key from the cache
func (c *ExpirableLRU[K, V]) Remove(key K) bool {
c.Lock()
defer c.Unlock()
if ent, ok := c.items[key]; ok {
c.removeElement(ent)
return true
}
return false
}

// RemoveOldest removes the oldest item from the cache.
func (c *ExpirableLRU[K, V]) RemoveOldest() (key K, value V, ok bool) {
c.Lock()
defer c.Unlock()
ent := c.evictList.Back()
if ent != nil {
c.removeElement(ent)
kv := ent.Value.(*expirableEntry[K, V])
return kv.key, kv.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.Lock()
defer c.Unlock()
return c.keys()
}

// Purge clears the cache completely.
func (c *ExpirableLRU[K, V]) Purge() {
c.Lock()
defer c.Unlock()
for k, v := range c.items {
if c.onEvicted != nil {
c.onEvicted(k, v.Value.(*expirableEntry[K, V]).value)
}
delete(c.items, k)
}
c.evictList.Init()
}

// DeleteExpired clears cache of expired items
func (c *ExpirableLRU[K, V]) DeleteExpired() {
c.Lock()
defer c.Unlock()
c.deleteExpired()
}

// Len return count of items in cache
func (c *ExpirableLRU[K, V]) Len() int {
c.Lock()
defer c.Unlock()
return c.evictList.Len()
}

// Resize changes the cache size. size 0 doesn't resize the cache, as it means unlimited.
func (c *ExpirableLRU[K, V]) Resize(size int) (evicted int) {
if size <= 0 {
return 0
}
c.Lock()
defer c.Unlock()
diff := c.evictList.Len() - 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.Lock()
defer c.Unlock()
close(c.done)
}

// removeOldest removes the oldest item from the cache. Has to be called with lock!
func (c *ExpirableLRU[K, V]) removeOldest() {
ent := c.evictList.Back()
if ent != nil {
c.removeElement(ent)
}
}

// 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.Prev() {
keys = append(keys, ent.Value.(*expirableEntry[K, V]).key)
}
return keys
}

// 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 *list.Element) {
c.evictList.Remove(e)
kv := e.Value.(*expirableEntry[K, V])
delete(c.items, kv.key)
if c.onEvicted != nil {
c.onEvicted(kv.key, kv.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].Value.(*expirableEntry[K, V]).expiresAt) {
c.removeElement(c.items[key])
continue
}
}
}

// expirableEntry is used to hold a value in the evictList
type expirableEntry[K comparable, V any] struct {
key K
value V
expiresAt time.Time
}
Loading

0 comments on commit a1ae7b4

Please sign in to comment.