mirror of
https://github.com/lukaszraczylo/graphql-monitoring-proxy.git
synced 2026-10-12 05:32:36 +00:00
fix: restore tracing endpoint validation; honor per-side client timeouts; cache eviction target; redis close
- tracing: validateEndpoint used fmt.Sscanf("%s:%d") which treats %s as
greedy, so every valid host:port endpoint failed and traced==nil disabled
tracing entirely. Validate with net.SplitHostPort (pure helper, port range
0-65535); test TestValidateEndpoint.
- proxy: createFasthttpClient used clientTimeout for every timeout slot,
ignoring the parsed CLIENT_READ_TIMEOUT/CLIENT_WRITE_TIMEOUT (silent
config loss, documented separately in README). Honour per-side read/write
timeouts, falling back to client timeout when unset.
- cache/memory: evictToFreeMemory capped candidates at maxCacheSize/5 and
stopped at the subset end, so a large write could leave the cache over
its memory limit. Collect all entries and sort.Slice (correct + O(n log n)
vs O(cap^2)), evict until the target is met or the cache is drained.
- cache/redis: the Redis cache client was never closed at shutdown (unlike
the metrics aggregator). Add RedisConfig.Close, CacheWrapper.Close, a
cache.Shutdown and register it with the ShutdownManager.
This commit is contained in:
Vendored
+10
@@ -168,6 +168,16 @@ func EnableCache(cfg *CacheConfig) {
|
||||
config = cfg
|
||||
}
|
||||
|
||||
// Shutdown closes the underlying cache client if it owns external resources
|
||||
// (the Redis connection pool). No-op for in-memory caches.
|
||||
func Shutdown() {
|
||||
if config != nil && config.Client != nil {
|
||||
if closer, ok := config.Client.(io.Closer); ok {
|
||||
_ = closer.Close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func CacheLookup(hash string) []byte {
|
||||
if !IsCacheInitialized() {
|
||||
return nil
|
||||
|
||||
Vendored
+29
@@ -183,3 +183,32 @@ func TestSetMaxMemorySize(t *testing.T) {
|
||||
func generateKey(index int) string {
|
||||
return "test-key-" + fmt.Sprintf("%d", index)
|
||||
}
|
||||
|
||||
// TestEvictToFreeMemoryWithSmallMaxEntriesReachesTarget is a regression for
|
||||
// under-eviction: evictToFreeMemory used to cap its candidate set at
|
||||
// maxCacheSize/5 and stop at the end of that subset. With a small
|
||||
// maxCacheSize the cap (maxCacheSize/5) could be lower than the number of
|
||||
// entries needed to satisfy bytesToFree, so the cache stayed over its
|
||||
// memory limit.
|
||||
func TestEvictToFreeMemoryWithSmallMaxEntriesReachesTarget(t *testing.T) {
|
||||
// maxCacheSize=10 -> old candidate cap was maxCacheSize/5 = 2 entries.
|
||||
cache := NewWithSize(5*time.Second, DefaultMaxMemorySize, 10)
|
||||
val := make([]byte, 1024) // ~1KB + overhead each
|
||||
const count = 8
|
||||
for i := 0; i < count; i++ {
|
||||
cache.Set(fmt.Sprintf("e%d", i), val, time.Second)
|
||||
}
|
||||
before := cache.GetMemoryUsage()
|
||||
// Request freeing an amount equal to ~half the entries (well beyond the
|
||||
// old 2-entry cap).
|
||||
need := (count / 2) * (int64(len(val)) + approxEntryOverhead)
|
||||
cache.evictToFreeMemory(need)
|
||||
after := cache.GetMemoryUsage()
|
||||
assert.LessOrEqual(t, after, before-need,
|
||||
"evictToFreeMemory must free at least the requested bytes even across more than maxCacheSize/5 entries")
|
||||
|
||||
// Requesting more than the total must drain the cache.
|
||||
cache.evictToFreeMemory(1 << 30)
|
||||
assert.Equal(t, int64(0), cache.GetMemoryUsage(),
|
||||
"an oversized request should evict every entry")
|
||||
}
|
||||
|
||||
Vendored
+17
-19
@@ -8,6 +8,7 @@ import (
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"io"
|
||||
"sort"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
@@ -322,32 +323,29 @@ func (c *Cache) evictToFreeMemory(bytesToFree int64) {
|
||||
memorySize int64
|
||||
}
|
||||
|
||||
// Collect entries to consider for eviction
|
||||
entries := make([]keyMemorySize, 0, int(c.maxCacheSize/5))
|
||||
// Collect ALL entries so eviction can actually reach the target. The
|
||||
// previous code capped the candidate set at maxCacheSize/5 and stopped at
|
||||
// the end of that subset, so a large write could leave the cache over its
|
||||
// memory limit.
|
||||
entries := make([]keyMemorySize, 0, int(c.maxCacheSize))
|
||||
c.entries.Range(func(k, v any) bool {
|
||||
key := k.(string)
|
||||
entry := v.(CacheEntry)
|
||||
entries = append(entries, keyMemorySize{entry.ExpiresAt, key, entry.MemorySize})
|
||||
return len(entries) < cap(entries)
|
||||
return true
|
||||
})
|
||||
|
||||
// Sort entries by expiry time (oldest first)
|
||||
// Simple selection sort since we only need to find the oldest entries
|
||||
var freedBytes int64
|
||||
for i := 0; i < len(entries) && freedBytes < bytesToFree; i++ {
|
||||
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]
|
||||
}
|
||||
// Sort by expiry time (oldest first)
|
||||
sort.Slice(entries, func(i, j int) bool {
|
||||
return entries[i].expiresAt.Before(entries[j].expiresAt)
|
||||
})
|
||||
|
||||
// Delete this entry
|
||||
if entry, exists := c.entries.LoadAndDelete(entries[i].key); exists {
|
||||
var freedBytes int64
|
||||
for _, e := range entries {
|
||||
if freedBytes >= bytesToFree {
|
||||
break
|
||||
}
|
||||
if entry, exists := c.entries.LoadAndDelete(e.key); exists {
|
||||
cacheEntry := entry.(CacheEntry)
|
||||
atomic.AddInt64(&c.entryCount, -1)
|
||||
atomic.AddInt64(&c.memoryUsage, -cacheEntry.MemorySize)
|
||||
|
||||
Vendored
+10
@@ -19,6 +19,16 @@ type RedisConfig struct {
|
||||
prefix string
|
||||
}
|
||||
|
||||
// Close releases the underlying Redis connection pool. Called at process
|
||||
// shutdown so the pooled connections and go-redis background goroutines are
|
||||
// terminated instead of relying on process exit.
|
||||
func (c *RedisConfig) Close() error {
|
||||
if c == nil || c.client == nil {
|
||||
return nil
|
||||
}
|
||||
return c.client.Close()
|
||||
}
|
||||
|
||||
func (c *RedisConfig) prependKeyName(key string) string {
|
||||
builder := c.builderPool.Get().(*strings.Builder)
|
||||
defer c.builderPool.Put(builder)
|
||||
|
||||
Vendored
+9
@@ -102,3 +102,12 @@ func (w *CacheWrapper) GetMemoryUsage() int64 {
|
||||
func (w *CacheWrapper) GetMaxMemorySize() int64 {
|
||||
return 0
|
||||
}
|
||||
|
||||
// Close releases the underlying Redis client's connection pool at shutdown.
|
||||
// Makes CacheWrapper satisfy io.Closer so the cache layer can close it.
|
||||
func (w *CacheWrapper) Close() error {
|
||||
if w == nil || w.redis == nil {
|
||||
return nil
|
||||
}
|
||||
return w.redis.Close()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user