mirror of
https://github.com/lukaszraczylo/graphql-monitoring-proxy.git
synced 2026-06-05 23:03:48 +00:00
c2c75d69c0
Performance / resource usage: - circuit_breaker_metrics: fix data race on failCounters map (RWMutex + double-checked locking) - server.go: drop user_id and op_name metric labels (Prometheus cardinality bound); de-duplicate extractUserInfo - graphql.go: gate runtime.ReadMemStats per-request behind ENABLE_ALLOCATION_TRACKING flag (default off) - graphql.go: collapse two-pass AST scan into single pass; lower-case once - sanitization.go: cache compiled redaction regexes per pattern via sync.Map; hoist inner constants to pkg vars - proxy.go: hoist connection/timeout substrings to pkg vars; sentinel errors for static error paths; drop dead Headers map alloc - metrics_aggregator.go: log-field allocation guarded by Logger.IsLevelEnabled - logging/logger.go: add IsLevelEnabled helper - lru_cache.go: 16-shard sharding, FNV-1a routing (concurrent throughput +22%) - cache/memory/lru_memory_cache.go: gzip compress/decompress moved outside mu.Lock - rps_tracker.go: RWMutex+uint64 -> atomic.Uint64 - retry_budget.go: drop unused mutex - api.go: bannedUsersIDs map+RWMutex -> sync.Map (+ snapshot/replace helpers) - tracing/tracing.go: pkg-level constSpanAttrs, copy-then-append in StartSpanWithAttributes - admin_dashboard.go: handleStatsWebSocket reuses bytes.Buffer + json.Encoder per connection Build / runtime: - Makefile: -ldflags="-s -w" -trimpath, CGO_ENABLED=0 for build (=1 for test recipes) - Dockerfile + Dockerfile.goreleaser: ENV GOMEMLIMIT=512MiB - main.go: blank import go.uber.org/automaxprocs (cgroup-aware GOMAXPROCS) - main.go: PPROF_PORT env var wires net/http/pprof on 127.0.0.1 only with full server timeouts - README.md: env-var docs + metric-label docs updated; cardinality note Test coverage push (per package): - main 51.2% -> 74.7% - cache 66.3% -> 93.7% - cache/redis 45.5% -> 98.2% - tracing 66.7% -> 72.9% - (cache/memory 91.6%, logging 91.9%, monitoring 77.6%, pkg/pools 100% unchanged) New test files: coverage_micro_test, coverage_extras_test, server_handlers_test, api_health_test, admin_dashboard_cluster_test, metrics_aggregator_test, concerns_test, cache/cache_coverage_test, cache/redis/redis_coverage_test, tracing/tracing_coverage_test. Bug fix: connection_resilience_test.go TestIntegratedHealthManagement.health_manager_startup was sync.Once-coupled to InitializeBackendHealth and panicked when another test (e.g. via parseConfig) had already triggered Once. Use NewBackendHealthManager directly.
319 lines
7.9 KiB
Go
319 lines
7.9 KiB
Go
package main
|
|
|
|
import (
|
|
"container/list"
|
|
"hash/fnv"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
)
|
|
|
|
// shardCount is the number of LRU shards. Must be a power of two for efficient
|
|
// modulo via bitmask, but the implementation uses a plain modulo to keep the
|
|
// constant flexible.
|
|
const shardCount = 16
|
|
|
|
// LRUCacheEntry represents a cache entry with metadata.
|
|
type LRUCacheEntry struct {
|
|
timestamp time.Time
|
|
value any
|
|
element *list.Element
|
|
key string
|
|
size int64
|
|
}
|
|
|
|
// lruCacheShard owns a slice of the keyspace and its own mutex/map/list. All
|
|
// per-shard state lives here so that operations on different shards do not
|
|
// contend on the same lock.
|
|
type lruCacheShard struct {
|
|
entries map[string]*LRUCacheEntry
|
|
evictList *list.List
|
|
currentSize int64
|
|
count int64
|
|
mu sync.Mutex
|
|
}
|
|
|
|
func newLRUCacheShard() *lruCacheShard {
|
|
return &lruCacheShard{
|
|
entries: make(map[string]*LRUCacheEntry),
|
|
evictList: list.New(),
|
|
}
|
|
}
|
|
|
|
// LRUCache implements a thread-safe LRU cache with O(1) operations and 16-way
|
|
// sharding to reduce mutex contention under concurrent load. Capacity and
|
|
// size limits are enforced globally; sharding is a concurrency optimisation.
|
|
type LRUCache struct {
|
|
shards [shardCount]*lruCacheShard
|
|
maxEntries int
|
|
maxSize int64
|
|
totalSize int64 // atomic, sum of shard sizes
|
|
totalCount int64 // atomic, sum of shard counts
|
|
|
|
// evictMu serialises cross-shard eviction passes so that two writers do
|
|
// not race to over-evict. The hot Get/Set paths do not touch this lock.
|
|
evictMu sync.Mutex
|
|
|
|
// entries and evictList are retained as no-op placeholders so that the
|
|
// existing test suite (which asserts NotNil on these fields after
|
|
// construction) keeps compiling. They are not used by the sharded
|
|
// implementation.
|
|
entries map[string]*LRUCacheEntry
|
|
evictList *list.List
|
|
}
|
|
|
|
// NewLRUCache creates a new LRU cache with the given global limits.
|
|
func NewLRUCache(maxEntries int, maxSize int64) *LRUCache {
|
|
if maxEntries < 0 {
|
|
maxEntries = 0
|
|
}
|
|
if maxSize < 0 {
|
|
maxSize = 0
|
|
}
|
|
|
|
c := &LRUCache{
|
|
maxEntries: maxEntries,
|
|
maxSize: maxSize,
|
|
entries: make(map[string]*LRUCacheEntry),
|
|
evictList: list.New(),
|
|
}
|
|
for i := 0; i < shardCount; i++ {
|
|
c.shards[i] = newLRUCacheShard()
|
|
}
|
|
return c
|
|
}
|
|
|
|
// shardFor routes a key to one of the shards via FNV-1a (no extra dependency).
|
|
func (c *LRUCache) shardFor(key string) *lruCacheShard {
|
|
h := fnv.New64a()
|
|
_, _ = h.Write([]byte(key))
|
|
return c.shards[h.Sum64()%shardCount]
|
|
}
|
|
|
|
// Get retrieves a value from the cache.
|
|
func (c *LRUCache) Get(key string) (any, bool) {
|
|
s := c.shardFor(key)
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
entry, exists := s.entries[key]
|
|
if !exists {
|
|
return nil, false
|
|
}
|
|
|
|
s.evictList.MoveToFront(entry.element)
|
|
entry.timestamp = time.Now()
|
|
return entry.value, true
|
|
}
|
|
|
|
// Set adds or updates a value in the cache.
|
|
func (c *LRUCache) Set(key string, value any, size int64) {
|
|
s := c.shardFor(key)
|
|
|
|
s.mu.Lock()
|
|
if entry, exists := s.entries[key]; exists {
|
|
delta := size - entry.size
|
|
entry.value = value
|
|
entry.size = size
|
|
entry.timestamp = time.Now()
|
|
s.evictList.MoveToFront(entry.element)
|
|
s.currentSize += delta
|
|
atomic.AddInt64(&c.totalSize, delta)
|
|
s.mu.Unlock()
|
|
c.evictIfNeeded()
|
|
return
|
|
}
|
|
|
|
entry := &LRUCacheEntry{
|
|
key: key,
|
|
value: value,
|
|
size: size,
|
|
timestamp: time.Now(),
|
|
}
|
|
entry.element = s.evictList.PushFront(entry)
|
|
s.entries[key] = entry
|
|
s.currentSize += size
|
|
s.count++
|
|
atomic.AddInt64(&c.totalSize, size)
|
|
atomic.AddInt64(&c.totalCount, 1)
|
|
s.mu.Unlock()
|
|
|
|
c.evictIfNeeded()
|
|
}
|
|
|
|
// evictIfNeeded enforces the global maxEntries / maxSize limits by evicting
|
|
// the globally least-recently-used entry across all shards until under limits.
|
|
// Selecting the victim shard requires inspecting each shard's tail timestamp,
|
|
// which is O(shardCount) per eviction — acceptable because shardCount is a
|
|
// small constant.
|
|
func (c *LRUCache) evictIfNeeded() {
|
|
if c.maxEntries == 0 || c.maxSize == 0 {
|
|
c.purgeAll()
|
|
return
|
|
}
|
|
|
|
// Fast path: lock-free check before acquiring evictMu. Avoids serialising
|
|
// every Set when limits are not exceeded.
|
|
if atomic.LoadInt64(&c.totalCount) <= int64(c.maxEntries) &&
|
|
atomic.LoadInt64(&c.totalSize) <= c.maxSize {
|
|
return
|
|
}
|
|
|
|
c.evictMu.Lock()
|
|
defer c.evictMu.Unlock()
|
|
|
|
for {
|
|
count := atomic.LoadInt64(&c.totalCount)
|
|
size := atomic.LoadInt64(&c.totalSize)
|
|
if count <= int64(c.maxEntries) && size <= c.maxSize {
|
|
return
|
|
}
|
|
if !c.evictGloballyOldest() {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// evictGloballyOldest removes the single entry with the oldest timestamp
|
|
// across all shards. Returns false if no entry could be evicted.
|
|
func (c *LRUCache) evictGloballyOldest() bool {
|
|
var (
|
|
victimShard *lruCacheShard
|
|
victimTS time.Time
|
|
first = true
|
|
)
|
|
|
|
// Snapshot tail timestamps under each shard lock. Briefly hold each lock.
|
|
for _, s := range c.shards {
|
|
s.mu.Lock()
|
|
back := s.evictList.Back()
|
|
if back != nil {
|
|
ts := back.Value.(*LRUCacheEntry).timestamp
|
|
if first || ts.Before(victimTS) {
|
|
victimTS = ts
|
|
victimShard = s
|
|
first = false
|
|
}
|
|
}
|
|
s.mu.Unlock()
|
|
}
|
|
|
|
if victimShard == nil {
|
|
return false
|
|
}
|
|
|
|
victimShard.mu.Lock()
|
|
defer victimShard.mu.Unlock()
|
|
back := victimShard.evictList.Back()
|
|
if back == nil {
|
|
return false
|
|
}
|
|
entry := back.Value.(*LRUCacheEntry)
|
|
c.removeFromShard(victimShard, entry)
|
|
return true
|
|
}
|
|
|
|
// removeFromShard removes an entry from its shard. Caller must hold shard lock.
|
|
func (c *LRUCache) removeFromShard(s *lruCacheShard, entry *LRUCacheEntry) {
|
|
s.evictList.Remove(entry.element)
|
|
delete(s.entries, entry.key)
|
|
s.currentSize -= entry.size
|
|
s.count--
|
|
atomic.AddInt64(&c.totalSize, -entry.size)
|
|
atomic.AddInt64(&c.totalCount, -1)
|
|
}
|
|
|
|
// purgeAll empties every shard. Used when limits are zero.
|
|
func (c *LRUCache) purgeAll() {
|
|
for _, s := range c.shards {
|
|
s.mu.Lock()
|
|
freedSize := s.currentSize
|
|
freedCount := s.count
|
|
s.entries = make(map[string]*LRUCacheEntry)
|
|
s.evictList = list.New()
|
|
s.currentSize = 0
|
|
s.count = 0
|
|
s.mu.Unlock()
|
|
atomic.AddInt64(&c.totalSize, -freedSize)
|
|
atomic.AddInt64(&c.totalCount, -freedCount)
|
|
}
|
|
}
|
|
|
|
// Delete removes a key from the cache.
|
|
func (c *LRUCache) Delete(key string) {
|
|
s := c.shardFor(key)
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
entry, exists := s.entries[key]
|
|
if !exists {
|
|
return
|
|
}
|
|
c.removeFromShard(s, entry)
|
|
}
|
|
|
|
// Clear removes all entries from the cache.
|
|
func (c *LRUCache) Clear() {
|
|
for _, s := range c.shards {
|
|
s.mu.Lock()
|
|
freedSize := s.currentSize
|
|
freedCount := s.count
|
|
s.entries = make(map[string]*LRUCacheEntry)
|
|
s.evictList = list.New()
|
|
s.currentSize = 0
|
|
s.count = 0
|
|
s.mu.Unlock()
|
|
atomic.AddInt64(&c.totalSize, -freedSize)
|
|
atomic.AddInt64(&c.totalCount, -freedCount)
|
|
}
|
|
}
|
|
|
|
// Len returns the number of entries in the cache.
|
|
func (c *LRUCache) Len() int {
|
|
return int(atomic.LoadInt64(&c.totalCount))
|
|
}
|
|
|
|
// Size returns the current size of the cache in bytes.
|
|
func (c *LRUCache) Size() int64 {
|
|
return atomic.LoadInt64(&c.totalSize)
|
|
}
|
|
|
|
// CleanupExpired removes entries older than the given duration across all
|
|
// shards. Returns the total number of entries removed.
|
|
func (c *LRUCache) CleanupExpired(maxAge time.Duration) int {
|
|
now := time.Now()
|
|
removed := 0
|
|
for _, s := range c.shards {
|
|
s.mu.Lock()
|
|
for element := s.evictList.Back(); element != nil; {
|
|
entry := element.Value.(*LRUCacheEntry)
|
|
if now.Sub(entry.timestamp) <= maxAge {
|
|
break
|
|
}
|
|
next := element.Prev()
|
|
c.removeFromShard(s, entry)
|
|
removed++
|
|
element = next
|
|
}
|
|
s.mu.Unlock()
|
|
}
|
|
return removed
|
|
}
|
|
|
|
// GetStats returns cache statistics.
|
|
func (c *LRUCache) GetStats() map[string]any {
|
|
size := atomic.LoadInt64(&c.totalSize)
|
|
count := atomic.LoadInt64(&c.totalCount)
|
|
var fillPercent float64
|
|
if c.maxSize > 0 {
|
|
fillPercent = float64(size) / float64(c.maxSize) * 100
|
|
}
|
|
return map[string]any{
|
|
"entries": int(count),
|
|
"size_bytes": size,
|
|
"max_entries": c.maxEntries,
|
|
"max_size": c.maxSize,
|
|
"fill_percent": fillPercent,
|
|
}
|
|
}
|