Skip to content

Commit 5015efc

Browse files
authored
Merge pull request #214 from jaime-amate/feat/close-restart-goroutine
feat: close/restart cleanup goroutine in runtime
2 parents 61701b2 + cff4d46 commit 5015efc

2 files changed

Lines changed: 127 additions & 40 deletions

File tree

expirable/expirable_lru.go

Lines changed: 80 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ type LRU[K comparable, V any] struct {
2929
buckets []bucket[K, V]
3030
// uint8 because it's number between 0 and numBuckets
3131
nextCleanupBucket uint8
32+
33+
cleanupStopped bool
3234
}
3335

3436
// bucket is a container for holding entries to be expired
@@ -60,12 +62,13 @@ func NewLRU[K comparable, V any](size int, onEvict EvictCallback[K, V], ttl time
6062
}
6163

6264
res := LRU[K, V]{
63-
ttl: ttl,
64-
size: size,
65-
evictList: internal.NewList[K, V](),
66-
items: make(map[K]*internal.Entry[K, V]),
67-
onEvict: onEvict,
68-
done: make(chan struct{}),
65+
ttl: ttl,
66+
size: size,
67+
evictList: internal.NewList[K, V](),
68+
items: make(map[K]*internal.Entry[K, V]),
69+
onEvict: onEvict,
70+
done: make(chan struct{}),
71+
cleanupStopped: false,
6972
}
7073

7174
// initialize the buckets
@@ -74,24 +77,7 @@ func NewLRU[K comparable, V any](size int, onEvict EvictCallback[K, V], ttl time
7477
res.buckets[i] = bucket[K, V]{entries: make(map[K]*internal.Entry[K, V])}
7578
}
7679

77-
// enable deleteExpired() running in separate goroutine for cache with non-zero TTL
78-
//
79-
// Important: done channel is never closed, so deleteExpired() goroutine will never exit,
80-
// it's decided to add functionality to close it in the version later than v2.
81-
if res.ttl != noEvictionTTL {
82-
go func(done <-chan struct{}) {
83-
ticker := time.NewTicker(res.ttl / numBuckets)
84-
defer ticker.Stop()
85-
for {
86-
select {
87-
case <-done:
88-
return
89-
case <-ticker.C:
90-
res.deleteExpired()
91-
}
92-
}
93-
}(res.done)
94-
}
80+
res.startGoroutine()
9581
return &res
9682
}
9783

@@ -152,7 +138,7 @@ func (c *LRU[K, V]) Get(key K) (value V, ok bool) {
152138
var ent *internal.Entry[K, V]
153139
if ent, ok = c.items[key]; ok {
154140
// Expired item check
155-
if time.Now().After(ent.ExpiresAt) {
141+
if !c.cleanupStopped && time.Now().After(ent.ExpiresAt) {
156142
return value, false
157143
}
158144
c.evictList.MoveToFront(ent)
@@ -178,7 +164,7 @@ func (c *LRU[K, V]) Peek(key K) (value V, ok bool) {
178164
var ent *internal.Entry[K, V]
179165
if ent, ok = c.items[key]; ok {
180166
// Expired item check
181-
if time.Now().After(ent.ExpiresAt) {
167+
if !c.cleanupStopped && time.Now().After(ent.ExpiresAt) {
182168
return value, false
183169
}
184170
return ent.Value, true
@@ -227,7 +213,7 @@ func (c *LRU[K, V]) Keys() []K {
227213
keys := make([]K, 0, len(c.items))
228214
now := time.Now()
229215
for ent := c.evictList.Back(); ent != nil; ent = ent.PrevEntry() {
230-
if now.After(ent.ExpiresAt) {
216+
if !c.cleanupStopped && now.After(ent.ExpiresAt) {
231217
continue
232218
}
233219
keys = append(keys, ent.Key)
@@ -243,7 +229,7 @@ func (c *LRU[K, V]) Values() []V {
243229
values := make([]V, 0, len(c.items))
244230
now := time.Now()
245231
for ent := c.evictList.Back(); ent != nil; ent = ent.PrevEntry() {
246-
if now.After(ent.ExpiresAt) {
232+
if !c.cleanupStopped && now.After(ent.ExpiresAt) {
247233
continue
248234
}
249235
values = append(values, ent.Value)
@@ -278,16 +264,17 @@ func (c *LRU[K, V]) Resize(size int) (evicted int) {
278264
}
279265

280266
// Close destroys cleanup goroutine. To clean up the cache, run Purge() before Close().
281-
// func (c *LRU[K, V]) Close() {
282-
// c.mu.Lock()
283-
// defer c.mu.Unlock()
284-
// select {
285-
// case <-c.done:
286-
// return
287-
// default:
288-
// }
289-
// close(c.done)
290-
// }
267+
func (c *LRU[K, V]) Close() {
268+
c.mu.Lock()
269+
defer c.mu.Unlock()
270+
select {
271+
case <-c.done:
272+
return
273+
default:
274+
}
275+
close(c.done)
276+
c.cleanupStopped = true
277+
}
291278

292279
// removeOldest removes the oldest item from the cache. Has to be called with lock!
293280
func (c *LRU[K, V]) removeOldest() {
@@ -310,13 +297,29 @@ func (c *LRU[K, V]) removeElement(e *internal.Entry[K, V]) {
310297
// in it to expire first.
311298
func (c *LRU[K, V]) deleteExpired() {
312299
c.mu.Lock()
300+
301+
// grab done channel to detect Closes
302+
done := c.done
303+
313304
bucketIdx := c.nextCleanupBucket
314305
timeToExpire := time.Until(c.buckets[bucketIdx].newestEntry)
315306
// wait for newest entry to expire before cleanup without holding lock
316307
if timeToExpire > 0 {
317308
c.mu.Unlock()
318-
time.Sleep(timeToExpire)
309+
select {
310+
case <-time.After(timeToExpire):
311+
case <-done:
312+
return
313+
}
319314
c.mu.Lock()
315+
316+
select {
317+
case <-done:
318+
// Done channel closed while sleeping, return without deleting entries
319+
c.mu.Unlock()
320+
return
321+
default:
322+
}
320323
}
321324
for _, ent := range c.buckets[bucketIdx].entries {
322325
c.removeElement(ent)
@@ -343,4 +346,41 @@ func (c *LRU[K, V]) removeFromBucket(e *internal.Entry[K, V]) {
343346
// Cap returns the capacity of the cache
344347
func (c *LRU[K, V]) Cap() int {
345348
return c.size
346-
}
349+
}
350+
351+
// Restart recreates the cleanup goroutine after Close() has been called.
352+
func (c *LRU[K, V]) Restart() {
353+
c.mu.Lock()
354+
defer c.mu.Unlock()
355+
356+
// Check if the goroutine is already running
357+
select {
358+
case <-c.done:
359+
// Channel is closed, need to recreate it
360+
c.done = make(chan struct{})
361+
c.cleanupStopped = false
362+
c.startGoroutine()
363+
default:
364+
// Goroutine is already running, nothing to do
365+
}
366+
}
367+
368+
// startGoroutine starts the cleanup goroutine for expired entries.
369+
func (c *LRU[K, V]) startGoroutine() {
370+
if c.ttl == noEvictionTTL {
371+
return
372+
}
373+
374+
go func(done <-chan struct{}) {
375+
ticker := time.NewTicker(c.ttl / numBuckets)
376+
defer ticker.Stop()
377+
for {
378+
select {
379+
case <-done:
380+
return
381+
case <-ticker.C:
382+
c.deleteExpired()
383+
}
384+
}
385+
}(c.done)
386+
}

expirable/expirable_lru_test.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -575,3 +575,50 @@ func TestCache_EvictionSameKey(t *testing.T) {
575575
t.Errorf("evictedKeys got: %v want: %v", evictedKeys, want)
576576
}
577577
}
578+
579+
func TestCache_CloseGoRoutine(t *testing.T) {
580+
cache := NewLRU[string, string](2, nil, time.Millisecond*10)
581+
582+
cache.Add("key1", "val1")
583+
cache.Close()
584+
585+
time.Sleep(time.Millisecond * 50)
586+
_, ok := cache.Get("key1")
587+
if !ok {
588+
t.Errorf("expected key 1 to be present")
589+
}
590+
591+
values := cache.Values()
592+
if len(values) != 1 || values[0] != "val1" {
593+
t.Errorf("expected values to contain val1")
594+
}
595+
596+
keys := cache.Keys()
597+
if len(keys) != 1 || keys[0] != "key1" {
598+
t.Errorf("expected keys to contain key1")
599+
}
600+
}
601+
602+
func TestCache_RestartGoRoutine(t *testing.T) {
603+
cache := NewLRU[string, string](2, nil, time.Millisecond*10)
604+
605+
cache.Add("key1", "val1")
606+
cache.Close()
607+
cache.Restart()
608+
609+
time.Sleep(time.Millisecond * 50)
610+
_, ok := cache.Get("key1")
611+
if ok {
612+
t.Errorf("expected key 1 to be expired")
613+
}
614+
615+
value := cache.Values()
616+
if len(value) != 0 {
617+
t.Errorf("expected values to be empty")
618+
}
619+
620+
keys := cache.Keys()
621+
if len(keys) != 0 {
622+
t.Errorf("expected keys to be empty")
623+
}
624+
}

0 commit comments

Comments
 (0)