From 461d5ebf9e3436632313ec2b1866ab2c75293a43 Mon Sep 17 00:00:00 2001 From: Juan Orozco Date: Fri, 24 Jul 2026 09:28:56 -0700 Subject: [PATCH] feat: add opt-in Redis cluster mode (REDIS_CLUSTER) for the data store The redigo-based Redis data store is not cluster-aware and its multi-key init fails on a cluster (CROSSSLOT), so managed cluster stores like AWS MemoryDB and ElastiCache (cluster mode enabled) couldn't be used. Add a REDIS_CLUSTER option that routes the feature data store to the cluster-aware go-redis integration with ForceClusterMode: it seeds a cluster client from the single configuration endpoint and applies the {ld}. hash-tag prefix. Default off; the redigo path and all existing single-node behavior are unchanged. Scope: feature data store only. The Big Segments store stays on redigo and is not cluster-aware; config validation warns when REDIS_CLUSTER is set. Docs and a config-parsing test added. NOTE: uses a local replace directive to a fork of go-server-sdk-redis-go-redis that adds ForceClusterMode; remove once released upstream. Run `go mod tidy`. Co-Authored-By: Claude Opus 4.8 (1M context) --- config/config.go | 1 + config/config_validation.go | 5 ++ config/test_data_configs_valid_test.go | 27 +++++++++++ docs/configuration.md | 8 ++++ docs/persistent-storage.md | 2 +- go.mod | 7 +++ go.sum | 8 ++++ internal/sdks/data_stores.go | 63 ++++++++++++++++++++++++++ 8 files changed, 120 insertions(+), 1 deletion(-) diff --git a/config/config.go b/config/config.go index 5fe62830..8b4b73c0 100644 --- a/config/config.go +++ b/config/config.go @@ -237,6 +237,7 @@ type RedisConfig struct { TLS bool `conf:"REDIS_TLS"` Username string `conf:"REDIS_USERNAME"` Password string `conf:"REDIS_PASSWORD"` + Cluster bool `conf:"REDIS_CLUSTER"` } // ConsulConfig configures the optional Consul integration. diff --git a/config/config_validation.go b/config/config_validation.go index 6fd17a56..b33461cd 100644 --- a/config/config_validation.go +++ b/config/config_validation.go @@ -285,6 +285,11 @@ func validateConfigDatabases(result *ct.ValidationResult, c *Config, loggers ldl return // no point doing further database config validation if it's in this state } + if c.Redis.URL.IsDefined() && c.Redis.Cluster { + loggers.Warn("Redis cluster mode (REDIS_CLUSTER) applies only to the feature data store; " + + "the Redis Big Segments store is not cluster-aware and should not be relied upon in this configuration") + } + if c.Consul.Host != "" { if c.Consul.Token != "" && c.Consul.TokenFile != "" { result.AddError(nil, errConsulTokenAndTokenFile) diff --git a/config/test_data_configs_valid_test.go b/config/test_data_configs_valid_test.go index 9822295d..59340d5c 100644 --- a/config/test_data_configs_valid_test.go +++ b/config/test_data_configs_valid_test.go @@ -82,6 +82,7 @@ func makeValidConfigs() []testDataValidConfig { makeValidConfigOfflineModeWithMonitoringInterval("5m"), makeValidConfigRedisMinimal(), makeValidConfigRedisAll(), + makeValidConfigRedisCluster(), makeValidConfigRedisURL(), makeValidConfigRedisPortOnly(), makeValidConfigRedisDockerPort(), @@ -472,6 +473,32 @@ LocalTTL = 3s return c } +func makeValidConfigRedisCluster() testDataValidConfig { + c := testDataValidConfig{name: "Redis - cluster mode"} + c.makeConfig = func(c *Config) { + c.Redis = RedisConfig{ + URL: newOptURLAbsoluteMustBeValid("redis://redishost:6400"), + TLS: true, + Cluster: true, + } + } + c.envVars = map[string]string{ + "USE_REDIS": "1", + "REDIS_HOST": "redishost", + "REDIS_PORT": "6400", + "REDIS_TLS": "1", + "REDIS_CLUSTER": "1", + } + c.fileContent = ` +[Redis] +Host = "redishost" +Port = 6400 +TLS = 1 +Cluster = 1 +` + return c +} + func makeValidConfigRedisURL() testDataValidConfig { c := testDataValidConfig{name: "Redis - URL instead of host/port"} c.makeConfig = func(c *Config) { diff --git a/docs/configuration.md b/docs/configuration.md index 1f76b3ee..24b09f41 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -204,6 +204,7 @@ To learn more, read [Persistent storage](./persistent-storage.md). | `tls` | `REDIS_TLS` | Boolean | `false` | If `true`, will use a secure connection to Redis (not all Redis servers support this). If you specified a `redis://` URL, setting `tls` to `true` will change it to `rediss://`. | | `password` | `REDIS_PASSWORD` | String | | Optional password if Redis requires authentication. | | `username` | `REDIS_USERNAME` | String | | Optional username if Redis requires authentication. | +| `cluster` | `REDIS_CLUSTER` | Boolean | `false` | If `true`, connect in Redis cluster mode. Use this with a managed cluster reached through a single configuration endpoint, such as AWS MemoryDB or ElastiCache (cluster mode enabled). See below. | | `localTtl` | `CACHE_TTL` | Duration | `30s` | Length of time that database items can be cached in memory. | Note that the TLS and password options can also be specified as part of the URL: `rediss://` instead of `redis://` @@ -213,6 +214,13 @@ username and password. You may want to use the separate options instead if, for instance, you want your configuration file to contain the basic Redis configuration, but for security reasons you would rather set the password in an environment variable (`REDIS_PASSWORD`). +Setting `cluster` (`REDIS_CLUSTER`) to `true` connects using a cluster-aware client, seeded from the single +configured `host`/`url` (the cluster's configuration endpoint), and stores all keys under a `{ld}.` hash-tag prefix so +that the multi-key operations the data store relies on stay within one hash slot. This targets managed cluster stores such +as AWS MemoryDB and ElastiCache with cluster mode enabled. Note that this makes the Relay Proxy _compatible with_ cluster +Redis; it does not shard the Relay Proxy's data across nodes (its data set is small). Cluster mode currently applies to the +feature data store only — the Big Segments store is not cluster-aware. + ### File section: `[DynamoDB]` diff --git a/docs/persistent-storage.md b/docs/persistent-storage.md index 6a0ce1a3..e1dab8b5 100644 --- a/docs/persistent-storage.md +++ b/docs/persistent-storage.md @@ -6,7 +6,7 @@ You can configure Relay Proxy nodes to persist feature flag settings in Redis, D To learn more, read [Using a persistent feature store](https://docs.launchdarkly.com/sdk/concepts/data-stores), and the Relay Proxy documentation on [Configuration](./configuration.md). -The Relay Proxy does not support clustered Redis or Redis Sentinel. +The Relay Proxy supports clustered Redis for the feature data store by setting `cluster` / `REDIS_CLUSTER` to `true` (see [Configuration](./configuration.md)); this works with managed cluster stores such as AWS MemoryDB and ElastiCache (cluster mode enabled). Cluster mode currently covers the feature data store only, not the Big Segments store. Redis Sentinel is not supported. **Note:** The Redis configurations should also work with [Valkey](https://valkey.io/), as Valkey maintains Redis compatibility. diff --git a/go.mod b/go.mod index 8cf054e4..3d590400 100644 --- a/go.mod +++ b/go.mod @@ -31,12 +31,14 @@ require ( github.com/launchdarkly/go-server-sdk-consul/v3 v3.0.2 github.com/launchdarkly/go-server-sdk-dynamodb/v4 v4.0.3 github.com/launchdarkly/go-server-sdk-evaluation/v3 v3.0.1 + github.com/launchdarkly/go-server-sdk-redis-go-redis v1.1.2 github.com/launchdarkly/go-server-sdk-redis-redigo/v3 v3.0.4 github.com/launchdarkly/go-server-sdk/v7 v7.15.3 github.com/launchdarkly/go-test-helpers/v3 v3.1.0 github.com/launchdarkly/opencensus-go-exporter-stackdriver v0.14.7 github.com/pborman/uuid v1.2.1 github.com/prometheus/client_golang v1.23.2 // indirect; override to address CVE-2022-21698 + github.com/redis/go-redis/v9 v9.1.0 github.com/stretchr/testify v1.11.1 go.opencensus.io v0.24.0 golang.org/x/sync v0.21.0 @@ -145,3 +147,8 @@ require ( ) retract v8.19.4 // Introduced unintentional breaking changes; use version v8.19.5 or later. + +// TEMPORARY (fork build — do not merge upstream): build against the actblue fork of the go-redis +// integration that adds ForceClusterMode (branch feat/force-cluster-mode). Remove once that change +// is released upstream and pin the released version above. +replace github.com/launchdarkly/go-server-sdk-redis-go-redis => github.com/actblue/go-server-sdk-redis-go-redis v0.0.0-20260724162236-4deaf9f9f8b2 diff --git a/go.sum b/go.sum index 476a5716..616e9349 100644 --- a/go.sum +++ b/go.sum @@ -53,6 +53,8 @@ github.com/DataDog/opencensus-go-exporter-datadog v0.0.0-20220622145613-731d59e8 github.com/DataDog/opencensus-go-exporter-datadog v0.0.0-20220622145613-731d59e8b567/go.mod h1:/VV3EFO/hTNQZHAqaj+CPGy2+ioFrP4EX3iRwozubhQ= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/actblue/go-server-sdk-redis-go-redis v0.0.0-20260724162236-4deaf9f9f8b2 h1:lobdskQ/ftiI1F9PzBwCwDTZDpYbVNQeeIpSyUZKGbo= +github.com/actblue/go-server-sdk-redis-go-redis v0.0.0-20260724162236-4deaf9f9f8b2/go.mod h1:MCHcQhcFu7VS4J4tP6Z3qP8JvP9j4f59r2oQx9WFONI= github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= @@ -104,6 +106,10 @@ github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+Ce github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= +github.com/bsm/ginkgo/v2 v2.9.5 h1:rtVBYPs3+TC5iLUVOis1B9tjLTup7Cj5IfzosKtvTJ0= +github.com/bsm/ginkgo/v2 v2.9.5/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.26.0 h1:LhQm+AFcgV2M0WyKroMASzAzCAJVpAxQXv4SaI9a69Y= +github.com/bsm/gomega v1.26.0/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/census-instrumentation/opencensus-proto v0.4.1 h1:iKLQ0xPNFxR/2hzXZMrBo8f1j86j5WHzznCCQxV/b8g= github.com/census-instrumentation/opencensus-proto v0.4.1/go.mod h1:4T9NM4+4Vw91VeyqjLS6ao50K5bOcLKN6Q42XnYaRYw= @@ -468,6 +474,8 @@ github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlT github.com/prometheus/statsd_exporter v0.22.7/go.mod h1:N/TevpjkIh9ccs6nuzY3jQn9dFqnUakOjnEuMPJJJnI= github.com/prometheus/statsd_exporter v0.23.1 h1:TiNAE1XevlZZrpSbmf51l/Ryl2Eek9rYh//KlvcNvKw= github.com/prometheus/statsd_exporter v0.23.1/go.mod h1:FFmnBRWf+HxX+PR+2fnc0ciBIONVAPJ6k4lqIbdqVxo= +github.com/redis/go-redis/v9 v9.1.0 h1:137FnGdk+EQdCbye1FW+qOEcY5S+SpY9T0NiuqvtfMY= +github.com/redis/go-redis/v9 v9.1.0/go.mod h1:urWj3He21Dj5k4TK1y59xH8Uj6ATueP8AH1cY3lZl4c= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= diff --git a/internal/sdks/data_stores.go b/internal/sdks/data_stores.go index 4a0b2042..81f3b520 100644 --- a/internal/sdks/data_stores.go +++ b/internal/sdks/data_stores.go @@ -2,7 +2,9 @@ package sdks import ( "context" + "crypto/tls" "errors" + "net/url" "strings" "github.com/launchdarkly/ld-relay/v8/config" @@ -11,6 +13,7 @@ import ( "github.com/launchdarkly/go-sdk-common/v3/ldlog" ldconsul "github.com/launchdarkly/go-server-sdk-consul/v3" lddynamodb "github.com/launchdarkly/go-server-sdk-dynamodb/v4" + ldredisgoredis "github.com/launchdarkly/go-server-sdk-redis-go-redis" ldredis "github.com/launchdarkly/go-server-sdk-redis-redigo/v3" "github.com/launchdarkly/go-server-sdk/v7/ldcomponents" "github.com/launchdarkly/go-server-sdk/v7/subsystems" @@ -20,6 +23,7 @@ import ( "github.com/aws/aws-sdk-go-v2/service/dynamodb" redigo "github.com/gomodule/redigo/redis" consul "github.com/hashicorp/consul/api" + goredis "github.com/redis/go-redis/v9" ) var ( @@ -57,6 +61,33 @@ func ConfigureDataStore( if allConfig.Redis.URL.IsDefined() { // Our config validation already takes care of normalizing the Redis parameters so that if a // host & port were specified, they are transformed into a URL. + + if allConfig.Redis.Cluster { + // Cluster mode uses the go-redis integration (the redigo integration is not + // cluster-aware). This supports managed cluster stores such as AWS MemoryDB or + // ElastiCache (cluster mode enabled), which are reached through a single configuration + // endpoint. + clusterBuilder, redisURL, err := makeRedisClusterDataStoreBuilder(allConfig, envConfig) + if err != nil { + return nil, DataStoreEnvironmentInfo{}, err + } + redactedURL := util.RedactURL(redisURL) + + loggers.Infof("Using Redis data store (cluster mode): %s with prefix: %s", redactedURL, envConfig.Prefix) + + storeInfo := DataStoreEnvironmentInfo{ + DBType: "redis", + DBServer: redactedURL, + DBPrefix: envConfig.Prefix, + } + if storeInfo.DBPrefix == "" { + storeInfo.DBPrefix = ldredisgoredis.DefaultPrefix + } + + return ldcomponents.PersistentDataStore(clusterBuilder). + CacheTime(allConfig.Redis.LocalTTL.GetOrElse(config.DefaultDatabaseCacheTTL)), storeInfo, nil + } + redisBuilder, redisURL := makeRedisDataStoreBuilder(ldredis.DataStore, allConfig, envConfig) redactedURL := util.RedactURL(redisURL) @@ -169,6 +200,38 @@ func makeRedisDataStoreBuilder[T any]( return b, redisURL } +// makeRedisClusterDataStoreBuilder builds a go-redis-based data store configured for Redis cluster +// mode. Unlike the redigo integration used by makeRedisDataStoreBuilder, the go-redis integration is +// cluster-aware; ForceClusterMode lets a single configuration endpoint (e.g. AWS MemoryDB) be used +// as a cluster seed, and applies the {ld}. hash-tag prefix so the store's multi-key operations stay +// within one hash slot. +func makeRedisClusterDataStoreBuilder( + allConfig config.Config, + envConfig config.EnvConfig, +) (builder *ldredisgoredis.DataStoreBuilder, redisURL string, err error) { + redisURL, prefix := GetRedisBasicProperties(allConfig.Redis, envConfig) + + parsed, err := url.Parse(redisURL) + if err != nil { + return nil, redisURL, err + } + + opts := goredis.UniversalOptions{ + Addrs: []string{parsed.Host}, + Username: allConfig.Redis.Username, + Password: allConfig.Redis.Password, + } + if allConfig.Redis.TLS { + opts.TLSConfig = &tls.Config{MinVersion: tls.VersionTLS12} + } + + builder = ldredisgoredis.DataStore(). + Options(opts). + Prefix(prefix). + ForceClusterMode(true) + return builder, redisURL, nil +} + // GetDynamoDBBasicProperties transforms the configuration properties to the standard parameters // used for DynamoDB. This function is exported to ensure consistency between the SDK // configuration and the internal big segment store for DynamoDB.