mirror of
https://github.com/lukaszraczylo/graphql-monitoring-proxy.git
synced 2026-10-24 06:48:09 +00:00
perf(cache): sort-based eviction and consistent non-positive TTL handling
The count-based evictOldest used an O(n squared) selection sort with a candidate cap over an unordered map range, so it could evict the wrong entries. It now collects all entries and sorts by expiry, matching the memory-based evictor. A non-positive CACHE_TTL behaved differently per backend: the standard memory cache panicked, Redis stored a permanent key, and the LRU cache expired entries immediately. Each backend now clamps a non-positive TTL to a shared default at its Set boundary. The nil-logger fallback in the Redis wrapper now uses the constructor so it cannot panic.
This commit is contained in:
Vendored
+100
@@ -2,6 +2,7 @@ package libpack_cache_memory
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -212,3 +213,102 @@ func TestEvictToFreeMemoryWithSmallMaxEntriesReachesTarget(t *testing.T) {
|
|||||||
assert.Equal(t, int64(0), cache.GetMemoryUsage(),
|
assert.Equal(t, int64(0), cache.GetMemoryUsage(),
|
||||||
"an oversized request should evict every entry")
|
"an oversized request should evict every entry")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestEvictOldest_EvictsCorrectOldestEntries is a determinism regression for
|
||||||
|
// P2: evictOldest used to selection-sort only a capped subset (n*2) of
|
||||||
|
// sync.Map's unordered Range, which could miss the true oldest entries
|
||||||
|
// entirely and evict the wrong ones. This verifies it evicts exactly the n
|
||||||
|
// entries with the earliest ExpiresAt, matching evictToFreeMemory's
|
||||||
|
// collect-all-then-sort.Slice approach.
|
||||||
|
func TestEvictOldest_EvictsCorrectOldestEntries(t *testing.T) {
|
||||||
|
cache := NewWithSize(5*time.Second, DefaultMaxMemorySize, DefaultMaxCacheSize)
|
||||||
|
|
||||||
|
const total = 50
|
||||||
|
keys := make([]string, total)
|
||||||
|
for i := 0; i < total; i++ {
|
||||||
|
key := fmt.Sprintf("key-%03d", i)
|
||||||
|
keys[i] = key
|
||||||
|
// Distinct, strictly increasing TTLs give distinct, strictly
|
||||||
|
// increasing ExpiresAt without depending on wall-clock sleeps between
|
||||||
|
// insertions.
|
||||||
|
cache.Set(key, []byte("v"), time.Duration(i+1)*time.Minute)
|
||||||
|
}
|
||||||
|
assert.Equal(t, int64(total), cache.CountQueries())
|
||||||
|
|
||||||
|
const evictCount = 20
|
||||||
|
cache.evictOldest(evictCount)
|
||||||
|
|
||||||
|
assert.Equal(t, int64(total-evictCount), cache.CountQueries())
|
||||||
|
|
||||||
|
// The evictCount entries with the smallest ExpiresAt (keys[0:evictCount])
|
||||||
|
// must be gone; the remaining, longer-lived entries must all survive.
|
||||||
|
for i := 0; i < evictCount; i++ {
|
||||||
|
_, found := cache.Get(keys[i])
|
||||||
|
assert.False(t, found, "oldest key %s should have been evicted", keys[i])
|
||||||
|
}
|
||||||
|
for i := evictCount; i < total; i++ {
|
||||||
|
_, found := cache.Get(keys[i])
|
||||||
|
assert.True(t, found, "newer key %s should survive eviction", keys[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEvictOldest_MoreThanAvailable_EvictsAllWithoutPanic verifies evictOldest
|
||||||
|
// clamps n to the number of live entries instead of indexing past the sorted
|
||||||
|
// slice.
|
||||||
|
func TestEvictOldest_MoreThanAvailable_EvictsAllWithoutPanic(t *testing.T) {
|
||||||
|
cache := NewWithSize(5*time.Second, DefaultMaxMemorySize, DefaultMaxCacheSize)
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
cache.Set(fmt.Sprintf("k%d", i), []byte("v"), time.Minute)
|
||||||
|
}
|
||||||
|
|
||||||
|
cache.evictOldest(1000)
|
||||||
|
|
||||||
|
assert.Equal(t, int64(0), cache.CountQueries())
|
||||||
|
assert.Equal(t, int64(0), cache.GetMemoryUsage())
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEvictOldest_ConcurrentHammer_StaysConsistent drives Set/Get/Delete from
|
||||||
|
// many goroutines against a small maxCacheSize, which forces the count-based
|
||||||
|
// eviction path (evictOldest) to run repeatedly and concurrently. Run with
|
||||||
|
// -race: the entryCount/memoryUsage counters must stay lock-free-consistent
|
||||||
|
// (never negative, always matching the live map) throughout.
|
||||||
|
func TestEvictOldest_ConcurrentHammer_StaysConsistent(t *testing.T) {
|
||||||
|
const maxEntries = 50
|
||||||
|
cache := NewWithSize(5*time.Second, DefaultMaxMemorySize, maxEntries)
|
||||||
|
|
||||||
|
const goroutines = 16
|
||||||
|
const opsPerGoroutine = 200
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Add(goroutines)
|
||||||
|
for g := 0; g < goroutines; g++ {
|
||||||
|
go func(g int) {
|
||||||
|
defer wg.Done()
|
||||||
|
for i := 0; i < opsPerGoroutine; i++ {
|
||||||
|
key := fmt.Sprintf("g%d-k%d", g, i%20)
|
||||||
|
cache.Set(key, []byte("v"), time.Duration(i%10+1)*time.Second)
|
||||||
|
cache.Get(key)
|
||||||
|
if i%7 == 0 {
|
||||||
|
cache.Delete(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}(g)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
// Counters must never go negative.
|
||||||
|
assert.GreaterOrEqual(t, cache.CountQueries(), int64(0))
|
||||||
|
assert.GreaterOrEqual(t, cache.GetMemoryUsage(), int64(0))
|
||||||
|
|
||||||
|
// Counters must match the live map contents exactly.
|
||||||
|
var actualCount, actualMemory int64
|
||||||
|
cache.entries.Range(func(_, v any) bool {
|
||||||
|
actualCount++
|
||||||
|
actualMemory += v.(CacheEntry).MemorySize
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
assert.Equal(t, actualCount, cache.CountQueries(),
|
||||||
|
"entryCount must match the number of live entries after concurrent Set/evictOldest")
|
||||||
|
assert.Equal(t, actualMemory, cache.GetMemoryUsage(),
|
||||||
|
"memoryUsage must match the sum of live entries' MemorySize after concurrent Set/evictOldest")
|
||||||
|
}
|
||||||
|
|||||||
Vendored
+8
@@ -65,6 +65,14 @@ func (c *LRUMemoryCache) Set(key string, value []byte, ttl time.Duration) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
entrySize := int64(len(key) + len(finalValue) + 64) // 64 bytes overhead estimate
|
entrySize := int64(len(key) + len(finalValue) + 64) // 64 bytes overhead estimate
|
||||||
|
|
||||||
|
// A non-positive TTL is clamped to defaultTTL (defined in memory.go) so
|
||||||
|
// this backend does not stamp the entry as already-expired (immediate
|
||||||
|
// miss on every read). Keeps behavior identical to the standard memory
|
||||||
|
// backend and Redis, which clamp the same way at their own Set boundary.
|
||||||
|
if ttl <= 0 {
|
||||||
|
ttl = defaultTTL
|
||||||
|
}
|
||||||
expiresAt := time.Now().Add(ttl)
|
expiresAt := time.Now().Add(ttl)
|
||||||
|
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
|
|||||||
+26
@@ -106,6 +106,32 @@ func (suite *LRUMemoryCacheTestSuite) TestExpiration() {
|
|||||||
suite.Nil(val)
|
suite.Nil(val)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestNonPositiveTTL_ClampedToDefaultTTL verifies the C11 cross-backend
|
||||||
|
// rule: a non-positive TTL is clamped to defaultTTL instead of stamping the
|
||||||
|
// entry as already-expired (which used to make every read miss
|
||||||
|
// immediately, unlike the Redis backend which used to keep the entry
|
||||||
|
// forever).
|
||||||
|
func (suite *LRUMemoryCacheTestSuite) TestNonPositiveTTL_ClampedToDefaultTTL() {
|
||||||
|
cache := NewLRUMemoryCache(1024*1024, 100)
|
||||||
|
|
||||||
|
cache.Set("zero-ttl", []byte("value"), 0)
|
||||||
|
val, found := cache.Get("zero-ttl")
|
||||||
|
suite.True(found, "zero TTL should be clamped to defaultTTL, not stored as already-expired")
|
||||||
|
suite.Equal([]byte("value"), val)
|
||||||
|
|
||||||
|
cache.Set("negative-ttl", []byte("value"), -1*time.Second)
|
||||||
|
val, found = cache.Get("negative-ttl")
|
||||||
|
suite.True(found, "negative TTL should be clamped to defaultTTL, not stored as already-expired")
|
||||||
|
suite.Equal([]byte("value"), val)
|
||||||
|
|
||||||
|
cache.mu.RLock()
|
||||||
|
entry, ok := cache.entries["zero-ttl"]
|
||||||
|
cache.mu.RUnlock()
|
||||||
|
suite.Require().True(ok)
|
||||||
|
suite.WithinDuration(time.Now().Add(defaultTTL), entry.expiresAt, 2*time.Second,
|
||||||
|
"clamped entry should expire ~defaultTTL from now")
|
||||||
|
}
|
||||||
|
|
||||||
func (suite *LRUMemoryCacheTestSuite) TestEvictionByCount() {
|
func (suite *LRUMemoryCacheTestSuite) TestEvictionByCount() {
|
||||||
cache := NewLRUMemoryCache(1024*1024, 3) // Max 3 entries
|
cache := NewLRUMemoryCache(1024*1024, 3) // Max 3 entries
|
||||||
|
|
||||||
|
|||||||
Vendored
+58
-17
@@ -28,6 +28,29 @@ const DefaultMaxCacheSize = 10000
|
|||||||
// This accounts for the CacheEntry struct overhead, map entry, and synchronization
|
// This accounts for the CacheEntry struct overhead, map entry, and synchronization
|
||||||
const approxEntryOverhead = 64
|
const approxEntryOverhead = 64
|
||||||
|
|
||||||
|
// defaultTTL is the sane fallback applied whenever a configured or per-entry
|
||||||
|
// TTL is non-positive (<=0).
|
||||||
|
//
|
||||||
|
// It serves two purposes:
|
||||||
|
// 1. Sizing the background cleanup ticker (cleanupRoutine): time.NewTicker
|
||||||
|
// panics for a non-positive duration, so a non-positive globalTTL is
|
||||||
|
// replaced with defaultTTL before the ticker is created.
|
||||||
|
// 2. Clamping a non-positive per-entry TTL passed to Set: time.Now().Add(ttl)
|
||||||
|
// with ttl<=0 stamps an entry as already expired, so every read would
|
||||||
|
// miss immediately.
|
||||||
|
//
|
||||||
|
// Cross-backend TTL rule (see also cache/memory/lru_memory_cache.go and
|
||||||
|
// cache/redis/redis.go, which each define and apply their own equivalent
|
||||||
|
// constant at their Set boundary): a non-positive TTL is always clamped to
|
||||||
|
// defaultTTL before being applied, for every backend. This keeps behavior
|
||||||
|
// identical regardless of backend. Without this rule, the three backends
|
||||||
|
// diverge: go-redis treats expiration<=0 as "no EX/PX option", i.e. the key
|
||||||
|
// never expires, the LRU backend stamps the entry as already expired so every
|
||||||
|
// read misses, and the standard (this) backend would otherwise panic in
|
||||||
|
// time.NewTicker. None of those are acceptable, so all three backends clamp
|
||||||
|
// to the same bounded default instead.
|
||||||
|
const defaultTTL = 60 * time.Second
|
||||||
|
|
||||||
type CacheEntry struct {
|
type CacheEntry struct {
|
||||||
ExpiresAt time.Time
|
ExpiresAt time.Time
|
||||||
Value []byte
|
Value []byte
|
||||||
@@ -82,6 +105,13 @@ func NewWithSize(globalTTL time.Duration, maxMemorySize int64, maxCacheSize int6
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *Cache) cleanupRoutine(globalTTL time.Duration) {
|
func (c *Cache) cleanupRoutine(globalTTL time.Duration) {
|
||||||
|
// time.NewTicker panics for a non-positive duration. A non-positive
|
||||||
|
// globalTTL (e.g. a misconfigured CACHE_TTL<=0) must not crash the
|
||||||
|
// process, so fall back to defaultTTL before deriving the interval.
|
||||||
|
if globalTTL <= 0 {
|
||||||
|
globalTTL = defaultTTL
|
||||||
|
}
|
||||||
|
|
||||||
// Clean up more frequently when the cache is large
|
// Clean up more frequently when the cache is large
|
||||||
ticker := time.NewTicker(globalTTL / 4)
|
ticker := time.NewTicker(globalTTL / 4)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
@@ -131,6 +161,12 @@ func (c *Cache) Set(key string, value []byte, ttl time.Duration) {
|
|||||||
c.evictOldest(int(c.maxCacheSize / 10)) // Evict 10% of entries
|
c.evictOldest(int(c.maxCacheSize / 10)) // Evict 10% of entries
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A non-positive TTL is clamped to defaultTTL so this backend expires
|
||||||
|
// entries the same way the LRU and Redis backends do. See the defaultTTL
|
||||||
|
// doc comment above for the cross-backend rule.
|
||||||
|
if ttl <= 0 {
|
||||||
|
ttl = defaultTTL
|
||||||
|
}
|
||||||
expiresAt := time.Now().Add(ttl)
|
expiresAt := time.Now().Add(ttl)
|
||||||
|
|
||||||
// Only compress if the value is larger than the threshold
|
// Only compress if the value is larger than the threshold
|
||||||
@@ -319,37 +355,42 @@ func (c *Cache) CleanExpiredEntries() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// evictOldest removes the oldest n entries from the cache
|
// evictOldest removes the oldest n entries from the cache, oldest by
|
||||||
|
// ExpiresAt first. It collects every live entry and sorts once with
|
||||||
|
// sort.Slice (O(n log n)), the same approach evictToFreeMemory below uses,
|
||||||
|
// instead of a nested-loop selection sort (O(n^2)).
|
||||||
|
//
|
||||||
|
// Collecting ALL entries (not a capped subset) matters for correctness, not
|
||||||
|
// just performance: sync.Map.Range iterates in unspecified order, so an
|
||||||
|
// early-terminated scan over an arbitrary subset can miss the true oldest
|
||||||
|
// keys entirely and evict the wrong entries.
|
||||||
func (c *Cache) evictOldest(n int) {
|
func (c *Cache) evictOldest(n int) {
|
||||||
type keyExpiry struct {
|
type keyExpiry struct {
|
||||||
expiresAt time.Time
|
expiresAt time.Time
|
||||||
key string
|
key string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Collect all entries with their expiry times
|
liveEntries := atomic.LoadInt64(&c.entryCount)
|
||||||
entries := make([]keyExpiry, 0, n*2)
|
if liveEntries < 0 {
|
||||||
|
liveEntries = 0
|
||||||
|
}
|
||||||
|
entries := make([]keyExpiry, 0, liveEntries)
|
||||||
c.entries.Range(func(k, v any) bool {
|
c.entries.Range(func(k, v any) bool {
|
||||||
key := k.(string)
|
key := k.(string)
|
||||||
entry := v.(CacheEntry)
|
entry := v.(CacheEntry)
|
||||||
entries = append(entries, keyExpiry{entry.ExpiresAt, key})
|
entries = append(entries, keyExpiry{entry.ExpiresAt, key})
|
||||||
return len(entries) < cap(entries)
|
return true
|
||||||
})
|
})
|
||||||
|
|
||||||
// Sort by expiry time (oldest first)
|
// Sort by expiry time (oldest first)
|
||||||
// Using a simple selection sort since we only need to find the n oldest
|
sort.Slice(entries, func(i, j int) bool {
|
||||||
for i := 0; i < n && i < len(entries); i++ {
|
return entries[i].expiresAt.Before(entries[j].expiresAt)
|
||||||
oldest := i
|
})
|
||||||
for j := i + 1; j < len(entries); j++ {
|
|
||||||
if entries[j].expiresAt.Before(entries[oldest].expiresAt) {
|
|
||||||
oldest = j
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Swap
|
|
||||||
if oldest != i {
|
|
||||||
entries[i], entries[oldest] = entries[oldest], entries[i]
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete this entry
|
if n > len(entries) {
|
||||||
|
n = len(entries)
|
||||||
|
}
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
if entry, exists := c.entries.LoadAndDelete(entries[i].key); exists {
|
if entry, exists := c.entries.LoadAndDelete(entries[i].key); exists {
|
||||||
cacheEntry := entry.(CacheEntry)
|
cacheEntry := entry.(CacheEntry)
|
||||||
atomic.AddInt64(&c.entryCount, -1)
|
atomic.AddInt64(&c.entryCount, -1)
|
||||||
|
|||||||
+38
@@ -346,3 +346,41 @@ func TestMemoryCacheClose_IdempotentAndStillUsable(t *testing.T) {
|
|||||||
t.Fatal("cache should remain writable after Close")
|
t.Fatal("cache should remain writable after Close")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestNewWithSize_NonPositiveTTL_DoesNotPanic is a regression test for C1: a
|
||||||
|
// non-positive globalTTL (e.g. a misconfigured CACHE_TTL<=0) used to reach
|
||||||
|
// time.NewTicker(globalTTL/4) unguarded inside cleanupRoutine, and
|
||||||
|
// time.NewTicker panics for d<=0. That panic happens in the background
|
||||||
|
// goroutine spawned by NewWithSize, so a regression here crashes the whole
|
||||||
|
// process, not just this test.
|
||||||
|
func TestNewWithSize_NonPositiveTTL_DoesNotPanic(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
ttl time.Duration
|
||||||
|
}{
|
||||||
|
{"zero TTL", 0},
|
||||||
|
{"negative TTL", -5 * time.Second},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
cache := NewWithSize(tt.ttl, DefaultMaxMemorySize, DefaultMaxCacheSize)
|
||||||
|
defer cache.Shutdown()
|
||||||
|
|
||||||
|
// Give the background cleanup goroutine a moment to construct its
|
||||||
|
// ticker.
|
||||||
|
time.Sleep(20 * time.Millisecond)
|
||||||
|
|
||||||
|
// Cache must remain fully usable.
|
||||||
|
cache.Set("key", []byte("value"), DefaultTestExpiration)
|
||||||
|
retrieved, found := cache.Get("key")
|
||||||
|
assert.True(t, found, "cache should be usable after construction with a non-positive TTL")
|
||||||
|
assert.Equal(t, []byte("value"), retrieved)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDefaultTTLValue documents the sane fallback used both to size the
|
||||||
|
// cleanup ticker (C1) and to clamp a non-positive per-entry TTL (C11).
|
||||||
|
func TestDefaultTTLValue(t *testing.T) {
|
||||||
|
assert.Equal(t, 60*time.Second, defaultTTL)
|
||||||
|
}
|
||||||
|
|||||||
Vendored
+25
-2
@@ -150,12 +150,35 @@ func (suite *MemoryTestSuite) Test_LargeItems() {
|
|||||||
suite.Assert().Equal(largeValue, retrieved)
|
suite.Assert().Equal(largeValue, retrieved)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Test_ZeroTTL verifies the C11 cross-backend rule: a non-positive TTL is
|
||||||
|
// clamped to defaultTTL instead of being stored as already-expired. Before
|
||||||
|
// this fix, Set("zero-ttl", ..., 0) stamped ExpiresAt at time.Now(), so the
|
||||||
|
// very next Get always missed.
|
||||||
func (suite *MemoryTestSuite) Test_ZeroTTL() {
|
func (suite *MemoryTestSuite) Test_ZeroTTL() {
|
||||||
cache := New(5 * time.Second)
|
cache := New(5 * time.Second)
|
||||||
cache.Set("zero-ttl", []byte("value"), 0)
|
cache.Set("zero-ttl", []byte("value"), 0)
|
||||||
|
|
||||||
_, found := cache.Get("zero-ttl")
|
retrieved, found := cache.Get("zero-ttl")
|
||||||
suite.Assert().False(found, "Item with zero TTL should not be stored")
|
suite.Assert().True(found, "zero TTL should be clamped to defaultTTL, not stored as already-expired")
|
||||||
|
suite.Assert().Equal([]byte("value"), retrieved)
|
||||||
|
|
||||||
|
raw, ok := cache.entries.Load("zero-ttl")
|
||||||
|
suite.Require().True(ok)
|
||||||
|
entry, ok := raw.(CacheEntry)
|
||||||
|
suite.Require().True(ok)
|
||||||
|
suite.Assert().WithinDuration(time.Now().Add(defaultTTL), entry.ExpiresAt, 2*time.Second,
|
||||||
|
"clamped entry should expire ~defaultTTL from now")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test_NegativeTTL verifies the same clamp applies to a negative TTL, not
|
||||||
|
// just exactly zero.
|
||||||
|
func (suite *MemoryTestSuite) Test_NegativeTTL() {
|
||||||
|
cache := New(5 * time.Second)
|
||||||
|
cache.Set("negative-ttl", []byte("value"), -1*time.Second)
|
||||||
|
|
||||||
|
retrieved, found := cache.Get("negative-ttl")
|
||||||
|
suite.Assert().True(found, "negative TTL should be clamped to defaultTTL, not stored as already-expired")
|
||||||
|
suite.Assert().Equal([]byte("value"), retrieved)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (suite *MemoryTestSuite) Test_LongTTL() {
|
func (suite *MemoryTestSuite) Test_LongTTL() {
|
||||||
|
|||||||
Vendored
+18
@@ -65,6 +65,21 @@ type RedisClientConfig struct {
|
|||||||
RedisDB int
|
RedisDB int
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// defaultTTL is the sane fallback applied to a non-positive per-entry TTL
|
||||||
|
// passed to Set.
|
||||||
|
//
|
||||||
|
// go-redis treats a non-positive expiration as "send SET with no EX/PX
|
||||||
|
// option", which makes the key permanent (see redis/go-redis/v9
|
||||||
|
// string_commands.go: `if expiration > 0 { ... }`, no else branch adds an
|
||||||
|
// expiry). That would silently diverge from the in-memory backends: the
|
||||||
|
// standard memory cache and the LRU cache both clamp a non-positive TTL to
|
||||||
|
// their own equivalent defaultTTL constant (cache/memory/memory.go and
|
||||||
|
// cache/memory/lru_memory_cache.go) so the entry still expires. Clamping here
|
||||||
|
// too keeps all three backends' behavior identical: a misconfigured
|
||||||
|
// (non-positive) TTL always results in a bounded expiry, never a permanent
|
||||||
|
// entry.
|
||||||
|
const defaultTTL = 60 * time.Second
|
||||||
|
|
||||||
func New(redisClientConfig *RedisClientConfig) (*RedisConfig, error) {
|
func New(redisClientConfig *RedisClientConfig) (*RedisConfig, error) {
|
||||||
c := &RedisConfig{
|
c := &RedisConfig{
|
||||||
client: redis.NewClient(&redis.Options{
|
client: redis.NewClient(&redis.Options{
|
||||||
@@ -89,6 +104,9 @@ func New(redisClientConfig *RedisClientConfig) (*RedisConfig, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *RedisConfig) Set(key string, value []byte, ttl time.Duration) error {
|
func (c *RedisConfig) Set(key string, value []byte, ttl time.Duration) error {
|
||||||
|
if ttl <= 0 {
|
||||||
|
ttl = defaultTTL
|
||||||
|
}
|
||||||
return c.client.Set(c.ctx, c.prependKeyName(key), value, ttl).Err()
|
return c.client.Set(c.ctx, c.prependKeyName(key), value, ttl).Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Vendored
+31
-5
@@ -134,14 +134,40 @@ func TestGet_MissingKey_ReturnsFalseNoError(t *testing.T) {
|
|||||||
assert.Nil(t, val)
|
assert.Nil(t, val)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSet_TTLZero_KeyPersists(t *testing.T) {
|
// TestSet_TTLZero_ClampedToDefaultTTL verifies the C11 cross-backend rule: a
|
||||||
|
// non-positive TTL is clamped to defaultTTL instead of go-redis's native
|
||||||
|
// behavior of sending SET with no EX/PX option (which makes the key
|
||||||
|
// permanent). This keeps Redis's expiry semantics identical to the memory
|
||||||
|
// and LRU backends instead of silently diverging into "never expires".
|
||||||
|
func TestSet_TTLZero_ClampedToDefaultTTL(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
rc, s := newTestRedis(t)
|
rc, s := newTestRedis(t)
|
||||||
require.NoError(t, rc.Set("persist", []byte("yes"), 0))
|
require.NoError(t, rc.Set("clamped", []byte("yes"), 0))
|
||||||
s.FastForward(24 * time.Hour)
|
|
||||||
_, found, err := rc.Get("persist")
|
// Still present before defaultTTL elapses.
|
||||||
|
s.FastForward(defaultTTL - 5*time.Second)
|
||||||
|
_, found, err := rc.Get("clamped")
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.True(t, found)
|
assert.True(t, found, "clamped entry should still be present before defaultTTL elapses")
|
||||||
|
|
||||||
|
// Gone once defaultTTL elapses -- i.e. it is bounded, not permanent.
|
||||||
|
s.FastForward(10 * time.Second)
|
||||||
|
_, found, err = rc.Get("clamped")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.False(t, found, "zero TTL should expire after defaultTTL, not persist forever")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSet_NegativeTTL_ClampedToDefaultTTL verifies the same clamp applies to
|
||||||
|
// a negative TTL, not just exactly zero.
|
||||||
|
func TestSet_NegativeTTL_ClampedToDefaultTTL(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
rc, s := newTestRedis(t)
|
||||||
|
require.NoError(t, rc.Set("neg", []byte("yes"), -1*time.Second))
|
||||||
|
|
||||||
|
s.FastForward(defaultTTL + 5*time.Second)
|
||||||
|
_, found, err := rc.Get("neg")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.False(t, found, "negative TTL should expire after defaultTTL, not persist forever")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSet_WithTTL_KeyExpires(t *testing.T) {
|
func TestSet_WithTTL_KeyExpires(t *testing.T) {
|
||||||
|
|||||||
Vendored
+4
-1
@@ -16,7 +16,10 @@ type CacheWrapper struct {
|
|||||||
// NewCacheWrapper creates a new cache wrapper
|
// NewCacheWrapper creates a new cache wrapper
|
||||||
func NewCacheWrapper(config *RedisConfig, logger *libpack_logger.Logger) *CacheWrapper {
|
func NewCacheWrapper(config *RedisConfig, logger *libpack_logger.Logger) *CacheWrapper {
|
||||||
if logger == nil {
|
if logger == nil {
|
||||||
logger = &libpack_logger.Logger{}
|
// Use the proper constructor, not a zero-value &Logger{} -- its
|
||||||
|
// timeFormat field is a nil atomic.Pointer, so a zero-value Logger
|
||||||
|
// panics on the first log call instead of degrading gracefully.
|
||||||
|
logger = libpack_logger.New()
|
||||||
}
|
}
|
||||||
return &CacheWrapper{
|
return &CacheWrapper{
|
||||||
redis: config,
|
redis: config,
|
||||||
|
|||||||
Reference in New Issue
Block a user