Files
upstream-centrifugo/internal/redisshard/shard.go
upstream-mirror b352a9a30d
Some checks failed
test / Lint (push) Failing after 3s
test / Test with Go 1.26.8 (push) Failing after 2s
snapshot of https://github.com/centrifugal/centrifugo.git (master, 2026-09-16; blobs > 131072B omitted)
2026-09-16 01:17:21 +05:00

508 lines
16 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package redisshard
import (
"context"
"crypto/tls"
"errors"
"fmt"
"net"
"net/url"
"strconv"
"strings"
"sync"
"time"
"github.com/redis/rueidis"
)
const (
defaultRedisIOTimeout = 4 * time.Second
defaultRedisConnectTimeout = time.Second
// sentinelTopologyRefreshInterval makes the Sentinel client periodically
// re-check its targets with Sentinels instead of relying solely on the
// +switch-master event. That event is the only way the client learns about
// a failover, and it can be missed — a demoted master keeps its connection
// alive and a role change sends no MOVED, so a client which missed the
// event stays pinned to a node that is now a read-only replica until
// restart. This is easy to hit with a Sentinel co-located with each Redis
// node (as in the Bitnami Redis Helm chart), where the client loses its
// Sentinel connection at the same moment the master dies and Redis PUB/SUB
// does not replay the missed message.
sentinelTopologyRefreshInterval = 5 * time.Second
)
type RedisShard struct {
config RedisShardConfig
client rueidis.Client
replicaClient rueidis.Client
closeCh chan struct{}
closeOnce sync.Once
isCluster bool
finalAddress []string
}
// NewRedisShard initializes new Redis shard.
func NewRedisShard(conf RedisShardConfig) (*RedisShard, error) {
if conf.ConnectTimeout == 0 {
conf.ConnectTimeout = defaultRedisConnectTimeout
}
if conf.IOTimeout == 0 {
conf.IOTimeout = defaultRedisIOTimeout
}
options := rueidis.ClientOption{
SelectDB: conf.DB,
ConnWriteTimeout: conf.IOTimeout,
TLSConfig: conf.TLSConfig,
Username: conf.User,
Password: conf.Password,
ClientName: conf.ClientName,
ShuffleInit: true,
DisableCache: true,
AlwaysPipelining: true,
AlwaysRESP2: conf.ForceRESP2,
MaxFlushDelay: 100 * time.Microsecond,
// Redis commands are issued without per-request context deadlines
// and rely on IOTimeout to bound every call. rueidis retries
// read-only commands on network errors until the context is done —
// which, without a deadline, means a read command issued during a
// Redis outage never returns instead of failing within the IOTimeout
// envelope like writes do. Disable client-level retries entirely:
// errors are surfaced to callers, and reads and writes should fail
// the same way. Same change as in centrifuge RedisShard.
DisableRetry: true,
Dialer: net.Dialer{
Timeout: conf.ConnectTimeout,
// KeepAlive doubles as the rueidis keepalive PING cadence — the
// mechanism that detects a silently stalled connection (peer
// stops replying while TCP stays open) and errors out every
// pending command. Worst-case failure time for a command on a
// stalled connection is 2*KeepAlive + IOTimeout: a straggler
// reply can make one keepalive tick look active, only the next
// tick sends a PING, and the PING waits a full IOTimeout. The
// rueidis default of 1s gives 6s with the default 4s IOTimeout;
// 400ms keeps the worst case within 5s (4.8s), since commands
// are issued without per-request context deadlines. Same change
// as in centrifuge RedisShard, where a regression test pins the
// bound against a blackholed connection.
KeepAlive: 400 * time.Millisecond,
},
}
var isCluster bool
var isSentinel bool
replicaClientEnabled := conf.ReplicaClientEnabled
if len(conf.SentinelAddresses) > 0 {
isSentinel = true
options.InitAddress = conf.SentinelAddresses
options.Sentinel = rueidis.SentinelOption{
TLSConfig: conf.SentinelTLSConfig,
MasterSet: conf.SentinelMasterName,
Username: conf.SentinelUser,
Password: conf.SentinelPassword,
ClientName: conf.SentinelClientName,
}
} else if len(conf.ClusterAddresses) > 0 {
isCluster = true
options.InitAddress = conf.ClusterAddresses
} else {
addressOpts, err := optionsFromAddress(conf.Address, options)
if err != nil {
return nil, fmt.Errorf("error processing Redis address: %v", err)
}
options, isCluster, isSentinel, replicaClientEnabled =
addressOpts.ClientOption, addressOpts.IsCluster, addressOpts.IsSentinel, addressOpts.ReplicaClientEnabled
}
if isSentinel {
if options.Sentinel.MasterSet == "" {
return nil, errors.New("sentinel master name must be configured for Redis Sentinel setup")
}
options.Sentinel.TopologyRefreshInterval = sentinelTopologyRefreshInterval
}
client, err := rueidis.NewClient(options)
if err != nil {
return nil, fmt.Errorf("error creating Redis client: %v", err)
}
if strings.Contains(strings.ToLower(fmt.Sprintf("%T", client)), "cluster") {
// Cluster mode is not explicitly set but client is a cluster client – thus set isCluster to true.
// This scenario covered with tests for our main integrations: see TestNewRedisShard.
// Centrifuge need to know that it's working with Redis Cluster to construct proper keys.
isCluster = true
}
shard := &RedisShard{
config: conf,
isCluster: isCluster,
closeCh: make(chan struct{}),
finalAddress: options.InitAddress,
}
shard.client = client
if replicaClientEnabled {
if !isCluster && !isSentinel {
return nil, errors.New("replica client may be initialized only in cluster and sentinel mode")
}
options.ReplicaOnly = true
replicaClient, err := rueidis.NewClient(options)
if err != nil {
return nil, fmt.Errorf("error creating Redis replica client: %w", err)
}
shard.replicaClient = replicaClient
}
return shard, nil
}
var knownRedisURLPrefixes = []string{
"redis://",
"rediss://",
"redis+sentinel://",
"rediss+sentinel://",
"redis+cluster://",
"unix://",
"tcp://",
}
type fromAddressOptions struct {
ClientOption rueidis.ClientOption
IsCluster bool
IsSentinel bool
ReplicaClientEnabled bool
}
func optionsFromAddress(address string, options rueidis.ClientOption) (fromAddressOptions, error) {
result := fromAddressOptions{ClientOption: options}
hasKnownURLPrefix := false
for _, prefix := range knownRedisURLPrefixes {
if strings.HasPrefix(address, prefix) {
hasKnownURLPrefix = true
break
}
}
if !hasKnownURLPrefix {
if host, port, err := net.SplitHostPort(address); err == nil && host != "" && port != "" {
result.ClientOption.InitAddress = []string{address}
return result, nil
}
return result, errors.New("malformed connection address, must be Redis URL or host:port")
}
u, err := url.Parse(address)
if err != nil {
return result, fmt.Errorf("malformed connection address, not a valid URL: %w", err)
}
var addresses []string
query := u.Query()
if query.Has("sentinel_master_name") {
result.ClientOption.Sentinel.MasterSet = query.Get("sentinel_master_name")
}
isCluster := u.Scheme == "redis+cluster"
isSentinel := u.Scheme == "redis+sentinel" || u.Scheme == "rediss+sentinel" || result.ClientOption.Sentinel.MasterSet != ""
if isSentinel && result.ClientOption.Sentinel.MasterSet == "" {
return result, errors.New("sentinel master name must be configured for Redis Sentinel setup")
}
switch u.Scheme {
case "tcp", "redis", "redis+sentinel", "redis+cluster", "rediss", "rediss+sentinel":
addresses = []string{u.Host}
if u.Path != "" {
db, err := strconv.Atoi(strings.TrimPrefix(u.Path, "/"))
if err != nil {
return result, fmt.Errorf("can't parse Redis DB number from connection address: %s is not a number", u.Path)
}
result.ClientOption.SelectDB = db
}
if strings.HasPrefix(u.Scheme, "rediss") {
if result.ClientOption.TLSConfig == nil {
result.ClientOption.TLSConfig = &tls.Config{
MinVersion: tls.VersionTLS12,
}
}
if isSentinel && result.ClientOption.Sentinel.TLSConfig == nil {
result.ClientOption.Sentinel.TLSConfig = &tls.Config{
MinVersion: tls.VersionTLS12,
}
}
}
case "unix":
addresses = []string{u.Path}
result.ClientOption.DialFn = func(s string, d *net.Dialer, c *tls.Config) (net.Conn, error) {
return d.Dial("unix", s)
}
}
if u.User != nil {
if u.User.Username() != "" {
result.ClientOption.Username = u.User.Username()
}
if pass, ok := u.User.Password(); ok {
result.ClientOption.Password = pass
}
}
addresses = append(addresses, query["addr"]...)
if query.Has("connect_timeout") {
to, err := time.ParseDuration(query.Get("connect_timeout"))
if err != nil {
return result, fmt.Errorf("invalid connect timeout: %q", query.Get("connect_timeout"))
}
result.ClientOption.Dialer.Timeout = to
}
if query.Has("io_timeout") {
to, err := time.ParseDuration(query.Get("io_timeout"))
if err != nil {
return result, fmt.Errorf("invalid io timeout: %q", query.Get("io_timeout"))
}
result.ClientOption.ConnWriteTimeout = to
}
if query.Has("tls_enabled") && result.ClientOption.TLSConfig == nil {
val, err := strconv.ParseBool(query.Get("tls_enabled"))
if err != nil {
return result, fmt.Errorf("invalid tls_enabled value: %q", query.Get("tls_enabled"))
}
if val {
result.ClientOption.TLSConfig = &tls.Config{
MinVersion: tls.VersionTLS12,
}
}
}
if query.Has("force_resp2") {
val, err := strconv.ParseBool(query.Get("force_resp2"))
if err != nil {
return result, fmt.Errorf("invalid force_resp2 value: %q", query.Get("force_resp2"))
}
result.ClientOption.AlwaysRESP2 = val
}
if query.Has("sentinel_user") {
result.ClientOption.Sentinel.Username = query.Get("sentinel_user")
}
if query.Has("sentinel_password") {
result.ClientOption.Sentinel.Password = query.Get("sentinel_password")
}
if query.Has("sentinel_tls_enabled") && result.ClientOption.Sentinel.TLSConfig == nil {
val, err := strconv.ParseBool(query.Get("sentinel_tls_enabled"))
if err != nil {
return result, fmt.Errorf("invalid sentinel_tls_enabled value: %q", query.Get("sentinel_tls_enabled"))
}
if val {
result.ClientOption.Sentinel.TLSConfig = &tls.Config{
MinVersion: tls.VersionTLS12,
}
}
}
if query.Has("replica_client_enabled") {
val, err := strconv.ParseBool(query.Get("replica_client_enabled"))
if err != nil {
return result, fmt.Errorf("invalid replica_client_enabled value: %q", query.Get("replica_client_enabled"))
}
result.ReplicaClientEnabled = val
}
result.ClientOption.InitAddress = addresses
result.IsCluster = isCluster
result.IsSentinel = isSentinel
return result, nil
}
// RedisShardConfig contains Redis connection options.
type RedisShardConfig struct {
// Address is a Redis server connection address. Address can be:
// - host:port
// - tcp://[[[user]:password]@]host:port[/db][?option1=value1&optionN=valueN]
// - redis://[[[user]:password]@]host:port[/db][?option1=value1&optionN=valueN]
// - unix://[[[user]:password]@]path[?option1=value1&optionN=valueN]
// It's also possible to use Address with redis+sentinel:// and redis+cluster://
// schemes when connecting to Redis Sentinel and Redis Cluster respectively.
// Examples:
// - redis+sentinel://[[[user]:password]@]host:port?sentinel_master_name=mymaster
// - redis+cluster://[[[user]:password]@]host:port[?addr=host2:port2&addr=host3:port3]
// If you need to connect to Redis Cluster then you need to provide ClusterAddresses
// or must use redis+cluster:// scheme in Address.
// If you need to connect to Redis Sentinel then you need to provide SentinelAddresses
// or must use redis+sentinel:// scheme in Address.
// I.e. Centrifuge requires you to explicitly specify the type of Redis setup you want
// to connect to.
Address string
// ClusterAddresses is a slice of seed cluster addresses to connect to.
// Each address should be in form of host:port. If ClusterAddresses set then
// RedisShardConfig.Address not used at all.
ClusterAddresses []string
// SentinelAddresses is a slice of Sentinel addresses. Each address should
// be in form of host:port. If set then Redis address will be automatically
// discovered from Sentinel. For Sentinel the name of the master instance
// Sentinel monitors (SentinelMasterName) must be provided. If SentinelAddresses
// set then RedisShardConfig.Address not used at all.
SentinelAddresses []string
// SentinelMasterName is a name of Redis instance master Sentinel monitors.
SentinelMasterName string
// SentinelUser is a user for Sentinel ACL-based auth.
SentinelUser string
// SentinelPassword is a password for Sentinel. Works with Sentinel >= 5.0.1.
SentinelPassword string
// SentinelClientName is a client name for established connections to Sentinel.
SentinelClientName string
// SentinelTLSConfig is a TLS configuration for Sentinel connections.
SentinelTLSConfig *tls.Config
// DB is Redis database number. If not set then database 0 used. Does not make sense in Redis Cluster case.
DB int
// User is a username for Redis ACL-based auth.
User string
// Password is password to use when connecting to Redis database.
// If zero then password not used.
Password string
// ClientName for established connections. See https://redis.io/commands/client-setname/
ClientName string
// TLSConfig contains connection TLS configuration.
TLSConfig *tls.Config
// ConnectTimeout is a timeout on connect operation.
// By default, 1 second is used.
ConnectTimeout time.Duration
// IOTimeout is a timeout on Redis connection operations.
// By default, 4 seconds is used.
IOTimeout time.Duration
// ForceRESP2 if set to true forces using RESP2 protocol for communicating with Redis.
// By default, Redis client tries to detect supported Redis protocol automatically
// trying RESP3 first.
ForceRESP2 bool
// ReplicaClientEnabled once set to true will initialize replica client for this shard.
// Replica client can then be used for read-only operations from replica nodes in Redis
// Cluster or Redis Sentinel setups (single Redis is not allowed). Replica client will
// be initialized with the same options as the main client but with ReplicaOnly option
// set to true.
ReplicaClientEnabled bool
}
func (s *RedisShard) Close() {
s.closeOnce.Do(func() {
close(s.closeCh)
s.client.Close()
if s.replicaClient != nil {
s.replicaClient.Close()
}
})
}
func (s *RedisShard) String() string {
return strings.Join(s.finalAddress, ",")
}
type Op func(client rueidis.Client) rueidis.RedisResult
// RunOp allows to run Op on selected shard.
func (s *RedisShard) RunOp(op Op) rueidis.RedisResult {
return op(s.client)
}
// RunOpReplica allows to run Op on selected shard, possibly on replica.
func (s *RedisShard) RunOpReplica(op Op, onReplica bool) rueidis.RedisResult {
if onReplica && s.replicaClient != nil {
return op(s.replicaClient)
}
return op(s.client)
}
type MultiOp func(client rueidis.Client) []rueidis.RedisResult
// RunMulti allows executing multiple commands.
func (s *RedisShard) RunMulti(op MultiOp) []rueidis.RedisResult {
return op(s.client)
}
// Publish sends data to a Redis PUB/SUB channel.
// Uses SPUBLISH for cluster mode, PUBLISH otherwise.
func (s *RedisShard) Publish(ctx context.Context, channel string, data []byte) error {
var cmd rueidis.Completed
if s.isCluster {
cmd = s.client.B().Spublish().Channel(channel).Message(string(data)).Build()
} else {
cmd = s.client.B().Publish().Channel(channel).Message(string(data)).Build()
}
return s.client.Do(ctx, cmd).Error()
}
// Subscribe listens on a Redis PUB/SUB channel and calls handler for each message.
// Uses SSUBSCRIBE for cluster mode, SUBSCRIBE otherwise.
// Blocks until ctx is cancelled. Handles reconnections automatically.
func (s *RedisShard) Subscribe(ctx context.Context, channel string, handler func(data []byte)) error {
for {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
err := s.runSubscribeLoop(ctx, channel, handler)
if err != nil && ctx.Err() != nil {
return ctx.Err()
}
// Reconnect after a short delay.
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(300 * time.Millisecond):
}
}
}
func (s *RedisShard) runSubscribeLoop(ctx context.Context, channel string, handler func(data []byte)) error {
conn, cancel := s.client.Dedicate()
defer cancel()
defer conn.Close()
done := make(chan struct{})
var doneOnce sync.Once
wait := conn.SetPubSubHooks(rueidis.PubSubHooks{
OnMessage: func(msg rueidis.PubSubMessage) {
handler([]byte(msg.Message))
},
OnSubscription: func(ps rueidis.PubSubSubscription) {
if s.isCluster && ps.Kind == "sunsubscribe" && ps.Channel == channel {
doneOnce.Do(func() { close(done) })
}
},
})
var err error
if s.isCluster {
err = conn.Do(ctx, conn.B().Ssubscribe().Channel(channel).Build()).Error()
} else {
err = conn.Do(ctx, conn.B().Subscribe().Channel(channel).Build()).Error()
}
if err != nil {
return err
}
select {
case <-ctx.Done():
return ctx.Err()
case <-done:
return errors.New("subscription lost due to slot migration")
case err = <-wait:
return err
}
}