mirror of
https://github.com/lukaszraczylo/graphql-monitoring-proxy.git
synced 2026-10-17 06:17:40 +00:00
fix(cache): make memory counter accounting lock-free and bound eviction alloc
The Set mutex serialised writes but left Delete, expiry, and eviction racing the same counters, so double subtraction remained. Eviction also pre-allocated from the configured maximum, which could allocate hundreds of megabytes on a near-empty cache. Use sync.Map.Swap in Set and adjust counters from the linearized previous value. Size the eviction slice from the live entry count. CleanExpiredEntries now subtracts the size of the entry it actually deleted. A concurrency test recomputes expected counters from the final map contents.
This commit is contained in:
+90
@@ -1,6 +1,7 @@
|
||||
package libpack_cache_memory
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"sync"
|
||||
"testing"
|
||||
@@ -148,6 +149,95 @@ func TestMemoryCacheConcurrentSetSameNewKey_CounterNotDoubled(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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 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
|
||||
|
||||
Reference in New Issue
Block a user