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.
Clear reset the counters with StoreInt64(0) after deleting entries, so
a Set racing the reset left a live entry while counters read zero, and
the next Delete drove them permanently negative.
Rewrite Clear to LoadAndDelete each key and subtract the linearized
returned entry, matching the rest of the file. Size the key slice from
the live entry count.
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.
cache.Shutdown only called Close() on an io.Closer, which only the Redis
backend (CacheWrapper) satisfied -- the in-memory Cache's Shutdown
(cancels ctx, stopping its cleanupRoutine background ticker) was never
reached, so that goroutine leaked until process exit (and with the
default set-up, and whenever Redis is unreachable and EnableCache falls
back to memory).
Add Close() error to Cache (calls Shutdown, idempotent, leaves the
cache usable) so cache.Shutdown handles both backends symmetrically.
Update Shutdown doc; add test TestMemoryCacheClose_IdempotentAndStillUsable.
- graphql.go: parseGraphQLQuery now indexes top-level FragmentDefinition nodes
and checkSelections resolves FragmentSpread by name, closing an
introspection-blocking bypass (`{ ...F }` where F spreads __schema was
allowed because the spread carried no selection set and definitions loop
skipped fragments).
- proxy.go/server.go: handleCaching stores the computed cache hash on the
request context; performProxyRequest reuses it instead of recomputing a
full MD5 of the request body per proxied request (fallback preserved for
direct callers).
- cache/memory: Set serializes the exists-check + counter update + store
with the embedded mutex so concurrent Sets of the same new key can't
double-increment entryCount/memoryUsage; regression test added.
Verification: go build, go vet, gofmt clean; full go test ./... and
go test -race ./... green (9 packages); golangci-lint 0 issues; trufflehog clean.
1) connection_pool.go: GetConnectionStats read lastRecoveryAttempt (a
multi-word time.Time) without any lock while checkAndRecover wrote it
under recoveryMutex -> data race on live admin API endpoints. Snapshot it
under recoveryMutex (the writer's lock). Regression test
TestGetConnectionStatsRace drives the read path concurrent with the locked
write under -race.
2) cache/memory memory.go: Cache.Get expired path used an unconditional
Delete + atomic decrement, so concurrent Gets on the same expired entry
each decremented entryCount/memoryUsage -> permanent negative drift. Use
LoadAndDelete-with-guard (as evictOldest/evictToFreeMemory already do) so
only the goroutine that actually removes the entry decrements. Regression
test TestMemoryCacheConcurrentGetOnExpiredEntry_CountersConsistent.