From df2f80f55fbb161228f54f1dcecd7aad9029362e Mon Sep 17 00:00:00 2001 From: Lukasz Raczylo Date: Thu, 13 Aug 2026 11:36:52 +0100 Subject: [PATCH] fix(cache): make in-memory backend Close-able so Shutdown stops its cleaner cache.Shutdown only called Close() on an io.Closer, which only the Redis backend (CacheWrapper) satisfied -- the in-memory Cache's Shutdown (cancels ctx, stopping its cleanupRoutine background ticker) was never reached, so that goroutine leaked until process exit (and with the default set-up, and whenever Redis is unreachable and EnableCache falls back to memory). Add Close() error to Cache (calls Shutdown, idempotent, leaves the cache usable) so cache.Shutdown handles both backends symmetrically. Update Shutdown doc; add test TestMemoryCacheClose_IdempotentAndStillUsable. --- cache/cache.go | 2 +- cache/memory/memory.go | 9 +++++++++ cache/memory/memory_additional_test.go | 28 ++++++++++++++++++++++++++ 3 files changed, 38 insertions(+), 1 deletion(-) diff --git a/cache/cache.go b/cache/cache.go index 41a1908..43ffe7c 100644 --- a/cache/cache.go +++ b/cache/cache.go @@ -169,7 +169,7 @@ func EnableCache(cfg *CacheConfig) { } // Shutdown closes the underlying cache client if it owns external resources -// (the Redis connection pool). No-op for in-memory caches. +// (the Redis connection pool, or the in-memory cleanup goroutine). func Shutdown() { if config != nil && config.Client != nil { if closer, ok := config.Client.(io.Closer); ok { diff --git a/cache/memory/memory.go b/cache/memory/memory.go index 3578312..e53f1ba 100644 --- a/cache/memory/memory.go +++ b/cache/memory/memory.go @@ -108,6 +108,15 @@ func (c *Cache) Shutdown() { } } +// Close implements io.Closer so the cache layer (cache.Shutdown) can stop the +// cleanup goroutine for the in-memory backend, matching the Redis backend +// which closes its connection pool. Idempotent: cancelling the context does +// not render the cache unusable, it only stops the background cleaner. +func (c *Cache) Close() error { + c.Shutdown() + return nil +} + func (c *Cache) Set(key string, value []byte, ttl time.Duration) { // Calculate the memory size of this entry entrySize := int64(len(key) + len(value) + approxEntryOverhead) diff --git a/cache/memory/memory_additional_test.go b/cache/memory/memory_additional_test.go index 495fc02..5ede35f 100644 --- a/cache/memory/memory_additional_test.go +++ b/cache/memory/memory_additional_test.go @@ -1,6 +1,7 @@ package libpack_cache_memory import ( + "io" "sync" "testing" "time" @@ -146,3 +147,30 @@ func TestMemoryCacheConcurrentSetSameNewKey_CounterNotDoubled(t *testing.T) { t.Fatal("key should be retrievable after Set") } } + +func TestMemoryCacheClose_IdempotentAndStillUsable(t *testing.T) { + // Compile-time guard: the memory backend must satisfy io.Closer so + // cache.Shutdown() can stop its cleanup goroutine (it otherwise leaks + // the background timer until process exit), matching the Redis backend. + var _ io.Closer = (*Cache)(nil) + + cache := New(DefaultTestExpiration) + cache.Set("key", []byte("value"), DefaultTestExpiration) + + // Close is idempotent: it only cancels the background cleaner. + if err := cache.Close(); err != nil { + t.Fatalf("first Close: %v", err) + } + if err := cache.Close(); err != nil { + t.Fatalf("second Close: %v", err) + } + + // Stopping the cleaner must not render the cache unusable. + if _, ok := cache.Get("key"); !ok { + t.Fatal("entry should survive Close; Close only stops the background cleaner") + } + cache.Set("key2", []byte("v2"), DefaultTestExpiration) + if _, ok := cache.Get("key2"); !ok { + t.Fatal("cache should remain writable after Close") + } +}