Files
go-redis/export_test.go
875ce21e48 fix(sentinel): do not close sentinel when replica list is empty (#3795)
* fix: do not close sentinel when replicas list is empty

When a sentinel-monitored Redis master has no replicas (master-only
setup), replicaAddrss() was calling closeSentinel() on receiving an
empty replica list without error. This destroyed the sentinel
connection (and its pub/sub failover subscription), forcing a full
sentinel rediscovery on every subsequent operation — creating a
continuous rediscovery loop that flooded logs and added latency.

The empty-replica case is a valid, stable state for master-only
deployments. The sentinel connection must be preserved for ongoing
master discovery and failover monitoring via pub/sub.

The fix returns early with an empty slice instead of tearing down
the sentinel. A test is added to verify the sentinel survives
repeated ReplicaAddrs calls when no replicas exist.

Fixes: sentinel rediscovery loop on master-only clusters

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: preserve UseDisconnectedReplicas fallback path

Address review feedback: the early return for zero replicas must only
apply when useDisconnected=false (the normal path). When
useDisconnected=true, fall through to the discovery loop which
correctly passes useDisconnected to parseReplicaAddrs — since
getReplicaAddrs hardcodes false, the cached sentinel path can never
find disconnected replicas.

Also remove unused SetSentinelForTest helper.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* test: use SENTINEL MONITOR to create a true zero-replica master

Address review feedback: the previous test used sentinelName which
has two replicas in the test environment, so getReplicaAddrs returned
a non-empty list and the zero-replica code path was never exercised.

Now the test registers a temporary master-only name via SENTINEL
MONITOR (pointing to the existing master port but with a new name
and no replicas), verifies that ReplicaAddrs returns empty without
closing the sentinel, and cleans up via SENTINEL REMOVE.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* test: skip zero-replica sentinel test when infrastructure unavailable

Add a connectivity check at the start of the test so it is skipped
(not failed) in environments where sentinel processes are not running.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* test: use standalone Redis (no replicas) for zero-replica sentinel test

Point SENTINEL MONITOR at the standalone Redis instance (redisPort)
instead of sentinelMasterPort, which already has two replicas. This
prevents Sentinel from discovering replicas via INFO REPLICATION and
ensures the test reliably exercises the zero-replica code path.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* ci: re-trigger checks

* ci: re-trigger checks (flaky PubSub ClientSetName and conn_state tests)

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Nedyalko Dyakov <1547186+ndyakov@users.noreply.github.com>
2026-05-27 17:05:11 +03:00

133 lines
3.1 KiB
Go

package redis
import (
"context"
"fmt"
"net"
"strings"
"github.com/redis/go-redis/v9/internal"
"github.com/redis/go-redis/v9/internal/hashtag"
"github.com/redis/go-redis/v9/internal/pool"
)
func (c *baseClient) Pool() pool.Pooler {
return c.connPool
}
func (c *PubSub) SetNetConn(netConn net.Conn) {
c.cn = pool.NewConn(netConn)
}
func (c *ClusterClient) LoadState(ctx context.Context) (*clusterState, error) {
// return c.state.Reload(ctx)
return c.loadState(ctx)
}
func (c *ClusterClient) SlotAddrs(ctx context.Context, slot int) []string {
state, err := c.state.Get(ctx)
if err != nil {
panic(err)
}
var addrs []string
for _, n := range state.slotNodes(slot) {
addrs = append(addrs, n.Client.getAddr())
}
return addrs
}
func (c *ClusterClient) Nodes(ctx context.Context, key string) ([]*clusterNode, error) {
state, err := c.state.Reload(ctx)
if err != nil {
return nil, err
}
slot := hashtag.Slot(key)
nodes := state.slotNodes(slot)
if len(nodes) != 2 {
return nil, fmt.Errorf("slot=%d does not have enough nodes: %v", slot, nodes)
}
return nodes, nil
}
func (c *ClusterClient) SwapNodes(ctx context.Context, key string) error {
nodes, err := c.Nodes(ctx, key)
if err != nil {
return err
}
nodes[0], nodes[1] = nodes[1], nodes[0]
return nil
}
func (c *clusterState) IsConsistent(ctx context.Context) bool {
if len(c.Masters) < 3 {
return false
}
for _, master := range c.Masters {
s := master.Client.Info(ctx, "replication").Val()
if !strings.Contains(s, "role:master") {
return false
}
}
if len(c.Slaves) < 3 {
return false
}
for _, slave := range c.Slaves {
s := slave.Client.Info(ctx, "replication").Val()
if !strings.Contains(s, "role:slave") {
return false
}
}
return true
}
func GetSlavesAddrByName(ctx context.Context, c *SentinelClient, name string) []string {
addrs, err := c.Replicas(ctx, name).Result()
if err != nil {
internal.Logger.Printf(ctx, "sentinel: Replicas name=%q failed: %s",
name, err)
return []string{}
}
return parseReplicaAddrs(addrs, false)
}
func (c *Ring) ShardByName(name string) *ringShard {
shard, _ := c.sharding.GetByName(name)
return shard
}
func (c *ModuleLoadexConfig) ToArgs() []interface{} {
return c.toArgs()
}
func ShouldRetry(err error, retryTimeout bool) bool {
return shouldRetry(err, retryTimeout)
}
// SentinelFailoverHasSentinel returns true if the sentinelFailover
// still holds a live sentinel connection. Used in tests to verify
// that replicaAddrs does not tear down the sentinel for master-only
// (zero-replica) setups.
func (c *sentinelFailover) HasSentinel() bool {
c.mu.RLock()
defer c.mu.RUnlock()
return c.sentinel != nil
}
// NewTestSentinelFailover creates a sentinelFailover with minimal
// options for unit testing. The caller must set c.sentinel manually.
func NewTestSentinelFailover(opt *FailoverOptions, sentinelAddrs []string) *sentinelFailover {
return &sentinelFailover{
opt: opt,
sentinelAddrs: sentinelAddrs,
}
}
// ReplicaAddrs is exported for testing.
func (c *sentinelFailover) ReplicaAddrs(ctx context.Context) ([]string, error) {
return c.replicaAddrs(ctx, false)
}