mirror of
https://github.com/1f349/cache.git
synced 2024-12-22 08:04:06 +00:00
Add initial cache source
This commit is contained in:
parent
23174db9a8
commit
a5f190e2fb
244
cache.go
Normal file
244
cache.go
Normal file
@ -0,0 +1,244 @@
|
|||||||
|
package cache
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/MrMelon54/rescheduler"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// timeNow is an alias used for easier testing
|
||||||
|
var timeNow = time.Now
|
||||||
|
|
||||||
|
// timeUntil is the same as time.Until but uses the test specific timeNow
|
||||||
|
func timeUntil(t time.Time) time.Duration { return t.Sub(timeNow()) }
|
||||||
|
|
||||||
|
// Cache manages items in a sync.Map, items are allowed to be permanent or have
|
||||||
|
// an expiry time. The keys which have an expiry time are stored in chain.
|
||||||
|
// chainAdd and chainDel are used to add/remove items to/from chain. These
|
||||||
|
// actions are completed in the cleaner goroutine to be concurrency safe.
|
||||||
|
type Cache[K comparable, V any] struct {
|
||||||
|
items sync.Map
|
||||||
|
chain *keyed[K] // linked list of to-expire keys
|
||||||
|
sched *rescheduler.Rescheduler // TODO: add a mode to have the goroutine running only once after multiple Run calls
|
||||||
|
close chan struct{}
|
||||||
|
chainAdd chan keyed[K]
|
||||||
|
chainDel chan K
|
||||||
|
}
|
||||||
|
|
||||||
|
// keyed is the same as item but uses the key as the data for the chain based,
|
||||||
|
// item removal scheduler. It also contains the atomic pointer to the next item
|
||||||
|
// in the chain.
|
||||||
|
type keyed[K any] struct {
|
||||||
|
item[K]
|
||||||
|
next *keyed[K]
|
||||||
|
}
|
||||||
|
|
||||||
|
// item is a piece of data stored as a value in items or as a key in chain. The
|
||||||
|
// data is stored along with the expiry date for quick access.
|
||||||
|
type item[T any] struct {
|
||||||
|
data T
|
||||||
|
expires time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasExpired returns true if the expiry time is non-zero and is before the value
|
||||||
|
// of timeNow.
|
||||||
|
func (c item[T]) HasExpired() bool {
|
||||||
|
return !c.expires.IsZero() && c.expires.Before(timeNow())
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates a *Cache[K, V] ready to be used by the caller
|
||||||
|
func New[K comparable, V any]() *Cache[K, V] {
|
||||||
|
c := &Cache[K, V]{}
|
||||||
|
c.sched = rescheduler.NewRescheduler(c.cleaner)
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close simply sends a signal to stop the cleaner goroutine. The cache is left
|
||||||
|
// in its current state and can still be read. The only difference being that
|
||||||
|
// items which have expired will not be garbage collected.
|
||||||
|
func (c *Cache[K, V]) Close() {
|
||||||
|
close(c.close)
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleaner handles removing expired keys. The chainAdd and chainDel channels are
|
||||||
|
// handled here to prevent race conditions. This ensures the expiry timer can be
|
||||||
|
// stopped before modifying the chain.
|
||||||
|
//
|
||||||
|
// The cleaner is stopped whenever the chain is empty due to there being no chain
|
||||||
|
// to manage.
|
||||||
|
func (c *Cache[K, V]) cleaner() {
|
||||||
|
var t *time.Timer
|
||||||
|
if c.chain == nil {
|
||||||
|
// fake timer as this select isn't used for an empty chain
|
||||||
|
t = &time.Timer{C: make(chan time.Time)}
|
||||||
|
} else {
|
||||||
|
t = time.NewTimer(timeUntil(c.chain.expires))
|
||||||
|
}
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-c.close:
|
||||||
|
// exit the cleaner goroutine
|
||||||
|
return
|
||||||
|
case node := <-c.chainAdd:
|
||||||
|
// stop the timer safely
|
||||||
|
if !t.Stop() {
|
||||||
|
<-t.C
|
||||||
|
}
|
||||||
|
// the chain will not be empty after this insert so no check is required
|
||||||
|
c.chainInsert(node)
|
||||||
|
|
||||||
|
t.Reset(timeUntil(c.chain.expires))
|
||||||
|
case key := <-c.chainDel:
|
||||||
|
// stop the timer safely
|
||||||
|
if !t.Stop() {
|
||||||
|
<-t.C
|
||||||
|
}
|
||||||
|
c.chainSplice(key)
|
||||||
|
|
||||||
|
// if there is no chain then kill the expiry scheduler
|
||||||
|
if c.chain == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Reset(timeUntil(c.chain.expires))
|
||||||
|
case <-t.C:
|
||||||
|
// if there is no chain then kill the expiry scheduler
|
||||||
|
if c.chain == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// remove all expired entries
|
||||||
|
for c.chain.HasExpired() {
|
||||||
|
c.items.Delete(c.chain.data)
|
||||||
|
c.chain = c.chain.next
|
||||||
|
}
|
||||||
|
|
||||||
|
// if there is no chain then kill the expiry scheduler
|
||||||
|
if c.chain == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Reset(timeUntil(c.chain.expires))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Cache[K, V]) chainInsert(node keyed[K]) {
|
||||||
|
// quick path for an empty chain
|
||||||
|
if c.chain == nil {
|
||||||
|
c.chain = &node
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// loop through the chain to add an item
|
||||||
|
ring := c.chain
|
||||||
|
for {
|
||||||
|
if ring.next == nil {
|
||||||
|
// add as last item
|
||||||
|
ring.next = &node
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if ring.expires.After(node.expires) {
|
||||||
|
// add between two nodes
|
||||||
|
node.next = ring.next
|
||||||
|
ring.next = &node
|
||||||
|
break
|
||||||
|
}
|
||||||
|
// move to the next ring in the chain
|
||||||
|
ring = ring.next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Cache[K, V]) chainSplice(key K) {
|
||||||
|
// quick path if the first node matches
|
||||||
|
if c.chain.data == key {
|
||||||
|
node := c.chain
|
||||||
|
c.chain = node.next
|
||||||
|
node.next = nil
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// loop through the chain to find an item
|
||||||
|
ring := c.chain
|
||||||
|
for {
|
||||||
|
// if the node is nil then the end has been reached
|
||||||
|
node := ring.next
|
||||||
|
if node == nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
// if the node is found then snip it out
|
||||||
|
if node.data == key {
|
||||||
|
ring.next = node.next
|
||||||
|
node.next = nil
|
||||||
|
break
|
||||||
|
}
|
||||||
|
// move to the next ring in the chain
|
||||||
|
ring = node
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetExpires returns (value, expiry, true) for any existing key. Or (V{},
|
||||||
|
// time.Time{}, false) for any unknown key.
|
||||||
|
func (c *Cache[K, V]) GetExpires(key K) (V, time.Time, bool) {
|
||||||
|
var v V // empty value
|
||||||
|
|
||||||
|
obj, exists := c.items.Load(key)
|
||||||
|
if !exists {
|
||||||
|
return v, time.Time{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
i := obj.(*item[V])
|
||||||
|
if i.HasExpired() {
|
||||||
|
return v, time.Time{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
return i.data, i.expires, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get returns (value, true) for any existing key. Or (V{}, false) for any
|
||||||
|
// unknown key. This is equivalent to calling GetExpires and ignoring the expiry
|
||||||
|
// time return value.
|
||||||
|
func (c *Cache[K, V]) Get(key K) (V, bool) {
|
||||||
|
value, _, found := c.GetExpires(key)
|
||||||
|
return value, found
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetPermanent adds an item to the cache without an expiry date.
|
||||||
|
//
|
||||||
|
// If an item is added with the same key then this item with be overwritten.
|
||||||
|
func (c *Cache[K, V]) SetPermanent(key K, value V) {
|
||||||
|
i := &item[V]{data: value} // expires is not set here
|
||||||
|
c.items.Store(key, i)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set adds an item to the cache with an expiry date.
|
||||||
|
//
|
||||||
|
// If an item is added with the same key then this item with be overwritten.
|
||||||
|
func (c *Cache[K, V]) Set(key K, value V, expires time.Time) {
|
||||||
|
if expires.Before(timeNow()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
i := &item[V]{data: value, expires: expires}
|
||||||
|
c.items.Store(key, i)
|
||||||
|
c.chainAdd <- keyed[K]{item: item[K]{data: key, expires: expires}}
|
||||||
|
c.sched.Run()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Range calls f with every key-value pair, which has not expired, currently
|
||||||
|
// stored in the map.
|
||||||
|
//
|
||||||
|
// If f returns false, range stops the iteration.
|
||||||
|
//
|
||||||
|
// See sync/Map.Range for implementation specific details.
|
||||||
|
func (c *Cache[K, V]) Range(f func(key K, value V) bool) {
|
||||||
|
c.items.Range(func(key, value any) bool {
|
||||||
|
item := value.(*item[V])
|
||||||
|
if item.HasExpired() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return f(key.(K), value.(V))
|
||||||
|
})
|
||||||
|
}
|
16
cache_test.go
Normal file
16
cache_test.go
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
package cache
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestItem_HasExpired(t *testing.T) {
|
||||||
|
n := time.Now()
|
||||||
|
a := item[string]{expires: n}
|
||||||
|
timeNow = func() time.Time { return n.Add(time.Second) }
|
||||||
|
assert.True(t, a.HasExpired())
|
||||||
|
a = item[string]{expires: n.Add(time.Second * 2)}
|
||||||
|
assert.False(t, a.HasExpired())
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user