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.