From ad7596ff85b98563fd8e193b5da9320d32ae4ec0 Mon Sep 17 00:00:00 2001 From: Sam Toxopeus Date: Mon, 22 Jun 2026 10:17:59 +0200 Subject: [PATCH] feat: Allow cache reading from replicas --- README.md | 11 ++++- bouncer.go | 1 + pkg/cache/cache.go | 76 ++++++++++++++++++++---------- pkg/configuration/configuration.go | 2 + 4 files changed, 62 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 4098944..2ab0e7a 100644 --- a/README.md +++ b/README.md @@ -444,7 +444,11 @@ make run - RedisCacheHost - string - default: "redis:6379" - - hostname and port for the Redis service + - hostname and port for the Redis write host (primary) +- RedisCacheReadHosts + - []string + - default: [] + - List of Redis replica hostnames (host:port) to use for read operations. Reads are distributed round-robin across replicas. Falls back to RedisCacheHost when empty. - RedisCachePassword - string - default: "" @@ -640,7 +644,10 @@ http: forwardedHeadersCustomName: X-Custom-Header remediationHeadersCustomName: cs-remediation redisCacheEnabled: false - redisCacheHost: "redis:6379" + redisCacheHost: "redis-primary:6379" + redisCacheReadHosts: + - "redis-replica-1:6379" + - "redis-replica-2:6379" redisCachePassword: password redisCacheDatabase: "5" redisCacheUnreachableBlock: true diff --git a/bouncer.go b/bouncer.go index a98cf18..2bd4212 100644 --- a/bouncer.go +++ b/bouncer.go @@ -268,6 +268,7 @@ func New(_ context.Context, next http.Handler, config *configuration.Config, nam log, config.RedisCacheEnabled, config.RedisCacheHost, + config.RedisCacheReadHosts, config.RedisCachePassword, config.RedisCacheDatabase, ) diff --git a/pkg/cache/cache.go b/pkg/cache/cache.go index e966638..87674c6 100644 --- a/pkg/cache/cache.go +++ b/pkg/cache/cache.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "log/slog" + "sync/atomic" ttl_map "github.com/leprosus/golang-ttl-map" simpleredis "github.com/maxlerebourg/simpleredis" @@ -27,10 +28,7 @@ const ( ) //nolint:gochecknoglobals -var ( - redis simpleredis.SimpleRedis - cache = ttl_map.New() -) +var cache = ttl_map.New() type localCache struct{} @@ -52,33 +50,53 @@ func (localCache) delete(key string) { } type redisCache struct { - log *slog.Logger + log *slog.Logger + writer simpleredis.SimpleRedis + readers []simpleredis.SimpleRedis + counter atomic.Uint64 } -func (redisCache) get(key string) (string, error) { - value, err := redis.Get(key) - valueString := string(value) - if err == nil && len(valueString) > 0 { - return valueString, nil +func (rc *redisCache) nextReader() *simpleredis.SimpleRedis { + n := len(rc.readers) + if n == 0 { + return &rc.writer } - errRedisMessage := err.Error() - if errRedisMessage == simpleredis.RedisMiss { - return "", errors.New(CacheMiss) - } - if errRedisMessage == simpleredis.RedisUnreachable { - return "", errors.New(CacheUnreachable) - } - return "", err + idx := rc.counter.Add(1) % uint64(n) + return &rc.readers[idx] } -func (rc redisCache) set(key, value string, duration int64) { - if err := redis.Set(key, []byte(value), duration); err != nil { +func redisResultToString(value []byte, err error) (string, error) { + if err == nil { + valueString := string(value) + if len(valueString) > 0 { + return valueString, nil + } + } + if err != nil { + switch err.Error() { + case simpleredis.RedisMiss: + return "", errors.New(CacheMiss) + case simpleredis.RedisUnreachable: + return "", errors.New(CacheUnreachable) + } + return "", err + } + return "", errors.New(CacheMiss) +} + +func (rc *redisCache) get(key string) (string, error) { + value, err := rc.nextReader().Get(key) + return redisResultToString(value, err) +} + +func (rc *redisCache) set(key, value string, duration int64) { + if err := rc.writer.Set(key, []byte(value), duration); err != nil { rc.log.Error("cache:setDecisionRedisCache" + err.Error()) } } -func (rc redisCache) delete(key string) { - if err := redis.Del(key); err != nil { +func (rc *redisCache) delete(key string) { + if err := rc.writer.Del(key); err != nil { rc.log.Error("cache:deleteDecisionRedisCache " + err.Error()) } } @@ -96,15 +114,21 @@ type Client struct { } // New Initialize cache client. -func (c *Client) New(log *slog.Logger, isRedis bool, host, pass, database string) { +func (c *Client) New(log *slog.Logger, isRedis bool, writeHost string, readHosts []string, pass, database string) { c.log = log if isRedis { - redis.Init(host, pass, database) - c.cache = &redisCache{log: log} + rc := &redisCache{log: log} + rc.writer.Init(writeHost, pass, database) + for _, h := range readHosts { + var r simpleredis.SimpleRedis + r.Init(h, pass, database) + rc.readers = append(rc.readers, r) + } + c.cache = rc } else { c.cache = &localCache{} } - c.log.Debug(fmt.Sprintf("cache:New initialized isRedis:%v", isRedis)) + c.log.Debug(fmt.Sprintf("cache:New initialized isRedis:%v writeHost:%v readHosts:%v", isRedis, writeHost, readHosts)) } // Delete delete decision in cache. diff --git a/pkg/configuration/configuration.go b/pkg/configuration/configuration.go index fda49d7..6a7a2e5 100644 --- a/pkg/configuration/configuration.go +++ b/pkg/configuration/configuration.go @@ -96,6 +96,7 @@ type Config struct { ClientTrustedIPs []string `json:"clientTrustedIps,omitempty"` RedisCacheEnabled bool `json:"redisCacheEnabled,omitempty"` RedisCacheHost string `json:"redisCacheHost,omitempty"` + RedisCacheReadHosts []string `json:"redisCacheReadHosts,omitempty"` RedisCachePassword string `json:"redisCachePassword,omitempty"` RedisCachePasswordFile string `json:"redisCachePasswordFile,omitempty"` RedisCacheDatabase string `json:"redisCacheDatabase,omitempty"` @@ -172,6 +173,7 @@ func New() *Config { ClientTrustedIPs: []string{}, RedisCacheEnabled: false, RedisCacheHost: "redis:6379", + RedisCacheReadHosts: []string{}, RedisCachePassword: "", RedisCacheDatabase: "", RedisCacheUnreachableBlock: true,