mirror of
https://github.com/steveiliop56/tinyauth.git
synced 2026-07-17 23:41:11 +00:00
refactor: export cache package (#1017)
This commit is contained in:
Vendored
+213
@@ -0,0 +1,213 @@
|
||||
package cache
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type CacheStoreActions[T any] struct {
|
||||
Set func(key string, value T, ttl time.Duration)
|
||||
Get func(key string) (T, bool)
|
||||
Delete func(key string)
|
||||
Update func(key string, value T, ttl time.Duration) bool
|
||||
}
|
||||
|
||||
type cacheEntry[T any] struct {
|
||||
value T
|
||||
expiresAt *time.Time
|
||||
}
|
||||
|
||||
type CacheStore[T any] struct {
|
||||
cache map[string]cacheEntry[T]
|
||||
order []string
|
||||
mu sync.RWMutex
|
||||
maxSize int
|
||||
}
|
||||
|
||||
func NewCacheStore[T any](maxSize int) *CacheStore[T] {
|
||||
return &CacheStore[T]{
|
||||
cache: make(map[string]cacheEntry[T]),
|
||||
order: make([]string, 0),
|
||||
maxSize: maxSize,
|
||||
}
|
||||
}
|
||||
|
||||
// WithLock allows performing multiple operations on a single lock.
|
||||
// The provided mutate function receives a set of actions (Set, Get, Delete, Update) that
|
||||
// can be used to manipulate the cache store within the locked context.
|
||||
func (cs *CacheStore[T]) WithLock(mutate func(actions CacheStoreActions[T])) {
|
||||
cs.mu.Lock()
|
||||
defer cs.mu.Unlock()
|
||||
actions := CacheStoreActions[T]{
|
||||
Set: cs.setCallback,
|
||||
Get: cs.getCallback,
|
||||
Delete: cs.deleteCallback,
|
||||
Update: cs.updateCallback,
|
||||
}
|
||||
mutate(actions)
|
||||
}
|
||||
|
||||
func (cs *CacheStore[T]) updateCallback(key string, value T, ttl time.Duration) bool {
|
||||
if currentEntry, exists := cs.cache[key]; exists {
|
||||
if currentEntry.expiresAt != nil && time.Now().After(*currentEntry.expiresAt) {
|
||||
return false
|
||||
}
|
||||
|
||||
entry := cacheEntry[T]{
|
||||
value: value,
|
||||
expiresAt: currentEntry.expiresAt,
|
||||
}
|
||||
|
||||
if ttl > 0 {
|
||||
expiration := time.Now().Add(ttl)
|
||||
entry.expiresAt = &expiration
|
||||
}
|
||||
|
||||
cs.cache[key] = entry
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func (cs *CacheStore[T]) Update(key string, value T, ttl time.Duration) bool {
|
||||
cs.mu.Lock()
|
||||
defer cs.mu.Unlock()
|
||||
return cs.updateCallback(key, value, ttl)
|
||||
}
|
||||
|
||||
func (cs *CacheStore[T]) setCallback(key string, value T, ttl time.Duration) {
|
||||
if cs.maxSize > 0 {
|
||||
if _, exists := cs.cache[key]; !exists && len(cs.cache) >= cs.maxSize {
|
||||
cs.evictOne()
|
||||
}
|
||||
}
|
||||
|
||||
var expiresAt *time.Time
|
||||
|
||||
if ttl > 0 {
|
||||
expiration := time.Now().Add(ttl)
|
||||
expiresAt = &expiration
|
||||
}
|
||||
|
||||
cs.cache[key] = cacheEntry[T]{
|
||||
value: value,
|
||||
expiresAt: expiresAt,
|
||||
}
|
||||
|
||||
if !slices.Contains(cs.order, key) {
|
||||
cs.order = append(cs.order, key)
|
||||
}
|
||||
}
|
||||
|
||||
func (cs *CacheStore[T]) Set(key string, value T, ttl time.Duration) {
|
||||
cs.mu.Lock()
|
||||
defer cs.mu.Unlock()
|
||||
cs.setCallback(key, value, ttl)
|
||||
}
|
||||
|
||||
func (cs *CacheStore[T]) getCallback(key string) (T, bool) {
|
||||
entry, exists := cs.cache[key]
|
||||
|
||||
if !exists {
|
||||
var zero T
|
||||
return zero, false
|
||||
}
|
||||
|
||||
if entry.expiresAt != nil && time.Now().After(*entry.expiresAt) {
|
||||
var zero T
|
||||
return zero, false
|
||||
}
|
||||
|
||||
return entry.value, true
|
||||
}
|
||||
|
||||
func (cs *CacheStore[T]) Get(key string) (T, bool) {
|
||||
cs.mu.RLock()
|
||||
defer cs.mu.RUnlock()
|
||||
return cs.getCallback(key)
|
||||
}
|
||||
|
||||
func (cs *CacheStore[T]) deleteCallback(key string) {
|
||||
delete(cs.cache, key)
|
||||
keyIdx := slices.Index(cs.order, key)
|
||||
if keyIdx != -1 {
|
||||
cs.order = append(cs.order[:keyIdx], cs.order[keyIdx+1:]...)
|
||||
}
|
||||
}
|
||||
|
||||
func (cs *CacheStore[T]) Delete(key string) {
|
||||
cs.mu.Lock()
|
||||
defer cs.mu.Unlock()
|
||||
cs.deleteCallback(key)
|
||||
}
|
||||
|
||||
func (cs *CacheStore[T]) Sweep() {
|
||||
cs.mu.Lock()
|
||||
for key, entry := range cs.cache {
|
||||
if entry.expiresAt != nil && time.Now().After(*entry.expiresAt) {
|
||||
cs.deleteCallback(key)
|
||||
}
|
||||
}
|
||||
cs.mu.Unlock()
|
||||
}
|
||||
|
||||
func (cs *CacheStore[T]) evictOne() bool {
|
||||
now := time.Now()
|
||||
var oldestKey string
|
||||
var oldestExp *time.Time
|
||||
|
||||
for k, e := range cs.cache {
|
||||
if e.expiresAt != nil && now.After(*e.expiresAt) {
|
||||
cs.deleteCallback(k)
|
||||
return true
|
||||
}
|
||||
if e.expiresAt != nil && (oldestExp == nil || e.expiresAt.Before(*oldestExp)) {
|
||||
oldestKey, oldestExp = k, e.expiresAt
|
||||
}
|
||||
}
|
||||
|
||||
// If we found an oldest key, evict it else we delete the first key in the order list
|
||||
if oldestKey != "" {
|
||||
cs.deleteCallback(oldestKey)
|
||||
return true
|
||||
} else {
|
||||
if len(cs.order) > 0 {
|
||||
cs.deleteCallback(cs.order[0])
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func (cs *CacheStore[T]) Size() int {
|
||||
cs.mu.RLock()
|
||||
defer cs.mu.RUnlock()
|
||||
return len(cs.cache)
|
||||
}
|
||||
|
||||
func (cs *CacheStore[T]) Clear() {
|
||||
cs.mu.Lock()
|
||||
defer cs.mu.Unlock()
|
||||
cs.cache = make(map[string]cacheEntry[T])
|
||||
cs.order = make([]string, 0)
|
||||
}
|
||||
|
||||
func (cs *CacheStore[T]) SetMaxSize(maxSize int) {
|
||||
cs.mu.Lock()
|
||||
defer cs.mu.Unlock()
|
||||
cs.maxSize = maxSize
|
||||
for len(cs.cache) > maxSize {
|
||||
if !cs.evictOne() {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
func (cs *CacheStore[T]) GetMaxSize() int {
|
||||
cs.mu.Lock()
|
||||
defer cs.mu.Unlock()
|
||||
return cs.maxSize
|
||||
}
|
||||
Vendored
+398
@@ -0,0 +1,398 @@
|
||||
package cache
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestCacheStoreGet(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setup func(cs *CacheStore[string])
|
||||
wantValue string
|
||||
wantOk bool
|
||||
}{
|
||||
{
|
||||
name: "returns a stored value",
|
||||
setup: func(cs *CacheStore[string]) { cs.Set("key", "value", 0) },
|
||||
wantValue: "value",
|
||||
wantOk: true,
|
||||
},
|
||||
{
|
||||
name: "reports a missing key",
|
||||
setup: func(cs *CacheStore[string]) {},
|
||||
wantOk: false,
|
||||
},
|
||||
{
|
||||
name: "returns the latest value after an overwrite",
|
||||
setup: func(cs *CacheStore[string]) {
|
||||
cs.Set("key", "first", 0)
|
||||
cs.Set("key", "second", 0)
|
||||
},
|
||||
wantValue: "second",
|
||||
wantOk: true,
|
||||
},
|
||||
{
|
||||
name: "returns a non-expired entry",
|
||||
setup: func(cs *CacheStore[string]) { cs.Set("key", "value", time.Minute) },
|
||||
wantValue: "value",
|
||||
wantOk: true,
|
||||
},
|
||||
{
|
||||
name: "treats an expired entry as missing",
|
||||
setup: func(cs *CacheStore[string]) {
|
||||
cs.Set("key", "value", 10*time.Millisecond)
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
},
|
||||
wantOk: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cs := NewCacheStore[string](0)
|
||||
tt.setup(cs)
|
||||
|
||||
value, ok := cs.Get("key")
|
||||
assert.Equal(t, tt.wantOk, ok)
|
||||
if tt.wantOk {
|
||||
assert.Equal(t, tt.wantValue, value)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCacheStoreUpdate(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setup func(cs *CacheStore[string])
|
||||
ttl time.Duration
|
||||
wantOk bool
|
||||
afterWait time.Duration
|
||||
wantPresent bool
|
||||
wantValue string
|
||||
}{
|
||||
{
|
||||
name: "updates an existing entry",
|
||||
setup: func(cs *CacheStore[string]) { cs.Set("key", "old", 0) },
|
||||
ttl: 0,
|
||||
wantOk: true,
|
||||
wantPresent: true,
|
||||
wantValue: "new",
|
||||
},
|
||||
{
|
||||
name: "does not create a missing entry",
|
||||
setup: func(cs *CacheStore[string]) {},
|
||||
ttl: 0,
|
||||
wantOk: false,
|
||||
wantPresent: false,
|
||||
},
|
||||
{
|
||||
name: "preserves the existing expiry when ttl is zero",
|
||||
setup: func(cs *CacheStore[string]) { cs.Set("key", "old", 30*time.Millisecond) },
|
||||
ttl: 0,
|
||||
wantOk: true,
|
||||
afterWait: 40 * time.Millisecond,
|
||||
wantPresent: false,
|
||||
},
|
||||
{
|
||||
name: "refreshes the expiry when ttl is provided",
|
||||
setup: func(cs *CacheStore[string]) { cs.Set("key", "old", 10*time.Millisecond) },
|
||||
ttl: time.Minute,
|
||||
wantOk: true,
|
||||
afterWait: 20 * time.Millisecond,
|
||||
wantPresent: true,
|
||||
wantValue: "new",
|
||||
},
|
||||
{
|
||||
name: "does not update an expired entry",
|
||||
setup: func(cs *CacheStore[string]) {
|
||||
cs.Set("key", "old", 10*time.Millisecond)
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
},
|
||||
ttl: time.Minute,
|
||||
wantOk: false,
|
||||
wantPresent: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cs := NewCacheStore[string](0)
|
||||
tt.setup(cs)
|
||||
|
||||
ok := cs.Update("key", "new", tt.ttl)
|
||||
assert.Equal(t, tt.wantOk, ok)
|
||||
|
||||
time.Sleep(tt.afterWait)
|
||||
|
||||
value, present := cs.Get("key")
|
||||
assert.Equal(t, tt.wantPresent, present)
|
||||
if tt.wantPresent {
|
||||
assert.Equal(t, tt.wantValue, value)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCacheStoreDelete(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setup func(cs *CacheStore[string])
|
||||
key string
|
||||
wantSize int
|
||||
}{
|
||||
{
|
||||
name: "removes an existing key",
|
||||
setup: func(cs *CacheStore[string]) {
|
||||
cs.Set("a", "1", 0)
|
||||
cs.Set("b", "2", 0)
|
||||
},
|
||||
key: "a",
|
||||
wantSize: 1,
|
||||
},
|
||||
{
|
||||
name: "is a no-op for a missing key",
|
||||
setup: func(cs *CacheStore[string]) { cs.Set("a", "1", 0) },
|
||||
key: "missing",
|
||||
wantSize: 1,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cs := NewCacheStore[string](0)
|
||||
tt.setup(cs)
|
||||
|
||||
cs.Delete(tt.key)
|
||||
|
||||
_, ok := cs.Get(tt.key)
|
||||
assert.False(t, ok)
|
||||
assert.Equal(t, tt.wantSize, cs.Size())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCacheStoreSweep(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setup func(cs *CacheStore[string])
|
||||
present []string
|
||||
absent []string
|
||||
wantSize int
|
||||
}{
|
||||
{
|
||||
name: "removes expired entries and keeps the rest",
|
||||
setup: func(cs *CacheStore[string]) {
|
||||
cs.Set("permanent", "value", 0)
|
||||
cs.Set("expired", "value", 10*time.Millisecond)
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
},
|
||||
present: []string{"permanent"},
|
||||
absent: []string{"expired"},
|
||||
wantSize: 1,
|
||||
},
|
||||
{
|
||||
name: "keeps all live entries",
|
||||
setup: func(cs *CacheStore[string]) {
|
||||
cs.Set("a", "value", 0)
|
||||
cs.Set("b", "value", time.Minute)
|
||||
},
|
||||
present: []string{"a", "b"},
|
||||
wantSize: 2,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cs := NewCacheStore[string](0)
|
||||
tt.setup(cs)
|
||||
|
||||
cs.Sweep()
|
||||
|
||||
for _, key := range tt.present {
|
||||
_, ok := cs.Get(key)
|
||||
assert.True(t, ok)
|
||||
}
|
||||
for _, key := range tt.absent {
|
||||
_, ok := cs.Get(key)
|
||||
assert.False(t, ok)
|
||||
}
|
||||
assert.Equal(t, tt.wantSize, cs.Size())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCacheStoreEviction(t *testing.T) {
|
||||
// Every case uses a cache with maxSize 2; the final Set in setup is the
|
||||
// insertion that overflows the cache and triggers an eviction.
|
||||
tests := []struct {
|
||||
name string
|
||||
setup func(cs *CacheStore[string])
|
||||
present []string
|
||||
absent []string
|
||||
wantSize int
|
||||
}{
|
||||
{
|
||||
name: "evicts an already expired entry first",
|
||||
setup: func(cs *CacheStore[string]) {
|
||||
cs.Set("expired", "value", 10*time.Millisecond)
|
||||
cs.Set("fresh", "value", time.Minute)
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
cs.Set("new", "value", time.Minute)
|
||||
},
|
||||
present: []string{"fresh", "new"},
|
||||
absent: []string{"expired"},
|
||||
wantSize: 2,
|
||||
},
|
||||
{
|
||||
name: "evicts the entry expiring soonest",
|
||||
setup: func(cs *CacheStore[string]) {
|
||||
cs.Set("soon", "value", 50*time.Millisecond)
|
||||
cs.Set("later", "value", time.Hour)
|
||||
cs.Set("new", "value", time.Hour)
|
||||
},
|
||||
present: []string{"later", "new"},
|
||||
absent: []string{"soon"},
|
||||
wantSize: 2,
|
||||
},
|
||||
{
|
||||
name: "evicts the oldest inserted entry when none have a ttl",
|
||||
setup: func(cs *CacheStore[string]) {
|
||||
cs.Set("first", "value", 0)
|
||||
cs.Set("second", "value", 0)
|
||||
cs.Set("third", "value", 0)
|
||||
},
|
||||
present: []string{"second", "third"},
|
||||
absent: []string{"first"},
|
||||
wantSize: 2,
|
||||
},
|
||||
{
|
||||
name: "overwriting an existing key does not trigger eviction",
|
||||
setup: func(cs *CacheStore[string]) {
|
||||
cs.Set("a", "1", 0)
|
||||
cs.Set("b", "2", 0)
|
||||
cs.Set("a", "updated", 0)
|
||||
},
|
||||
present: []string{"a", "b"},
|
||||
wantSize: 2,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cs := NewCacheStore[string](2)
|
||||
tt.setup(cs)
|
||||
|
||||
for _, key := range tt.present {
|
||||
_, ok := cs.Get(key)
|
||||
assert.True(t, ok)
|
||||
}
|
||||
for _, key := range tt.absent {
|
||||
_, ok := cs.Get(key)
|
||||
assert.False(t, ok)
|
||||
}
|
||||
assert.Equal(t, tt.wantSize, cs.Size())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCacheStoreSizeAndClear(t *testing.T) {
|
||||
cs := NewCacheStore[string](0)
|
||||
assert.Equal(t, 0, cs.Size())
|
||||
|
||||
cs.Set("a", "1", 0)
|
||||
cs.Set("b", "2", 0)
|
||||
assert.Equal(t, 2, cs.Size())
|
||||
|
||||
cs.Clear()
|
||||
assert.Equal(t, 0, cs.Size())
|
||||
|
||||
_, ok := cs.Get("a")
|
||||
assert.False(t, ok)
|
||||
}
|
||||
|
||||
func TestCacheStoreWithMaxSize(t *testing.T) {
|
||||
cs := NewCacheStore[string](0)
|
||||
assert.Equal(t, 0, cs.Size())
|
||||
|
||||
for i := 0; i < 100; i++ {
|
||||
cs.Set(strconv.Itoa(i), strconv.Itoa(i), 0)
|
||||
}
|
||||
|
||||
assert.Equal(t, 100, cs.Size())
|
||||
|
||||
cs.SetMaxSize(10)
|
||||
|
||||
assert.Equal(t, 10, cs.Size())
|
||||
}
|
||||
|
||||
func TestCacheStoreWithLock(t *testing.T) {
|
||||
cs := NewCacheStore[int](0)
|
||||
cs.Set("counter", 1, 0)
|
||||
|
||||
// All four actions run atomically under a single lock.
|
||||
cs.WithLock(func(actions CacheStoreActions[int]) {
|
||||
current, ok := actions.Get("counter")
|
||||
assert.True(t, ok)
|
||||
|
||||
actions.Set("counter", current+1, 0)
|
||||
actions.Set("other", 100, 0)
|
||||
actions.Delete("counter")
|
||||
|
||||
updated := actions.Update("other", 200, 0)
|
||||
assert.True(t, updated)
|
||||
})
|
||||
|
||||
_, ok := cs.Get("counter")
|
||||
assert.False(t, ok)
|
||||
|
||||
value, ok := cs.Get("other")
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, 200, value)
|
||||
}
|
||||
|
||||
// TestCacheStoreConcurrency exercises every locking path concurrently so the
|
||||
// race detector (go test -race) can flag unsynchronised access.
|
||||
func TestCacheStoreConcurrency(t *testing.T) {
|
||||
cs := NewCacheStore[int](64)
|
||||
|
||||
const goroutines = 16
|
||||
const iterations = 200
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(goroutines)
|
||||
|
||||
for g := range goroutines {
|
||||
go func(g int) {
|
||||
defer wg.Done()
|
||||
for i := range iterations {
|
||||
key := strconv.Itoa((g*iterations + i) % 32)
|
||||
switch i % 6 {
|
||||
case 0:
|
||||
cs.Set(key, i, time.Minute)
|
||||
case 1:
|
||||
cs.Get(key)
|
||||
case 2:
|
||||
cs.Update(key, i, time.Minute)
|
||||
case 3:
|
||||
cs.Delete(key)
|
||||
case 4:
|
||||
cs.Size()
|
||||
case 5:
|
||||
cs.WithLock(func(actions CacheStoreActions[int]) {
|
||||
if v, ok := actions.Get(key); ok {
|
||||
actions.Set(key, v+1, time.Minute)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}(g)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
}
|
||||
Reference in New Issue
Block a user