Skip to content

Commit 75a7033

Browse files
committed
feat(eviction): robustify all eviction strategies, add config options, and improve docs
- Refactored all eviction algorithms (LRU, LFU, ARC, Clock, CAWOLFU) for thread safety, zero-capacity handling, and deadlock prevention. - Inlined eviction logic in Clock and CAWOLFU to avoid recursive locking and deadlocks. - Ensured all mutating operations use proper locking and updated value/frequency on duplicate keys. - Improved memory hygiene by resetting pooled items before reuse. - Added buffer size and debounce interval options for expiration trigger channel in config and HyperCache. - Updated README to clarify package paths and usage, and fixed example commands. - Added PRD.md with design review and suggestions. - Updated benchmark to use LRU for proactive eviction test. - Improved code comments and documentation for maintainability.
1 parent 38b805a commit 75a7033

11 files changed

Lines changed: 385 additions & 121 deletions

File tree

PRD.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# PRD
2+
3+
Overview
4+
Hypercache is a thread‑safe caching library with pluggable backends and eviction algorithms, already targeting Go 1.25 and generics. The design exposes a service interface that supports middleware layers for decorating cache operations.
5+
6+
Findings & Suggestions
7+
Ticker Lifecycle – Background jobs create time.Tickers but never stop them, which can leak resources; explicitly call tick.Stop() when terminating the loops.
8+
9+
WorkerPool Resize Logic – Resizing the pool sends a quit signal pool.workers times regardless of how many workers need removal; adjust to send -diff signals and consider buffering to avoid blocking.
10+
11+
Expired‑Key Semantics – GetMultiple returns ErrKeyExpired, yet tests expect ErrKeyNotFound for expired items, indicating a behavior mismatch or test expectation issue.
12+
13+
Per‑Call Goroutines – Get and GetWithInfo spawn a goroutine for each expired item, which may create overhead under heavy load; consider synchronous cleanup or a dedicated worker to batch expirations.
14+
15+
Object Pool Hygiene – ItemPoolManager returns items to the pool without resetting fields, risking retention of large values; zero the struct before reusing it.
16+
17+
Modern Go Features – The project already uses Go 1.25; further modernization could include:
18+
19+
Generics for typed values rather than any, providing compile‑time safety.
20+
21+
Use of maps.Clone/maps.DeleteFunc and slices.SortFunc for cleaner collection handling.
22+
23+
errors.Is/errors.Join to simplify error comparisons.
24+
25+
Potential Enhancements
26+
27+
Expose a MGet/MSet on backends for efficient multi-key operations.
28+
29+
Provide context-aware cancellation in WorkerPool and background jobs to ease shutdown.
30+
31+
Add methods such as Contains, Peek, Increment, or TTL refresh APIs.
32+
33+
Integrate OpenTelemetry metrics and tracing for observability.

README.md

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,29 +6,29 @@
66

77
HyperCache is a **thread-safe** **high-performance** cache implementation in `Go` that supports multiple backends with an optional size limit, expiration, and eviction of items with custom algorithms alongside the defaults. It can be used as a standalone cache, distributed environents, or as a cache middleware for a service. It can implement a [service interface](./service.go) to intercept and decorate the cache methods with middleware (default or custom).
88
It is optimized for performance and flexibility, allowing to specify of the expiration and eviction intervals and providing and registering new eviction algorithms, stats collectors, and middleware(s).
9-
It ships with a default [historigram stats collector](./stats/stats.go) and several eviction algorithms. However, you can develop and register your own if it implements the [Eviction Algorithm interface](./eviction/eviction.go).:
9+
It ships with a default [historigram stats collector](./pkg/stats/stats.go) and several eviction algorithms. However, you can develop and register your own if it implements the [Eviction Algorithm interface](./pkg/eviction/eviction.go).:
1010

11-
- [Recently Used (LRU) eviction algorithm](./eviction/lru.go)
12-
- [The Least Frequently Used (LFU) algorithm](./eviction/lfu.go)
13-
- [Cache-Aware Write-Optimized LFU (CAWOLFU)](./eviction/cawolfu.go)
14-
- [The Adaptive Replacement Cache (ARC) algorithm](./eviction/arc.go)
15-
- [The clock eviction algorithm](./eviction/clock.go)
11+
- [Recently Used (LRU) eviction algorithm](./pkg/eviction/lru.go)
12+
- [The Least Frequently Used (LFU) algorithm](./pkg/eviction/lfu.go)
13+
- [Cache-Aware Write-Optimized LFU (CAWOLFU)](./pkg/eviction/cawolfu.go)
14+
- [The Adaptive Replacement Cache (ARC) algorithm](./pkg/eviction/arc.go)
15+
- [The clock eviction algorithm](./pkg/eviction/clock.go)
1616

1717
### Features
1818

1919
- Thread-safe
2020
- High-performance
2121
- Supports multiple, custom backends. Default backends are:
22-
1. [In-memory](./backend/inmemory.go)
23-
2. [Redis](./backend/redis.go)
22+
1. [In-memory](./pkg/backend/inmemory.go)
23+
2. [Redis](./pkg/backend/redis.go)
2424
- Store items in the cache with a key and expiration duration
2525
- Retrieve items from the cache by their key
2626
- Delete items from the cache by their key
2727
- Clear the cache of all items
2828
- Evitc items in the background based on the cache capacity and items access leveraging several custom eviction algorithms
2929
- Expire items in the background based on their duration
30-
- [Eviction Algorithm interface](./eviction/eviction.go) to implement custom eviction algorithms.
31-
- Stats collection with a default [stats collector](./stats/stats.go) or a custom one that implements the StatsCollector interface.
30+
- [Eviction Algorithm interface](./pkg/eviction/eviction.go) to implement custom eviction algorithms.
31+
- Stats collection with a default [stats collector](./pkg/stats/stats.go) or a custom one that implements the StatsCollector interface.
3232
- [Service interface implementation](./service.go) to allow intercepting cache methods and decorate them with custom or default middleware(s).
3333

3434
## Installation
@@ -64,7 +64,7 @@ ok github.com/hyp3rd/hypercache/tests/benchmark 30.031s
6464
To run the examples, use the following command:
6565

6666
```bash
67-
make run-example example=eviction # or any other example
67+
make run-example group=eviction # or any other example
6868
```
6969

7070
For a complete list of examples, refer to the [examples](./__examples/README.md) directory.

config.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,22 @@ func WithEvictionInterval[T backend.IBackendConstrain](evictionInterval time.Dur
124124
}
125125
}
126126

127+
// WithExpirationTriggerBuffer sets the buffer size of the expiration trigger channel.
128+
// If set to <= 0, the default (capacity/2, minimum 1) is used.
129+
func WithExpirationTriggerBuffer[T backend.IBackendConstrain](size int) Option[T] {
130+
return func(cache *HyperCache[T]) {
131+
cache.expirationTriggerBufSize = size
132+
}
133+
}
134+
135+
// WithExpirationTriggerDebounce sets an optional debounce interval for coalescing expiration triggers.
136+
// Triggers arriving within this interval after the last accepted trigger may be dropped.
137+
func WithExpirationTriggerDebounce[T backend.IBackendConstrain](interval time.Duration) Option[T] {
138+
return func(cache *HyperCache[T]) {
139+
cache.expirationDebounceInterval = interval
140+
}
141+
}
142+
127143
// WithMaxEvictionCount is an option that sets the max eviction count field of the `HyperCache` struct.
128144
// The max eviction count determines the maximum number of items that can be removed during a single eviction run.
129145
func WithMaxEvictionCount[T backend.IBackendConstrain](maxEvictionCount uint) Option[T] {

cspell.config.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ words:
4343
- memprofile
4444
- msgpack
4545
- mvdan
46+
- nestif
4647
- Newf
4748
- nolint
4849
- NOVENDOR

hypercache.go

Lines changed: 85 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -39,24 +39,28 @@ import (
3939
// The cache also has a mutex that is used to protect the eviction algorithm from concurrent access.
4040
// The stop channel is used to signal the expiration and eviction loops to stop. The evictCh channel is used to signal the eviction loop to start.
4141
type HyperCache[T backend.IBackendConstrain] struct {
42-
backend backend.IBackend[T] // `backend`` holds the backend that the cache uses to store the items. It must implement the IBackend interface.
43-
cacheBackendChecker introspect.CacheBackendChecker[T] // `cacheBackendChecker` holds an instance of the CacheBackendChecker interface. It helps to determine the type of the backend.
44-
itemPoolManager *cache.ItemPoolManager // `itemPoolManager` manages the item pool for the cache.
45-
stop chan bool // `stop` channel to signal the expiration and eviction loops to stop
46-
workerPool *WorkerPool // `workerPool` holds a pointer to the worker pool that the cache uses to run the expiration and eviction loops.
47-
expirationTriggerCh chan bool // `expirationTriggerCh` channel to signal the expiration trigger loop to start
48-
evictCh chan bool // `evictCh` channel to signal the eviction loop to start
49-
evictionAlgorithmName string // `evictionAlgorithmName` name of the eviction algorithm to use when evicting items
50-
evictionAlgorithm eviction.IAlgorithm // `evictionAlgorithm` eviction algorithm to use when evicting items
51-
expirationInterval time.Duration // `expirationInterval` interval at which the expiration loop should run
52-
evictionInterval time.Duration // interval at which the eviction loop should run
53-
shouldEvict atomic.Bool // `shouldEvict` indicates whether the cache should evict items or not
54-
maxEvictionCount uint // `evictionInterval` maximum number of items that can be evicted in a single eviction loop iteration
55-
maxCacheSize int64 // maxCacheSize instructs the cache not allocate more memory than this limit, value in MB, 0 means no limit
56-
memoryAllocation atomic.Int64 // memoryAllocation is the current memory allocation of the cache, value in bytes
57-
mutex sync.RWMutex // `mutex` holds a RWMutex (Read-Write Mutex) that is used to protect the eviction algorithm from concurrent access
58-
once sync.Once // `once` holds a Once struct that is used to ensure that the expiration and eviction loops are only started once
59-
statsCollectorName string // `statsCollectorName` holds the name of the stats collector that the cache should use when collecting cache statistics
42+
backend backend.IBackend[T] // `backend`` holds the backend that the cache uses to store the items. It must implement the IBackend interface.
43+
cacheBackendChecker introspect.CacheBackendChecker[T] // `cacheBackendChecker` holds an instance of the CacheBackendChecker interface. It helps to determine the type of the backend.
44+
itemPoolManager *cache.ItemPoolManager // `itemPoolManager` manages the item pool for the cache.
45+
stop chan bool // `stop` channel to signal the expiration and eviction loops to stop
46+
workerPool *WorkerPool // `workerPool` holds a pointer to the worker pool that the cache uses to run the expiration and eviction loops.
47+
expirationTriggerCh chan bool // `expirationTriggerCh` channel to signal the expiration trigger loop to start
48+
expirationTriggerBufSize int // optional override for expiration trigger channel buffer size (default: capacity/2)
49+
expirationSignalPending atomic.Bool // coalesces multiple expiration trigger requests
50+
expirationDebounceInterval time.Duration // optional debounce interval between accepted triggers
51+
lastExpirationTrigger atomic.Int64 // unix nano timestamp of last accepted trigger
52+
evictCh chan bool // `evictCh` channel to signal the eviction loop to start
53+
evictionAlgorithmName string // `evictionAlgorithmName` name of the eviction algorithm to use when evicting items
54+
evictionAlgorithm eviction.IAlgorithm // `evictionAlgorithm` eviction algorithm to use when evicting items
55+
expirationInterval time.Duration // `expirationInterval` interval at which the expiration loop should run
56+
evictionInterval time.Duration // interval at which the eviction loop should run
57+
shouldEvict atomic.Bool // `shouldEvict` indicates whether the cache should evict items or not
58+
maxEvictionCount uint // `evictionInterval` maximum number of items that can be evicted in a single eviction loop iteration
59+
maxCacheSize int64 // maxCacheSize instructs the cache not allocate more memory than this limit, value in MB, 0 means no limit
60+
memoryAllocation atomic.Int64 // memoryAllocation is the current memory allocation of the cache, value in bytes
61+
mutex sync.RWMutex // `mutex` holds a RWMutex (Read-Write Mutex) that is used to protect the eviction algorithm from concurrent access
62+
once sync.Once // `once` holds a Once struct that is used to ensure that the expiration and eviction loops are only started once
63+
statsCollectorName string // `statsCollectorName` holds the name of the stats collector that the cache should use when collecting cache statistics
6064
// StatsCollector to collect cache statistics
6165
StatsCollector stats.ICollector
6266
}
@@ -100,6 +104,8 @@ func NewInMemoryWithDefaults(capacity int) (*HyperCache[backend.InMemory], error
100104
// - The eviction algorithm is set to LRU.
101105
// - The expiration interval is set to 30 minutes.
102106
// - The stats collector is set to the HistogramStatsCollector stats collector.
107+
//
108+
//nolint:cyclop
103109
func New[T backend.IBackendConstrain](bm *BackendManager, config *Config[T]) (*HyperCache[T], error) {
104110
// Get the backend constructor from the registry
105111
constructor, exists := bm.backendRegistry[config.BackendType]
@@ -179,15 +185,26 @@ func New[T backend.IBackendConstrain](bm *BackendManager, config *Config[T]) (*H
179185
return nil, sentinel.ErrInvalidCapacity
180186
}
181187

182-
// Initialize the expiration trigger channel with the buffer size set to half the capacity
183-
hyperCache.expirationTriggerCh = make(chan bool, hyperCache.backend.Capacity()/2)
188+
// Initialize the expiration trigger channel with configurable buffer size (default: half capacity)
189+
buf := hyperCache.backend.Capacity() / 2
190+
if hyperCache.expirationTriggerBufSize > 0 {
191+
buf = hyperCache.expirationTriggerBufSize
192+
}
193+
194+
if buf < 1 {
195+
buf = 1
196+
}
197+
198+
hyperCache.expirationTriggerCh = make(chan bool, buf)
184199

185200
hyperCache.startBackgroundJobs()
186201

187202
return hyperCache, err
188203
}
189204

190205
// startBackgroundJobs starts the background jobs for the hyper cache.
206+
//
207+
//nolint:cyclop
191208
func (hyperCache *HyperCache[T]) startBackgroundJobs() {
192209
// Initialize the eviction channel with the buffer size set to half the capacity
193210
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
@@ -206,6 +223,17 @@ func (hyperCache *HyperCache[T]) startBackgroundJobs() {
206223
case <-hyperCache.expirationTriggerCh:
207224
// trigger expiration
208225
hyperCache.expirationLoop(ctx)
226+
// clear pending flag and drain any coalesced triggers quickly
227+
hyperCache.expirationSignalPending.Store(false)
228+
229+
drained := true
230+
for drained {
231+
select {
232+
case <-hyperCache.expirationTriggerCh:
233+
default:
234+
drained = false
235+
}
236+
}
209237
case <-hyperCache.evictCh:
210238
// trigger eviction
211239
hyperCache.evictionLoop(ctx)
@@ -241,6 +269,35 @@ func (hyperCache *HyperCache[T]) startBackgroundJobs() {
241269
})
242270
}
243271

272+
// triggerExpiration coalesces and optionally debounces expiration triggers to avoid flooding the channel.
273+
func (hyperCache *HyperCache[T]) triggerExpiration() {
274+
// Optional debounce: if configured, drop triggers that arrive within the interval.
275+
if d := hyperCache.expirationDebounceInterval; d > 0 {
276+
last := time.Unix(0, hyperCache.lastExpirationTrigger.Load())
277+
if time.Since(last) < d {
278+
// record backpressure metric
279+
hyperCache.StatsCollector.Incr(constants.StatIncr, 1)
280+
281+
return
282+
}
283+
}
284+
285+
// Coalesce: if a signal is already pending, skip enqueueing another.
286+
if hyperCache.expirationSignalPending.Swap(true) {
287+
hyperCache.StatsCollector.Incr(constants.StatIncr, 1)
288+
289+
return
290+
}
291+
292+
select {
293+
case hyperCache.expirationTriggerCh <- true:
294+
hyperCache.lastExpirationTrigger.Store(time.Now().UnixNano())
295+
default:
296+
// channel full; keep pending flag set and record metric
297+
hyperCache.StatsCollector.Incr(constants.StatIncr, 1)
298+
}
299+
}
300+
244301
// expirationLoop is a function that runs in a separate goroutine and expires items in the cache based on their expiration duration.
245302
func (hyperCache *HyperCache[T]) expirationLoop(ctx context.Context) {
246303
hyperCache.workerPool.Enqueue(func() error {
@@ -401,11 +458,8 @@ func (hyperCache *HyperCache[T]) Get(ctx context.Context, key string) (any, bool
401458
if item.Expired() {
402459
// Return the item to the pool and non-blocking trigger of expiration loop
403460
hyperCache.itemPoolManager.Put(item)
404-
405-
select {
406-
case hyperCache.expirationTriggerCh <- true:
407-
default:
408-
}
461+
// Coalesced/debounced trigger
462+
hyperCache.triggerExpiration()
409463

410464
return nil, false
411465
}
@@ -428,11 +482,8 @@ func (hyperCache *HyperCache[T]) GetWithInfo(ctx context.Context, key string) (*
428482
if item.Expired() {
429483
// Return the item to the pool and non-blocking trigger of expiration loop
430484
hyperCache.itemPoolManager.Put(item)
431-
432-
select {
433-
case hyperCache.expirationTriggerCh <- true:
434-
default:
435-
}
485+
// Coalesced/debounced trigger
486+
hyperCache.triggerExpiration()
436487

437488
return nil, false
438489
}
@@ -452,11 +503,8 @@ func (hyperCache *HyperCache[T]) GetOrSet(ctx context.Context, key string, value
452503
if item.Expired() {
453504
// Return the item to the pool and non-blocking trigger of expiration loop
454505
hyperCache.itemPoolManager.Put(item)
455-
456-
select {
457-
case hyperCache.expirationTriggerCh <- true:
458-
default:
459-
}
506+
// Coalesced/debounced trigger
507+
hyperCache.triggerExpiration()
460508

461509
return nil, sentinel.ErrKeyExpired
462510
}
@@ -536,11 +584,8 @@ func (hyperCache *HyperCache[T]) GetMultiple(ctx context.Context, keys ...string
536584
hyperCache.itemPoolManager.Put(item)
537585
// Treat expired items as not found per API semantics
538586
failed[key] = sentinel.ErrKeyNotFound
539-
// Non-blocking trigger of the expiration loop via channel
540-
select {
541-
case hyperCache.expirationTriggerCh <- true:
542-
default:
543-
}
587+
// Coalesced/debounced trigger of the expiration loop via channel
588+
hyperCache.triggerExpiration()
544589
} else {
545590
item.Touch() // Update the last access time and access count
546591
// Add the item to the result map

pkg/eviction/arc.go

Lines changed: 23 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -47,37 +47,47 @@ func NewARCAlgorithm(capacity int) (*ARC, error) {
4747
// Get retrieves the item with the given key from the cache.
4848
// If the key is not found in the cache, it returns nil.
4949
func (arc *ARC) Get(key string) (any, bool) {
50-
arc.mutex.RLock()
50+
arc.mutex.Lock()
51+
defer arc.mutex.Unlock()
5152

5253
// Check t1
5354
item, ok := arc.t1[key]
5455
if ok {
55-
arc.mutex.RUnlock()
5656
arc.promote(key)
5757

5858
return item.Value, true
5959
}
6060
// Check t2
6161
item, ok = arc.t2[key]
6262
if ok {
63-
arc.mutex.RUnlock()
6463
arc.demote(key)
6564

6665
return item.Value, true
6766
}
6867

69-
arc.mutex.RUnlock()
70-
7168
return nil, false
7269
}
7370

7471
// Set adds a new item to the cache with the given key.
7572
func (arc *ARC) Set(key string, value any) {
76-
arc.mutex.RLock()
77-
defer arc.mutex.RUnlock()
78-
// Check if key is already in cache
79-
_, ok := arc.Get(key)
80-
if ok {
73+
arc.mutex.Lock()
74+
defer arc.mutex.Unlock()
75+
76+
if arc.capacity == 0 {
77+
// Zero-capacity ARC is a no-op
78+
return
79+
}
80+
81+
// If key exists in t1 or t2, update value only
82+
if item, ok := arc.t1[key]; ok {
83+
item.Value = value
84+
85+
return
86+
}
87+
88+
if item, ok := arc.t2[key]; ok {
89+
item.Value = value
90+
8191
return
8292
}
8393

@@ -93,9 +103,7 @@ func (arc *ARC) Set(key string, value any) {
93103
}
94104

95105
item := arc.itemPoolManager.Get()
96-
97106
item.Value = value
98-
99107
arc.t1[key] = item
100108
arc.c++
101109

@@ -135,11 +143,13 @@ func (arc *ARC) Delete(key string) {
135143
// Evict removes an item from the cache and returns the key of the evicted item.
136144
// If no item can be evicted, it returns an error.
137145
func (arc *ARC) Evict() (string, bool) {
146+
if arc.capacity == 0 {
147+
return "", false
148+
}
138149
// Check t1
139150
for key, val := range arc.t1 {
140151
delete(arc.t1, key)
141152
arc.c--
142-
143153
arc.itemPoolManager.Put(val)
144154

145155
return key, true
@@ -148,7 +158,6 @@ func (arc *ARC) Evict() (string, bool) {
148158
for key, val := range arc.t2 {
149159
delete(arc.t2, key)
150160
arc.c--
151-
152161
arc.itemPoolManager.Put(val)
153162

154163
return key, true

0 commit comments

Comments
 (0)