Skip to content

Commit 47e23fe

Browse files
committed
feat: support redis cluster mode
1 parent 1759ce7 commit 47e23fe

15 files changed

Lines changed: 718 additions & 21 deletions

cmd/main.go

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -145,13 +145,11 @@ func main() {
145145

146146
// Shared Redis client used by both on-call and the agent worker. We open
147147
// it once here so both subsystems share connections.
148-
var sharedRedis *redis.Client
148+
var sharedRedis redis.UniversalClient
149149

150150
if cfg.OnCall.Enable || cfg.OnCall.InitializedOnly {
151-
redisOptions := handlerRedisOptions(cfg.Redis)
152-
153-
// Initialize Redis client
154-
redisClient := redis.NewClient(redisOptions)
151+
// Initialize Redis client (cluster-aware when redis.cluster is set)
152+
redisClient := newRedisClient(cfg.Redis)
155153

156154
// Test Redis connection
157155
if err := redisClient.Ping(context.Background()).Err(); err != nil {
@@ -179,7 +177,7 @@ func main() {
179177
// in-memory cursors when Redis isn't reachable).
180178
rdb := sharedRedis
181179
if rdb == nil && cfg.Redis.Host != "" {
182-
rdb = redis.NewClient(handlerRedisOptions(cfg.Redis))
180+
rdb = newRedisClient(cfg.Redis)
183181
if err := rdb.Ping(context.Background()).Err(); err != nil {
184182
log.Printf("agent: Redis unavailable (%v); cursors will be in-memory only", err)
185183
rdb = nil
@@ -225,7 +223,7 @@ func main() {
225223
// startAgent constructs the worker, starts it in a goroutine, and registers
226224
// admin routes on the fiber app. It returns the catalog so the caller can
227225
// hold a reference (and so future hot-reload code has a handle to it).
228-
func startAgent(ctx context.Context, app *fiber.App, cfg c.AgentConfig, gatewaySecret string, store storage.Provider, rdb *redis.Client) (*agent.Catalog, error) {
226+
func startAgent(ctx context.Context, app *fiber.App, cfg c.AgentConfig, gatewaySecret string, store storage.Provider, rdb redis.UniversalClient) (*agent.Catalog, error) {
229227
// On the Postgres backend, install the typed signal-table
230228
// catalog store so the log catalog reads/writes the explicit
231229
// vs_patterns/vs_logs/vs_services tables (searchable, indexed) instead of
@@ -408,6 +406,24 @@ func handleQueueMessage(content *map[string]interface{}) error {
408406
return services.CreateIncident("", content, &overwrite) // teamID as empty string
409407
}
410408

409+
// newRedisClient builds the Redis client both subsystems share. It reuses
410+
// handlerRedisOptions for the addr/password/TLS settings, then returns either a
411+
// single-node client (the default) or a cluster-aware client when the operator
412+
// opts in via redis.cluster / REDIS_CLUSTER. Both concrete types satisfy
413+
// redis.UniversalClient, so callers thread the interface and single-key
414+
// Get/Set follow MOVED/ASK redirects transparently in cluster mode.
415+
func newRedisClient(rc c.RedisConfig) redis.UniversalClient {
416+
opts := handlerRedisOptions(rc)
417+
if rc.ClusterEnabled() {
418+
return redis.NewClusterClient(&redis.ClusterOptions{
419+
Addrs: []string{opts.Addr},
420+
Password: opts.Password,
421+
TLSConfig: opts.TLSConfig,
422+
})
423+
}
424+
return redis.NewClient(opts)
425+
}
426+
411427
func handlerRedisOptions(rc c.RedisConfig) *redis.Options {
412428
redisOptions := &redis.Options{
413429
Addr: rc.Host + ":" + strconv.Itoa(rc.Port),

pkg/agent/brain_log_test.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -379,3 +379,39 @@ func TestLogBrain_AutoPromoteAfter_AlreadyKnownStaysKnown(t *testing.T) {
379379
t.Fatalf("catalog verdict = %q, want known (an already-known pattern must stay known)", c.Get("p").Verdict)
380380
}
381381
}
382+
383+
// (f) The operator's real "first fetch" scenario: an Elasticsearch source's
384+
// FIRST batch lands a pattern whose sighting count is already far above the
385+
// threshold (e.g. 3304 >= 100) in a single tick. It must classify known
386+
// immediately — Promote flips the stored verdict and LogReadiness reports Ready
387+
// — not sit "still learning" forever. This pins the count clause of isLogKnown
388+
// on the first-batch path so a large first pull is never stuck learning once the
389+
// threshold is the (non-zero) default.
390+
func TestLogBrain_AutoPromoteAfter_FirstBatchAboveThresholdIsKnown(t *testing.T) {
391+
cat := config.AgentCatalogConfig{
392+
AutoPromoteAfter: 100, // the embedded default an omitted key resolves to
393+
SpikeMultiplier: 0, // isolate the count-promotion path from spike
394+
}
395+
b, c := newLogBrainForTest(t, cat)
396+
397+
v := classifyOnce(t, b, logObs("p", 3304)) // single first-fetch batch
398+
if got := c.Get("p").Count; got != 3304 {
399+
t.Fatalf("count = %d, want 3304", got)
400+
}
401+
if v.Class != core.VerdictKnownPattern {
402+
t.Fatalf("first-batch verdict = %v, want known (3304 >= 100)", v.Class)
403+
}
404+
if c.Get("p").Verdict != "known" {
405+
t.Fatalf("catalog verdict = %q, want known (Promote must fire on the first batch)", c.Get("p").Verdict)
406+
}
407+
// The read-side readiness the UI consumes must agree with the classifier:
408+
// Ready, with the threshold as the target — never the Needed=0 "no target"
409+
// state for a pattern already past the (non-zero) threshold.
410+
r := LogReadiness(c.Get("p"), cat.AutoPromoteAfter, 30*time.Second)
411+
if !r.Ready {
412+
t.Fatalf("LogReadiness.Ready = false, want true (3304 >= 100)")
413+
}
414+
if r.Needed != 100 {
415+
t.Fatalf("LogReadiness.Needed = %d, want 100 (threshold target, not the no-target sentinel)", r.Needed)
416+
}
417+
}

pkg/agent/cursors.go

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,15 @@ import (
1313
// falls back to in-memory storage when Redis is unavailable so that
1414
// development setups don't require Redis just to try training mode.
1515
type CursorStore struct {
16-
rdb *redis.Client
16+
rdb redis.UniversalClient
1717
keyPrefix string
1818

1919
mu sync.RWMutex
2020
mem map[string]time.Time
2121
}
2222

2323
// NewCursorStore returns a CursorStore. Pass `rdb=nil` for in-memory only.
24-
func NewCursorStore(rdb *redis.Client) *CursorStore {
24+
func NewCursorStore(rdb redis.UniversalClient) *CursorStore {
2525
return &CursorStore{
2626
rdb: rdb,
2727
keyPrefix: "versus:agent:cursor:",
@@ -77,14 +77,30 @@ func (s *CursorStore) Reset(ctx context.Context) error {
7777
if s.rdb == nil {
7878
return nil
7979
}
80+
// In cluster mode a plain SCAN only walks the node the call routes to,
81+
// so sweep every master shard; in single-node mode there is exactly one
82+
// backend and the single deleteCursorKeys pass covers it.
83+
if cc, ok := s.rdb.(*redis.ClusterClient); ok {
84+
return cc.ForEachMaster(ctx, func(ctx context.Context, m *redis.Client) error {
85+
return s.deleteCursorKeys(ctx, m)
86+
})
87+
}
88+
return s.deleteCursorKeys(ctx, s.rdb)
89+
}
90+
91+
// deleteCursorKeys scans one Redis backend for every cursor key under the
92+
// prefix and deletes them ONE AT A TIME. Single-key Del always routes to the
93+
// key's owning slot, so it is correct on a *redis.ClusterClient too — a
94+
// multi-key Del across different hash slots would fail with CROSSSLOT.
95+
func (s *CursorStore) deleteCursorKeys(ctx context.Context, c redis.Cmdable) error {
8096
var cursor uint64
8197
for {
82-
keys, next, err := s.rdb.Scan(ctx, cursor, s.keyPrefix+"*", 100).Result()
98+
keys, next, err := c.Scan(ctx, cursor, s.keyPrefix+"*", 100).Result()
8399
if err != nil {
84100
return err
85101
}
86-
if len(keys) > 0 {
87-
if err := s.rdb.Del(ctx, keys...).Err(); err != nil {
102+
for _, k := range keys {
103+
if err := c.Del(ctx, k).Err(); err != nil {
88104
return err
89105
}
90106
}

pkg/config/config.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,16 @@ type RedisConfig struct {
236236
DB int `mapstructure:"db"`
237237
TLS *bool `mapstructure:"tls"`
238238
InsecureSkipVerify bool `mapstructure:"insecure_skip_verify"`
239+
Cluster *bool `mapstructure:"cluster"`
240+
}
241+
242+
// ClusterEnabled reports whether the Redis client should be built in
243+
// cluster mode (redis.NewClusterClient) so it follows MOVED/ASK redirects
244+
// across shards — required for AWS ElastiCache Valkey/Redis in cluster mode.
245+
// Single-node is the default: a nil flag (key omitted) preserves today's
246+
// behaviour. Set redis.cluster: true (or REDIS_CLUSTER=true) to opt in.
247+
func (r RedisConfig) ClusterEnabled() bool {
248+
return r.Cluster != nil && *r.Cluster
239249
}
240250

241251
// TLSEnabled reports whether the Redis client should dial over TLS. TLS is
@@ -256,6 +266,16 @@ func redisTLSFromEnv(v string) *bool {
256266
return &enabled
257267
}
258268

269+
// redisClusterFromEnv resolves a non-empty REDIS_CLUSTER value to a cluster
270+
// flag, fail-safe OFF: only an explicit on-value (true/1/yes/on, any case)
271+
// enables cluster mode; every other value (a typo, empty, off, ...) keeps the
272+
// single-node default so an operator never accidentally switches topology.
273+
func redisClusterFromEnv(v string) *bool {
274+
on := map[string]bool{"true": true, "1": true, "yes": true, "on": true}
275+
enabled := on[strings.ToLower(strings.TrimSpace(v))]
276+
return &enabled
277+
}
278+
259279
var (
260280
cfg *Config
261281
cfgOnce sync.Once
@@ -352,6 +372,14 @@ func loadConfigFromPath(path string) (*Config, error) {
352372
loaded.Redis.TLS = redisTLSFromEnv(val)
353373
}
354374

375+
// Redis env override: REDIS_CLUSTER toggles cluster-mode client
376+
// construction. Fail-safe OFF: only an explicit on-value (true/1/yes/on,
377+
// any case) enables cluster mode; unset or any other value keeps the
378+
// single-node default so topology is never switched by accident.
379+
if val := os.Getenv("REDIS_CLUSTER"); val != "" {
380+
loaded.Redis.Cluster = redisClusterFromEnv(val)
381+
}
382+
355383
// Storage env overrides
356384
if t := os.Getenv("STORAGE_TYPE"); t != "" {
357385
loaded.Storage.Type = t

pkg/config/redis_test.go

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,3 +62,63 @@ func TestRedisTLSFromEnv(t *testing.T) {
6262
})
6363
}
6464
}
65+
66+
// TestRedisConfigClusterEnabled covers the cluster opt-in: single-node is the
67+
// default when the flag is omitted, an explicit true enables cluster mode.
68+
func TestRedisConfigClusterEnabled(t *testing.T) {
69+
tru := true
70+
fls := false
71+
72+
tests := []struct {
73+
name string
74+
cluster *bool
75+
want bool
76+
}{
77+
{"omitted defaults to single-node", nil, false},
78+
{"explicit true", &tru, true},
79+
{"explicit false", &fls, false},
80+
}
81+
82+
for _, tc := range tests {
83+
t.Run(tc.name, func(t *testing.T) {
84+
rc := RedisConfig{Cluster: tc.cluster}
85+
if got := rc.ClusterEnabled(); got != tc.want {
86+
t.Fatalf("ClusterEnabled() = %v, want %v", got, tc.want)
87+
}
88+
})
89+
}
90+
}
91+
92+
// TestRedisClusterFromEnv covers the fail-safe-OFF parse: only an explicit
93+
// on-value (true/1/yes/on, any case) enables cluster mode; every other value
94+
// — an off-value, a typo, or garbage — keeps the single-node default. Unset
95+
// is handled by the caller (no override) and stays off via ClusterEnabled().
96+
func TestRedisClusterFromEnv(t *testing.T) {
97+
tests := []struct {
98+
value string
99+
want bool // resolved ClusterEnabled()
100+
}{
101+
{"true", true},
102+
{"TRUE", true},
103+
{"1", true},
104+
{"yes", true},
105+
{"on", true},
106+
{" true ", true},
107+
{"false", false},
108+
{"0", false},
109+
{"no", false},
110+
{"off", false},
111+
{"ture", false}, // typo stays single-node
112+
{"garbage", false},
113+
{"", false},
114+
}
115+
116+
for _, tc := range tests {
117+
t.Run(tc.value, func(t *testing.T) {
118+
got := RedisConfig{Cluster: redisClusterFromEnv(tc.value)}.ClusterEnabled()
119+
if got != tc.want {
120+
t.Fatalf("redisClusterFromEnv(%q) → ClusterEnabled() = %v, want %v", tc.value, got, tc.want)
121+
}
122+
})
123+
}
124+
}

pkg/core/oncall.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ var CreateOnCallProvider func(cfg *config.Config, awsClient *ssmincidents.Client
2323
// OnCallWorkflow coordinates on-call escalation with a single provider
2424
type OnCallWorkflow struct {
2525
provider OnCallProvider
26-
redisClient *redis.Client
26+
redisClient redis.UniversalClient
2727
}
2828

2929
// Global instance for singleton access
@@ -33,7 +33,7 @@ var (
3333
)
3434

3535
// NewOnCallWorkflow creates a new on-call workflow with the given provider
36-
func NewOnCallWorkflow(redisClient *redis.Client, provider OnCallProvider) *OnCallWorkflow {
36+
func NewOnCallWorkflow(redisClient redis.UniversalClient, provider OnCallProvider) *OnCallWorkflow {
3737
return &OnCallWorkflow{
3838
provider: provider,
3939
redisClient: redisClient,
@@ -42,7 +42,7 @@ func NewOnCallWorkflow(redisClient *redis.Client, provider OnCallProvider) *OnCa
4242

4343
// InitOnCallWorkflow initializes the global singleton instance
4444
// This is called once from main.go with the Redis client and AWS client
45-
func InitOnCallWorkflow(awsClient *ssmincidents.Client, redisClient *redis.Client) {
45+
func InitOnCallWorkflow(awsClient *ssmincidents.Client, redisClient redis.UniversalClient) {
4646
once.Do(func() {
4747
cfg := config.GetConfig()
4848

0 commit comments

Comments
 (0)