feat(store/valkey): Add Redis(R) Sentinel support (#1294)

* feat(internal): add ListOr[T any] type

This is a utility type that lets you decode a JSON T or list of T as a
single value. This will be used with Redis Sentinel config so that you
can specify multiple sentinel addresses.

Ref TecharoHQ/botstopper#24

Assisted-by: GLM 4.6 via Claude Code
Signed-off-by: Xe Iaso <me@xeiaso.net>

* feat(store/valkey): add Redis(R) Sentinel support

Signed-off-by: Xe Iaso <me@xeiaso.net>

* chore: spelling

check-spelling run (pull_request) for Xe/redis-sentinel

Signed-off-by: check-spelling-bot <check-spelling-bot@users.noreply.github.com>
on-behalf-of: @check-spelling <check-spelling-bot@check-spelling.dev>

* chore(store/valkey): remove pointless comments

Signed-off-by: Xe Iaso <me@xeiaso.net>

* docs: document the Redis™ Sentinel configuration options

Signed-off-by: Xe Iaso <me@xeiaso.net>

* fix(store/valkey): Redis™ Sentinel doesn't require a password

Signed-off-by: Xe Iaso <me@xeiaso.net>

* chore: spelling

Signed-off-by: Xe Iaso <me@xeiaso.net>

* chore: spelling

Signed-off-by: Xe Iaso <me@xeiaso.net>

---------

Signed-off-by: Xe Iaso <me@xeiaso.net>
Signed-off-by: check-spelling-bot <check-spelling-bot@users.noreply.github.com>
This commit is contained in:
Xe Iaso 2025-11-18 09:55:19 -05:00 committed by GitHub
parent 69e9023cbb
commit 02989f03d0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 321 additions and 24 deletions

View file

@ -7,6 +7,7 @@ import (
"fmt"
"time"
"github.com/TecharoHQ/anubis/internal"
"github.com/TecharoHQ/anubis/lib/store"
valkey "github.com/redis/go-redis/v9"
"github.com/redis/go-redis/v9/maintnotifications"
@ -16,26 +17,84 @@ func init() {
store.Register("valkey", Factory{})
}
// Errors kept as-is so other code/tests still pass.
var (
ErrNoURL = errors.New("valkey.Config: no URL defined")
ErrBadURL = errors.New("valkey.Config: URL is invalid")
// Sentinel validation errors
ErrSentinelMasterNameRequired = errors.New("valkey.Sentinel: masterName is required")
ErrSentinelAddrRequired = errors.New("valkey.Sentinel: addr is required")
ErrSentinelAddrEmpty = errors.New("valkey.Sentinel: addr cannot be empty")
)
// Config is what Anubis unmarshals from the "parameters" JSON.
type Config struct {
URL string `json:"url"`
Cluster bool `json:"cluster,omitempty"`
Sentinel *Sentinel `json:"sentinel,omitempty"`
}
func (c Config) Valid() error {
if c.URL == "" {
return ErrNoURL
var errs []error
if c.URL == "" && c.Sentinel == nil {
errs = append(errs, ErrNoURL)
}
// Just validate that it's a valid Redis URL.
if _, err := valkey.ParseURL(c.URL); err != nil {
return fmt.Errorf("%w: %v", ErrBadURL, err)
// Validate URL only if provided
if c.URL != "" {
if _, err := valkey.ParseURL(c.URL); err != nil {
errs = append(errs, fmt.Errorf("%w: %v", ErrBadURL, err))
}
}
if c.Sentinel != nil {
if err := c.Sentinel.Valid(); err != nil {
errs = append(errs, err)
}
}
if len(errs) > 0 {
return errors.Join(errs...)
}
return nil
}
type Sentinel struct {
MasterName string `json:"masterName"`
Addr internal.ListOr[string] `json:"addr"`
ClientName string `json:"clientName,omitempty"`
Username string `json:"username,omitempty"`
Password string `json:"password,omitempty"`
}
func (s Sentinel) Valid() error {
var errs []error
if s.MasterName == "" {
errs = append(errs, ErrSentinelMasterNameRequired)
}
if len(s.Addr) == 0 {
errs = append(errs, ErrSentinelAddrRequired)
} else {
// Check if all addresses in the list are empty
allEmpty := true
for _, addr := range s.Addr {
if addr != "" {
allEmpty = false
break
}
}
if allEmpty {
errs = append(errs, ErrSentinelAddrEmpty)
}
}
if len(errs) > 0 {
return errors.Join(errs...)
}
return nil
@ -68,14 +127,15 @@ func (Factory) Build(ctx context.Context, data json.RawMessage) (store.Interface
return nil, err
}
opts, err := valkey.ParseURL(cfg.URL)
if err != nil {
return nil, fmt.Errorf("valkey.Factory: %w", err)
}
var client redisClient
if cfg.Cluster {
switch {
case cfg.Cluster:
opts, err := valkey.ParseURL(cfg.URL)
if err != nil {
return nil, fmt.Errorf("valkey.Factory: %w", err)
}
// Cluster mode: use the parsed Addr as the seed node.
clusterOpts := &valkey.ClusterOptions{
Addrs: []string{opts.Addr},
@ -86,7 +146,23 @@ func (Factory) Build(ctx context.Context, data json.RawMessage) (store.Interface
},
}
client = valkey.NewClusterClient(clusterOpts)
} else {
case cfg.Sentinel != nil:
opts := &valkey.FailoverOptions{
MasterName: cfg.Sentinel.MasterName,
SentinelAddrs: cfg.Sentinel.Addr,
SentinelUsername: cfg.Sentinel.Username,
SentinelPassword: cfg.Sentinel.Password,
Username: cfg.Sentinel.Username,
Password: cfg.Sentinel.Password,
ClientName: cfg.Sentinel.ClientName,
}
client = valkey.NewFailoverClusterClient(opts)
default:
opts, err := valkey.ParseURL(cfg.URL)
if err != nil {
return nil, fmt.Errorf("valkey.Factory: %w", err)
}
opts.MaintNotificationsConfig = &maintnotifications.Config{
Mode: maintnotifications.ModeDisabled,
}