Skip to content

Commit

Permalink
add expirable LRU implementation
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
paskal committed Aug 5, 2023
1 parent 78003ed commit 738ef57
Show file tree
Hide file tree
Showing 5 changed files with 820 additions and 17 deletions.
67 changes: 56 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}
```
265 changes: 265 additions & 0 deletions simplelru/expirable_lru.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading

0 comments on commit 738ef57

Please sign in to comment.