mirror of
https://github.com/lukaszraczylo/graphql-monitoring-proxy.git
synced 2026-10-14 05:48:25 +00:00
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.
203 lines
6.6 KiB
Go
203 lines
6.6 KiB
Go
// Package libpack_cache_redis provides a Redis-backed cache implementation
|
|
// for distributed caching across multiple proxy instances. Supports key
|
|
// prefixing for multi-tenant isolation.
|
|
package libpack_cache_redis
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
redis "github.com/redis/go-redis/v9"
|
|
)
|
|
|
|
// globEscaper escapes Redis glob-pattern metacharacters (\, *, ?, [, ]) so a
|
|
// literal string can be embedded in a SCAN/KEYS MATCH pattern without being
|
|
// interpreted as a wildcard. Each character is matched once per input byte,
|
|
// so the replacements cannot cascade into one another.
|
|
var globEscaper = strings.NewReplacer(
|
|
`\`, `\\`,
|
|
`*`, `\*`,
|
|
`?`, `\?`,
|
|
`[`, `\[`,
|
|
`]`, `\]`,
|
|
)
|
|
|
|
// escapeGlobPattern returns s with Redis glob metacharacters escaped so it
|
|
// can be used as a literal prefix inside a MATCH pattern.
|
|
func escapeGlobPattern(s string) string {
|
|
return globEscaper.Replace(s)
|
|
}
|
|
|
|
type RedisConfig struct {
|
|
ctx context.Context
|
|
client *redis.Client
|
|
builderPool *sync.Pool
|
|
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)
|
|
builder.Reset()
|
|
builder.WriteString(c.prefix)
|
|
builder.WriteString(key)
|
|
return builder.String()
|
|
}
|
|
|
|
type RedisClientConfig struct {
|
|
RedisServer string
|
|
RedisPassword string
|
|
Prefix string
|
|
RedisDB int
|
|
}
|
|
|
|
// defaultTTL is the sane fallback applied to a non-positive per-entry TTL
|
|
// passed to Set.
|
|
//
|
|
// go-redis treats a non-positive expiration as "send SET with no EX/PX
|
|
// option", which makes the key permanent (see redis/go-redis/v9
|
|
// string_commands.go: `if expiration > 0 { ... }`, no else branch adds an
|
|
// expiry). That would silently diverge from the in-memory backends: the
|
|
// standard memory cache and the LRU cache both clamp a non-positive TTL to
|
|
// their own equivalent defaultTTL constant (cache/memory/memory.go and
|
|
// cache/memory/lru_memory_cache.go) so the entry still expires. Clamping here
|
|
// too keeps all three backends' behavior identical: a misconfigured
|
|
// (non-positive) TTL always results in a bounded expiry, never a permanent
|
|
// entry.
|
|
const defaultTTL = 60 * time.Second
|
|
|
|
func New(redisClientConfig *RedisClientConfig) (*RedisConfig, error) {
|
|
c := &RedisConfig{
|
|
client: redis.NewClient(&redis.Options{
|
|
Addr: redisClientConfig.RedisServer,
|
|
Password: redisClientConfig.RedisPassword,
|
|
DB: redisClientConfig.RedisDB,
|
|
}),
|
|
ctx: context.Background(),
|
|
prefix: redisClientConfig.Prefix,
|
|
builderPool: &sync.Pool{
|
|
New: func() any {
|
|
return &strings.Builder{}
|
|
},
|
|
},
|
|
}
|
|
|
|
_, err := c.client.Ping(c.ctx).Result()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return c, nil
|
|
}
|
|
|
|
func (c *RedisConfig) Set(key string, value []byte, ttl time.Duration) error {
|
|
if ttl <= 0 {
|
|
ttl = defaultTTL
|
|
}
|
|
return c.client.Set(c.ctx, c.prependKeyName(key), value, ttl).Err()
|
|
}
|
|
|
|
func (c *RedisConfig) Get(key string) ([]byte, bool, error) {
|
|
val, err := c.client.Get(c.ctx, c.prependKeyName(key)).Result()
|
|
if err == redis.Nil {
|
|
return nil, false, nil
|
|
}
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
return []byte(val), true, nil
|
|
}
|
|
|
|
func (c *RedisConfig) Delete(key string) error {
|
|
return c.client.Del(c.ctx, c.prependKeyName(key)).Err()
|
|
}
|
|
|
|
// Clear removes only the keys owned by this cache (those under the configured
|
|
// prefix). Unlike FlushDB it does not wipe unrelated keys that may share the
|
|
// selected Redis DB when the database is not exclusively owned by this proxy.
|
|
// The prefix is glob-escaped before use so a prefix containing Redis glob
|
|
// metacharacters (*, ?, [, ], \) is matched literally instead of being
|
|
// interpreted as a wildcard. A failure to delete one scanned batch does not
|
|
// abort the clear: remaining batches are still attempted, and any errors
|
|
// encountered (scan or delete) are joined and returned to the caller so a
|
|
// partial clear is never silently reported as a success.
|
|
func (c *RedisConfig) Clear() error {
|
|
pattern := escapeGlobPattern(c.prefix) + "*"
|
|
var cursor uint64
|
|
var errs []error
|
|
for {
|
|
keys, next, err := c.client.Scan(c.ctx, cursor, pattern, 100).Result()
|
|
if err != nil {
|
|
errs = append(errs, fmt.Errorf("redis: scan at cursor %d: %w", cursor, err))
|
|
break
|
|
}
|
|
if len(keys) > 0 {
|
|
if err := c.client.Del(c.ctx, keys...).Err(); err != nil {
|
|
errs = append(errs, fmt.Errorf("redis: delete batch at cursor %d: %w", cursor, err))
|
|
}
|
|
}
|
|
cursor = next
|
|
if cursor == 0 {
|
|
break
|
|
}
|
|
}
|
|
return errors.Join(errs...)
|
|
}
|
|
|
|
// CountQueries returns the number of keys owned by this cache (those under
|
|
// the configured prefix). The prefix is glob-escaped the same way Clear
|
|
// escapes it, so a prefix containing Redis glob metacharacters is matched
|
|
// literally instead of being interpreted as a wildcard, which would
|
|
// otherwise undercount or overcount keys in admin-dashboard stats.
|
|
func (c *RedisConfig) CountQueries() (int64, error) {
|
|
pattern := escapeGlobPattern(c.prefix) + "*"
|
|
keys, err := c.client.Keys(c.ctx, pattern).Result()
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return int64(len(keys)), nil
|
|
}
|
|
|
|
// CountQueriesWithPattern returns the number of keys owned by this cache
|
|
// whose suffix (after the prefix) matches pattern. Only the prefix is
|
|
// glob-escaped, the same way Clear and CountQueries escape it; pattern
|
|
// itself is left as given so a caller can still use glob metacharacters
|
|
// intentionally in the suffix it supplies.
|
|
func (c *RedisConfig) CountQueriesWithPattern(pattern string) (int, error) {
|
|
fullPattern := escapeGlobPattern(c.prefix) + pattern
|
|
keys, err := c.client.Keys(c.ctx, fullPattern).Result()
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return len(keys), nil
|
|
}
|
|
|
|
// GetMemoryUsage returns an approximation of memory usage for Redis.
|
|
// Actual memory is managed by the Redis server, so the proxy reports 0 here.
|
|
// Avoid an Info() round-trip: its result was always discarded, and it could
|
|
// stall the dashboard stats endpoint when Redis is slow or unreachable.
|
|
func (c *RedisConfig) GetMemoryUsage() int64 {
|
|
return 0
|
|
}
|
|
|
|
// GetMaxMemorySize returns the configured max memory for Redis
|
|
// In Redis, this would be the 'maxmemory' configuration value
|
|
func (c *RedisConfig) GetMaxMemorySize() int64 {
|
|
// Return a default value as Redis manages its own memory limits
|
|
// In a production environment, you could get this from Redis config
|
|
return 0
|
|
}
|