mirror of
https://github.com/lukaszraczylo/graphql-monitoring-proxy.git
synced 2026-10-11 05:27:47 +00:00
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.
387 lines
12 KiB
Go
387 lines
12 KiB
Go
package libpack_cache_memory
|
|
|
|
import (
|
|
"fmt"
|
|
"io"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
)
|
|
|
|
// Default constants for testing
|
|
const (
|
|
DefaultTestExpiration = 5 * time.Second
|
|
)
|
|
|
|
func TestMemoryCacheClear(t *testing.T) {
|
|
cache := New(DefaultTestExpiration)
|
|
|
|
// Add some entries
|
|
cache.Set("key1", []byte("value1"), DefaultTestExpiration)
|
|
cache.Set("key2", []byte("value2"), DefaultTestExpiration)
|
|
|
|
// Verify entries exist
|
|
_, found := cache.Get("key1")
|
|
assert.True(t, found, "Expected key1 to exist before clearing cache")
|
|
|
|
// Clear the cache
|
|
cache.Clear()
|
|
|
|
// Verify cache is empty
|
|
_, found = cache.Get("key1")
|
|
assert.False(t, found, "Expected key1 to be removed after clearing cache")
|
|
_, found = cache.Get("key2")
|
|
assert.False(t, found, "Expected key2 to be removed after clearing cache")
|
|
|
|
// Check that counter was reset
|
|
assert.Equal(t, int64(0), cache.CountQueries(), "Expected count to be 0 after clearing cache")
|
|
}
|
|
|
|
func TestMemoryCacheCountQueries(t *testing.T) {
|
|
cache := New(DefaultTestExpiration)
|
|
|
|
// Check initial count
|
|
assert.Equal(t, int64(0), cache.CountQueries(), "Expected initial count to be 0")
|
|
|
|
// Add some entries
|
|
cache.Set("key1", []byte("value1"), DefaultTestExpiration)
|
|
cache.Set("key2", []byte("value2"), DefaultTestExpiration)
|
|
cache.Set("key3", []byte("value3"), DefaultTestExpiration)
|
|
|
|
// Check count
|
|
assert.Equal(t, int64(3), cache.CountQueries(), "Expected count to be 3 after adding 3 entries")
|
|
|
|
// Delete an entry
|
|
cache.Delete("key1")
|
|
|
|
// Check count after deletion
|
|
assert.Equal(t, int64(2), cache.CountQueries(), "Expected count to be 2 after deleting 1 entry")
|
|
}
|
|
|
|
func TestMemoryCacheCleanExpiredEntries(t *testing.T) {
|
|
// Create a cache with default expiration
|
|
cache := New(10 * time.Second)
|
|
|
|
// Add an entry that will expire quickly
|
|
cache.Set("expire-soon", []byte("value1"), 10*time.Millisecond)
|
|
|
|
// Add an entry that will not expire during the test
|
|
cache.Set("expire-later", []byte("value3"), 10*time.Minute)
|
|
|
|
// Initial count should be 2
|
|
assert.Equal(t, int64(2), cache.CountQueries(), "Expected count to be 2 after adding entries")
|
|
|
|
// Wait for short expiration
|
|
time.Sleep(20 * time.Millisecond)
|
|
|
|
// Get the expired key directly to verify it's expired
|
|
_, expiredFound := cache.Get("expire-soon")
|
|
assert.False(t, expiredFound, "Key 'expire-soon' should be expired now")
|
|
|
|
// Verify the not-expired key is still there
|
|
val, nonExpiredFound := cache.Get("expire-later")
|
|
assert.True(t, nonExpiredFound, "Key 'expire-later' should not be expired")
|
|
assert.Equal(t, []byte("value3"), val, "Expected correct value for 'expire-later'")
|
|
|
|
// Manually clean expired entries
|
|
cache.CleanExpiredEntries()
|
|
|
|
// Count should be 1 now (only the non-expired entry)
|
|
assert.Equal(t, int64(1), cache.CountQueries(), "Expected count to be 1 after cleaning expired entries")
|
|
}
|
|
|
|
func TestMemoryCacheConcurrentGetOnExpiredEntry_CountersConsistent(t *testing.T) {
|
|
// Regression: concurrent Gets on the same expired entry must decrement
|
|
// entryCount exactly once. Previously Get used an unconditional Delete +
|
|
// decrement, so N concurrent Gets drove entryCount negative.
|
|
cache := NewWithSize(DefaultTestExpiration, DefaultMaxMemorySize, DefaultMaxCacheSize)
|
|
|
|
cache.Set("k", []byte("v"), 10*time.Millisecond)
|
|
time.Sleep(50 * time.Millisecond) // let it expire
|
|
|
|
var wg sync.WaitGroup
|
|
for range 32 {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
if _, ok := cache.Get("k"); ok {
|
|
t.Error("expired entry should not be returned")
|
|
}
|
|
}()
|
|
}
|
|
wg.Wait()
|
|
|
|
if got := cache.CountQueries(); got != 0 {
|
|
t.Fatalf("entryCount = %d after concurrent expired gets, want 0", got)
|
|
}
|
|
if got := cache.GetMemoryUsage(); got != 0 {
|
|
t.Fatalf("memoryUsage = %d after concurrent expired gets, want 0", got)
|
|
}
|
|
}
|
|
|
|
func TestMemoryCacheConcurrentSetSameNewKey_CounterNotDoubled(t *testing.T) {
|
|
// Regression: two (or more) concurrent Sets of the same brand-new key must
|
|
// not both take the "new entry" branch. Previously the exists-check and
|
|
// counter increment were separate atomic ops, so concurrent Sets of a new
|
|
// key each incremented entryCount, inflating it for a single stored entry.
|
|
cache := NewWithSize(DefaultTestExpiration, DefaultMaxMemorySize, DefaultMaxCacheSize)
|
|
|
|
const workers = 32
|
|
var wg sync.WaitGroup
|
|
for range workers {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
cache.Set("same-key", []byte("value"), DefaultTestExpiration)
|
|
}()
|
|
}
|
|
wg.Wait()
|
|
|
|
if got := cache.CountQueries(); got != 1 {
|
|
t.Fatalf("entryCount = %d after %d concurrent Sets of one new key, want 1", got, workers)
|
|
}
|
|
|
|
// Sanity: the entry is actually stored and retrievable.
|
|
if _, ok := cache.Get("same-key"); !ok {
|
|
t.Fatal("key should be retrievable after Set")
|
|
}
|
|
}
|
|
|
|
func TestMemoryCacheConcurrentSetDeleteEvict_CountersMatchActualState(t *testing.T) {
|
|
// Regression for M4 (evictToFreeMemory pre-allocation sized from live
|
|
// entryCount, not configured maxCacheSize) and S2 (lock-free Set via
|
|
// sync.Map.Swap, so Set no longer double-accounts when it races with
|
|
// Delete/evictOldest/evictToFreeMemory/CleanExpiredEntries on the same
|
|
// key).
|
|
//
|
|
// The specific key each goroutine touches, and which operation wins a
|
|
// given race, is inherently nondeterministic. What must always hold,
|
|
// deterministically, once every goroutine has joined, is the invariant
|
|
// that entryCount/memoryUsage are derived state: they must exactly match
|
|
// the cache's real final contents. Recomputing "expected" from the
|
|
// actual final map (the known final key set) and diffing against the
|
|
// atomic counters catches any lost or double-counted update.
|
|
const (
|
|
numKeys = 50
|
|
numWorkers = 16
|
|
numOpsEach = 300
|
|
)
|
|
|
|
// Small maxCacheSize so Set's own eviction path (evictOldest) also fires
|
|
// under the concurrent load, in addition to the evictors driven directly
|
|
// below.
|
|
cache := NewWithSize(DefaultTestExpiration, DefaultMaxMemorySize, 40)
|
|
defer cache.Shutdown()
|
|
|
|
keyFor := func(i int) string { return fmt.Sprintf("hammer-key-%d", i) }
|
|
|
|
// Pre-seed so evictors have entries to act on from the start.
|
|
for i := range numKeys {
|
|
cache.Set(keyFor(i), []byte("seed-value"), DefaultTestExpiration)
|
|
}
|
|
|
|
var wg sync.WaitGroup
|
|
for w := range numWorkers {
|
|
wg.Add(1)
|
|
go func(worker int) {
|
|
defer wg.Done()
|
|
for op := range numOpsEach {
|
|
k := keyFor((worker + op) % numKeys)
|
|
switch op % 3 {
|
|
case 0:
|
|
cache.Set(k, []byte("value"), DefaultTestExpiration)
|
|
case 1:
|
|
cache.Delete(k)
|
|
case 2:
|
|
cache.evictOldest(2)
|
|
}
|
|
}
|
|
}(w)
|
|
}
|
|
|
|
wg.Add(2)
|
|
go func() {
|
|
defer wg.Done()
|
|
for range 100 {
|
|
cache.evictToFreeMemory(128)
|
|
}
|
|
}()
|
|
go func() {
|
|
defer wg.Done()
|
|
for range 100 {
|
|
cache.CleanExpiredEntries()
|
|
}
|
|
}()
|
|
|
|
wg.Wait()
|
|
|
|
// Stop the background cleanup goroutine before recomputing expected
|
|
// state, so it cannot race with the Range below (nothing has expired
|
|
// yet - DefaultTestExpiration is 5s - but this removes any doubt).
|
|
cache.Shutdown()
|
|
|
|
var wantCount, wantMemory int64
|
|
cache.entries.Range(func(_, v any) bool {
|
|
entry := v.(CacheEntry)
|
|
wantCount++
|
|
wantMemory += entry.MemorySize
|
|
return true
|
|
})
|
|
|
|
if got := cache.CountQueries(); got != wantCount {
|
|
t.Fatalf("entryCount = %d, want %d (actual live entries after join)", got, wantCount)
|
|
}
|
|
if got := cache.GetMemoryUsage(); got != wantMemory {
|
|
t.Fatalf("memoryUsage = %d, want %d (sum of live entries' MemorySize after join)", got, wantMemory)
|
|
}
|
|
}
|
|
|
|
func TestMemoryCacheConcurrentSetDeleteClear_CountersMatchActualState(t *testing.T) {
|
|
// Regression for Clear(): the previous implementation Ranged the
|
|
// sync.Map calling Delete per key, then unconditionally reset both
|
|
// counters with StoreInt64(0). A Set racing Clear could land after the
|
|
// Range pass but before or after the StoreInt64 calls, leaving a live
|
|
// entry in the map while the counters read zero - the next Delete of
|
|
// that key then drove the counters permanently negative.
|
|
//
|
|
// Clear now follows the same linearized LoadAndDelete-then-subtract
|
|
// pattern as Delete/Get/CleanExpiredEntries/the evictors, so it no
|
|
// longer promises a consistent point-in-time snapshot (a concurrent Set
|
|
// can survive a Clear) but does guarantee entryCount/memoryUsage always
|
|
// match the entries actually removed. As in
|
|
// TestMemoryCacheConcurrentSetDeleteEvict_CountersMatchActualState,
|
|
// which key wins a given race is nondeterministic, so the assertion
|
|
// recomputes "expected" from the final map contents rather than
|
|
// asserting a fixed number.
|
|
const (
|
|
numKeys = 50
|
|
numWorkers = 16
|
|
numOpsEach = 300
|
|
)
|
|
|
|
cache := NewWithSize(DefaultTestExpiration, DefaultMaxMemorySize, DefaultMaxCacheSize)
|
|
defer cache.Shutdown()
|
|
|
|
keyFor := func(i int) string { return fmt.Sprintf("clear-key-%d", i) }
|
|
|
|
// Pre-seed so Clear and the Delete workers have entries to act on from
|
|
// the start.
|
|
for i := range numKeys {
|
|
cache.Set(keyFor(i), []byte("seed-value"), DefaultTestExpiration)
|
|
}
|
|
|
|
var wg sync.WaitGroup
|
|
for w := range numWorkers {
|
|
wg.Add(1)
|
|
go func(worker int) {
|
|
defer wg.Done()
|
|
for op := range numOpsEach {
|
|
k := keyFor((worker + op) % numKeys)
|
|
switch op % 2 {
|
|
case 0:
|
|
cache.Set(k, []byte("value"), DefaultTestExpiration)
|
|
case 1:
|
|
cache.Delete(k)
|
|
}
|
|
}
|
|
}(w)
|
|
}
|
|
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
for range 100 {
|
|
cache.Clear()
|
|
}
|
|
}()
|
|
|
|
wg.Wait()
|
|
|
|
// Stop the background cleanup goroutine before recomputing expected
|
|
// state, so it cannot race with the Range below (nothing has expired
|
|
// yet - DefaultTestExpiration is 5s - but this removes any doubt).
|
|
cache.Shutdown()
|
|
|
|
var wantCount, wantMemory int64
|
|
cache.entries.Range(func(_, v any) bool {
|
|
entry := v.(CacheEntry)
|
|
wantCount++
|
|
wantMemory += entry.MemorySize
|
|
return true
|
|
})
|
|
|
|
if got := cache.CountQueries(); got != wantCount {
|
|
t.Fatalf("entryCount = %d, want %d (actual live entries after join)", got, wantCount)
|
|
}
|
|
if got := cache.GetMemoryUsage(); got != wantMemory {
|
|
t.Fatalf("memoryUsage = %d, want %d (sum of live entries' MemorySize after join)", got, wantMemory)
|
|
}
|
|
}
|
|
|
|
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")
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|