Files
go-redis/internal_test.go
Spicy Shark a179f00376 fix(cluster): accept a null host in CLUSTER SLOTS (#4131)
* fix(cluster): accept a null host in CLUSTER SLOTS

With cluster-preferred-endpoint-type unknown-endpoint the server sends a
null host. ClusterSlotsCmd returned redis.Nil and left the rest of the
reply unread on the connection. Read the null as an empty host and let
newClusterState reuse the origin host, as redis.conf describes.

* test(cluster): expect the origin host for an empty slot host

An empty host in CLUSTER SLOTS, which a node sends when it does not know
its own IP, now also takes the origin host, as redis-py does. Update the
loopback spec that asserted the old ":1234" address.

* chore(cluster): say why a null CLUSTER SLOTS host is accepted

Explain the Nil guard so it is not removed later as a typo.

* test(cluster): cover unknown-endpoint end to end

Set cluster-preferred-endpoint-type unknown-endpoint on the 6-node test
cluster, then check that a ClusterClient loads the topology, runs
SET/GET on two shards and follows a MOVED redirect.

* test(cluster): say the MOVED check relies on a loopback cluster

With unknown-endpoint, Redis sends MOVED and ASK with an empty host, and
the client dials ":port" on the local machine. The spec follows the
MOVED only because the test cluster is on loopback. Say so in the spec
comment and the It text, and leave taking the origin host for the
redirect target to a follow-up.
2026-10-26 12:36:10 +02:00

1663 lines
50 KiB
Go

package redis
import (
"context"
"errors"
"fmt"
"net"
"reflect"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/redis/go-redis/v9/auth"
"github.com/redis/go-redis/v9/internal/pool"
"github.com/redis/go-redis/v9/internal/proto"
. "github.com/bsm/ginkgo/v2"
. "github.com/bsm/gomega"
)
var ctx = context.TODO()
type capturingLogger struct {
mu sync.Mutex
logs []string
}
func (l *capturingLogger) Printf(_ context.Context, format string, v ...interface{}) {
l.mu.Lock()
defer l.mu.Unlock()
l.logs = append(l.logs, fmt.Sprintf(format, v...))
}
func (l *capturingLogger) contains(substr string) bool {
l.mu.Lock()
defer l.mu.Unlock()
for _, msg := range l.logs {
if strings.Contains(msg, substr) {
return true
}
}
return false
}
var _ = Describe("newClusterState", func() {
var state *clusterState
createClusterState := func(slots []ClusterSlot) *clusterState {
opt := &ClusterOptions{}
opt.init()
nodes := newClusterNodes(opt)
state, err := newClusterState(nodes, slots, "10.10.10.10:1234")
Expect(err).NotTo(HaveOccurred())
return state
}
Describe("sorting", func() {
BeforeEach(func() {
state = createClusterState([]ClusterSlot{{
Start: 1000,
End: 1999,
}, {
Start: 0,
End: 999,
}, {
Start: 2000,
End: 2999,
}})
})
It("sorts slots", func() {
Expect(state.slots).To(Equal([]*clusterSlot{
{start: 0, end: 999, nodes: nil},
{start: 1000, end: 1999, nodes: nil},
{start: 2000, end: 2999, nodes: nil},
}))
})
})
Describe("loopback", func() {
BeforeEach(func() {
state = createClusterState([]ClusterSlot{{
Nodes: []ClusterNode{{Addr: "127.0.0.1:7001"}},
}, {
Nodes: []ClusterNode{{Addr: "127.0.0.1:7002"}},
}, {
Nodes: []ClusterNode{{Addr: "1.2.3.4:1234"}},
}, {
Nodes: []ClusterNode{{Addr: ":1234"}},
}})
})
It("replaces loopback hosts in addresses", func() {
slotAddr := func(slot *clusterSlot) string {
return slot.nodes[0].Client.Options().Addr
}
Expect(slotAddr(state.slots[0])).To(Equal("10.10.10.10:7001"))
Expect(slotAddr(state.slots[1])).To(Equal("10.10.10.10:7002"))
Expect(slotAddr(state.slots[2])).To(Equal("1.2.3.4:1234"))
Expect(slotAddr(state.slots[3])).To(Equal("10.10.10.10:1234"))
})
})
Describe("NodeAddress", func() {
BeforeEach(func() {
state = createClusterState([]ClusterSlot{{
Nodes: []ClusterNode{{Addr: "127.0.0.1:7001"}},
}, {
Nodes: []ClusterNode{{Addr: "127.0.0.1:7002"}},
}, {
Nodes: []ClusterNode{{Addr: "1.2.3.4:1234"}},
}, {
Nodes: []ClusterNode{{Addr: ":1234"}},
}})
})
It("preserves node address from ClusterSlots before loopback replacement", func() {
slotNodeAddress := func(slot *clusterSlot) string {
return slot.nodes[0].Client.NodeAddress()
}
// Node addresses should be preserved even when Addr is transformed
Expect(slotNodeAddress(state.slots[0])).To(Equal("127.0.0.1:7001"))
Expect(slotNodeAddress(state.slots[1])).To(Equal("127.0.0.1:7002"))
Expect(slotNodeAddress(state.slots[2])).To(Equal("1.2.3.4:1234"))
Expect(slotNodeAddress(state.slots[3])).To(Equal(":1234"))
})
It("has different Addr and NodeAddress when loopback is replaced", func() {
// For loopback addresses, Addr should be transformed but NodeAddress preserved
slot0 := state.slots[0]
Expect(slot0.nodes[0].Client.Options().Addr).To(Equal("10.10.10.10:7001"))
Expect(slot0.nodes[0].Client.NodeAddress()).To(Equal("127.0.0.1:7001"))
// For non-loopback addresses, Addr and NodeAddress should be the same
slot2 := state.slots[2]
Expect(slot2.nodes[0].Client.Options().Addr).To(Equal("1.2.3.4:1234"))
Expect(slot2.nodes[0].Client.NodeAddress()).To(Equal("1.2.3.4:1234"))
})
})
Describe("NodeAddress with FQDN", func() {
BeforeEach(func() {
// Simulate FQDN endpoints that might be resolved/transformed
state = createClusterState([]ClusterSlot{{
Nodes: []ClusterNode{{Addr: "redis-master.example.com:6379"}},
}, {
Nodes: []ClusterNode{{Addr: "redis-replica-1.example.com:6379"}},
}, {
Nodes: []ClusterNode{{Addr: "redis-replica-2.example.com:6379"}},
}})
})
It("preserves FQDN node addresses", func() {
slotNodeAddress := func(slot *clusterSlot) string {
return slot.nodes[0].Client.NodeAddress()
}
Expect(slotNodeAddress(state.slots[0])).To(Equal("redis-master.example.com:6379"))
Expect(slotNodeAddress(state.slots[1])).To(Equal("redis-replica-1.example.com:6379"))
Expect(slotNodeAddress(state.slots[2])).To(Equal("redis-replica-2.example.com:6379"))
})
})
Describe("NodeAddress with multiple nodes per slot", func() {
BeforeEach(func() {
state = createClusterState([]ClusterSlot{{
Start: 0,
End: 5460,
Nodes: []ClusterNode{
{Addr: "127.0.0.1:7001"}, // master
{Addr: "127.0.0.1:7004"}, // replica
},
}, {
Start: 5461,
End: 10922,
Nodes: []ClusterNode{
{Addr: "master-2.redis.local:6379"}, // master
{Addr: "replica-2.redis.local:6379"}, // replica
},
}})
})
It("preserves node addresses for all nodes in a slot", func() {
// First slot - loopback addresses
slot0 := state.slots[0]
Expect(slot0.nodes[0].Client.NodeAddress()).To(Equal("127.0.0.1:7001"))
Expect(slot0.nodes[1].Client.NodeAddress()).To(Equal("127.0.0.1:7004"))
// Verify Addr is transformed for loopback
Expect(slot0.nodes[0].Client.Options().Addr).To(Equal("10.10.10.10:7001"))
Expect(slot0.nodes[1].Client.Options().Addr).To(Equal("10.10.10.10:7004"))
// Second slot - FQDN addresses (no transformation)
slot1 := state.slots[1]
Expect(slot1.nodes[0].Client.NodeAddress()).To(Equal("master-2.redis.local:6379"))
Expect(slot1.nodes[1].Client.NodeAddress()).To(Equal("replica-2.redis.local:6379"))
// Verify Addr is same as NodeAddress for non-loopback
Expect(slot1.nodes[0].Client.Options().Addr).To(Equal("master-2.redis.local:6379"))
Expect(slot1.nodes[1].Client.Options().Addr).To(Equal("replica-2.redis.local:6379"))
})
})
})
// TestNodeAddress tests that NodeAddress is correctly preserved
// when cluster nodes are created from ClusterSlots responses.
func TestNodeAddress(t *testing.T) {
t.Run("preserves node address when loopback is replaced", func(t *testing.T) {
opt := &ClusterOptions{}
opt.init()
nodes := newClusterNodes(opt)
defer nodes.Close()
// Create cluster state with loopback addresses
// Origin is non-loopback, so loopback addresses will be replaced
slots := []ClusterSlot{{
Start: 0,
End: 5460,
Nodes: []ClusterNode{{Addr: "127.0.0.1:7001"}},
}, {
Start: 5461,
End: 10922,
Nodes: []ClusterNode{{Addr: "127.0.0.1:7002"}},
}}
state, err := newClusterState(nodes, slots, "10.10.10.10:1234")
if err != nil {
t.Fatalf("newClusterState failed: %v", err)
}
// Verify Addr is transformed (loopback replaced with origin host)
if got := state.slots[0].nodes[0].Client.Options().Addr; got != "10.10.10.10:7001" {
t.Errorf("Addr = %q, want %q", got, "10.10.10.10:7001")
}
if got := state.slots[1].nodes[0].Client.Options().Addr; got != "10.10.10.10:7002" {
t.Errorf("Addr = %q, want %q", got, "10.10.10.10:7002")
}
// Verify NodeAddress is preserved (original from ClusterSlots)
if got := state.slots[0].nodes[0].Client.NodeAddress(); got != "127.0.0.1:7001" {
t.Errorf("NodeAddress = %q, want %q", got, "127.0.0.1:7001")
}
if got := state.slots[1].nodes[0].Client.NodeAddress(); got != "127.0.0.1:7002" {
t.Errorf("NodeAddress = %q, want %q", got, "127.0.0.1:7002")
}
})
t.Run("preserves FQDN node addresses", func(t *testing.T) {
opt := &ClusterOptions{}
opt.init()
nodes := newClusterNodes(opt)
defer nodes.Close()
slots := []ClusterSlot{{
Start: 0,
End: 5460,
Nodes: []ClusterNode{{Addr: "redis-master.example.com:6379"}},
}, {
Start: 5461,
End: 10922,
Nodes: []ClusterNode{{Addr: "redis-replica.example.com:6379"}},
}}
state, err := newClusterState(nodes, slots, "10.10.10.10:1234")
if err != nil {
t.Fatalf("newClusterState failed: %v", err)
}
// For non-loopback addresses, Addr and NodeAddress should be the same
if got := state.slots[0].nodes[0].Client.Options().Addr; got != "redis-master.example.com:6379" {
t.Errorf("Addr = %q, want %q", got, "redis-master.example.com:6379")
}
if got := state.slots[0].nodes[0].Client.NodeAddress(); got != "redis-master.example.com:6379" {
t.Errorf("NodeAddress = %q, want %q", got, "redis-master.example.com:6379")
}
if got := state.slots[1].nodes[0].Client.Options().Addr; got != "redis-replica.example.com:6379" {
t.Errorf("Addr = %q, want %q", got, "redis-replica.example.com:6379")
}
if got := state.slots[1].nodes[0].Client.NodeAddress(); got != "redis-replica.example.com:6379" {
t.Errorf("NodeAddress = %q, want %q", got, "redis-replica.example.com:6379")
}
})
t.Run("preserves node address for multiple nodes per slot", func(t *testing.T) {
opt := &ClusterOptions{}
opt.init()
nodes := newClusterNodes(opt)
defer nodes.Close()
slots := []ClusterSlot{{
Start: 0,
End: 5460,
Nodes: []ClusterNode{
{Addr: "127.0.0.1:7001"}, // master
{Addr: "127.0.0.1:7004"}, // replica
},
}}
state, err := newClusterState(nodes, slots, "10.10.10.10:1234")
if err != nil {
t.Fatalf("newClusterState failed: %v", err)
}
// Master node
if got := state.slots[0].nodes[0].Client.Options().Addr; got != "10.10.10.10:7001" {
t.Errorf("Master Addr = %q, want %q", got, "10.10.10.10:7001")
}
if got := state.slots[0].nodes[0].Client.NodeAddress(); got != "127.0.0.1:7001" {
t.Errorf("Master NodeAddress = %q, want %q", got, "127.0.0.1:7001")
}
// Replica node
if got := state.slots[0].nodes[1].Client.Options().Addr; got != "10.10.10.10:7004" {
t.Errorf("Replica Addr = %q, want %q", got, "10.10.10.10:7004")
}
if got := state.slots[0].nodes[1].Client.NodeAddress(); got != "127.0.0.1:7004" {
t.Errorf("Replica NodeAddress = %q, want %q", got, "127.0.0.1:7004")
}
})
t.Run("GetOrCreate without node address defaults to Addr", func(t *testing.T) {
opt := &ClusterOptions{}
opt.init()
nodes := newClusterNodes(opt)
defer nodes.Close()
// GetOrCreate without node address (e.g., from MOVED/ASK error)
node, err := nodes.GetOrCreate("10.10.10.10:7001")
if err != nil {
t.Fatalf("GetOrCreate failed: %v", err)
}
// NodeAddress should default to Addr when not provided
if got := node.Client.NodeAddress(); got != "10.10.10.10:7001" {
t.Errorf("NodeAddress = %q, want %q", got, "10.10.10.10:7001")
}
// Addr should be set correctly
if got := node.Client.Options().Addr; got != "10.10.10.10:7001" {
t.Errorf("Addr = %q, want %q", got, "10.10.10.10:7001")
}
})
t.Run("GetOrCreateWithNodeAddress sets node address", func(t *testing.T) {
opt := &ClusterOptions{}
opt.init()
nodes := newClusterNodes(opt)
defer nodes.Close()
// GetOrCreateWithNodeAddress with node address
node, err := nodes.GetOrCreateWithNodeAddress("10.10.10.10:7001", "127.0.0.1:7001")
if err != nil {
t.Fatalf("GetOrCreateWithNodeAddress failed: %v", err)
}
// NodeAddress should be set
if got := node.Client.NodeAddress(); got != "127.0.0.1:7001" {
t.Errorf("NodeAddress = %q, want %q", got, "127.0.0.1:7001")
}
// Addr should be the transformed address
if got := node.Client.Options().Addr; got != "10.10.10.10:7001" {
t.Errorf("Addr = %q, want %q", got, "10.10.10.10:7001")
}
})
}
// TestNewClusterState_PortZero verifies that nodes reported by CLUSTER SLOTS
// with a port of 0 — the topology produced by TLS-only Redis clusters started
// with `--port 0 --tls-port 6379` — get their dial address normalized so they
// do not surface to the dial path as ":0".
//
// Regression test for https://github.com/redis/go-redis/issues/3726
// PubSub in v9.18.0 fails in TLS-only clusters because newClusterState stores
// the verbatim ":0" port into clusterNode.Client.opt.Addr and the PubSub path
// dials that address directly with no MOVED/seed fallback, producing
// `dial tcp <ip>:0: connection refused`.
//
// The fix is expected to use the origin endpoint's port as a fallback whenever
// CLUSTER SLOTS reports port 0 — the origin is by definition reachable, since
// that is the address we used to obtain the slot map in the first place.
func TestNewClusterState_PortZero(t *testing.T) {
t.Run("non-loopback origin replaces :0 port with origin port", func(t *testing.T) {
opt := &ClusterOptions{}
opt.init()
nodes := newClusterNodes(opt)
defer nodes.Close()
// Simulates a TLS-only cluster: CLUSTER SLOTS reports port 0 because
// the server is configured with `--port 0 --tls-port 6379`.
slots := []ClusterSlot{{
Start: 0,
End: 5460,
Nodes: []ClusterNode{{Addr: "172.30.0.10:0"}},
}, {
Start: 5461,
End: 10922,
Nodes: []ClusterNode{{Addr: "172.30.0.11:0"}},
}}
// Origin is the TLS configuration endpoint that returned this slot map.
state, err := newClusterState(nodes, slots, "172.30.0.10:6379")
if err != nil {
t.Fatalf("newClusterState failed: %v", err)
}
for i, want := range []string{"172.30.0.10:6379", "172.30.0.11:6379"} {
got := state.slots[i].nodes[0].Client.Options().Addr
if _, port, _ := net.SplitHostPort(got); port == "0" {
t.Fatalf("slot %d Addr = %q: port 0 reached the dial path "+
"(see #3726: PubSub dials this verbatim and fails with "+
"`connection refused`)", i, got)
}
if got != want {
t.Errorf("slot %d Addr = %q, want %q (origin port should be "+
"used as fallback for :0 nodes)", i, got, want)
}
// NodeAddress documents the raw server-reported endpoint
// (see options.go: "the exact endpoint string returned by
// CLUSTER SLOTS before any resolution or transformation").
// The port-0 fix is a transformation, so NodeAddress keeps
// the original ":0" value used by maintenance-notification
// matching. If the design changes this, update this assertion.
wantNodeAddr := []string{"172.30.0.10:0", "172.30.0.11:0"}[i]
if got := state.slots[i].nodes[0].Client.NodeAddress(); got != wantNodeAddr {
t.Errorf("slot %d NodeAddress = %q, want %q (raw server value)",
i, got, wantNodeAddr)
}
}
})
t.Run("non-zero ports are unchanged", func(t *testing.T) {
// Guards against the fix over-rewriting ports for normal clusters.
opt := &ClusterOptions{}
opt.init()
nodes := newClusterNodes(opt)
defer nodes.Close()
slots := []ClusterSlot{{
Nodes: []ClusterNode{{Addr: "172.30.0.10:6380"}},
}}
state, err := newClusterState(nodes, slots, "172.30.0.10:6379")
if err != nil {
t.Fatalf("newClusterState failed: %v", err)
}
if got := state.slots[0].nodes[0].Client.Options().Addr; got != "172.30.0.10:6380" {
t.Errorf("Addr = %q, want %q (non-zero ports must be preserved)",
got, "172.30.0.10:6380")
}
})
t.Run("loopback :0 follows loopback substitution and uses origin port", func(t *testing.T) {
// Combines the two transformations newClusterState already does
// (loopback substitution + the new port-0 fallback) and asserts
// the order is sane: replace the loopback host with the origin
// host, then replace the :0 port with the origin port.
opt := &ClusterOptions{}
opt.init()
nodes := newClusterNodes(opt)
defer nodes.Close()
slots := []ClusterSlot{{
Nodes: []ClusterNode{{Addr: "127.0.0.1:0"}},
}}
state, err := newClusterState(nodes, slots, "172.30.0.10:6379")
if err != nil {
t.Fatalf("newClusterState failed: %v", err)
}
if got := state.slots[0].nodes[0].Client.Options().Addr; got != "172.30.0.10:6379" {
t.Errorf("Addr = %q, want %q (loopback host + :0 port should both "+
"be replaced from the origin)", got, "172.30.0.10:6379")
}
})
}
// With `cluster-preferred-endpoint-type unknown-endpoint` the server sends a
// null host in CLUSTER SLOTS, and the client is expected to reuse the host it
// connected to.
func TestNewClusterState_NullHost(t *testing.T) {
for _, null := range []string{"_\r\n", "$-1\r\n"} {
cmd := NewClusterSlotsCmd(context.Background())
reply := "*1\r\n*3\r\n:0\r\n:5460\r\n*2\r\n" + null + ":6380\r\n"
if err := cmd.readReply(proto.NewReader(strings.NewReader(reply))); err != nil {
t.Fatalf("reply %q: readReply() = %v", null, err)
}
opt := &ClusterOptions{}
opt.init()
nodes := newClusterNodes(opt)
defer nodes.Close()
state, err := newClusterState(nodes, cmd.Val(), "172.30.0.10:6379")
if err != nil {
t.Fatalf("newClusterState failed: %v", err)
}
client := state.slots[0].nodes[0].Client
if got := client.Options().Addr; got != "172.30.0.10:6380" {
t.Errorf("reply %q: Addr = %q, want %q", null, got, "172.30.0.10:6380")
}
if got := client.NodeAddress(); got != ":6380" {
t.Errorf("reply %q: NodeAddress = %q, want %q", null, got, ":6380")
}
}
}
type fixedHash string
func (h fixedHash) Get(string) string {
return string(h)
}
func TestRingSetAddrsAndRebalanceRace(t *testing.T) {
const (
ringShard1Name = "ringShardOne"
ringShard2Name = "ringShardTwo"
ringShard1Port = "6390"
ringShard2Port = "6391"
)
ring := NewRing(&RingOptions{
Addrs: map[string]string{
ringShard1Name: ":" + ringShard1Port,
},
// Disable heartbeat
HeartbeatFrequency: 1 * time.Hour,
NewConsistentHash: func(shards []string) ConsistentHash {
switch len(shards) {
case 1:
return fixedHash(ringShard1Name)
case 2:
return fixedHash(ringShard2Name)
default:
t.Fatalf("Unexpected number of shards: %v", shards)
return nil
}
},
})
defer ring.Close()
// Continuously update addresses by adding and removing one address
updatesDone := make(chan struct{})
defer func() { close(updatesDone) }()
go func() {
for i := 0; ; i++ {
select {
case <-updatesDone:
return
default:
if i%2 == 0 {
ring.SetAddrs(map[string]string{
ringShard1Name: ":" + ringShard1Port,
})
} else {
ring.SetAddrs(map[string]string{
ringShard1Name: ":" + ringShard1Port,
ringShard2Name: ":" + ringShard2Port,
})
}
}
}
}()
timer := time.NewTimer(1 * time.Second)
for running := true; running; {
select {
case <-timer.C:
running = false
default:
shard, err := ring.sharding.GetByKey("whatever")
if err == nil && shard == nil {
t.Fatal("shard is nil")
}
}
}
}
func BenchmarkRingShardingRebalanceLocked(b *testing.B) {
opts := &RingOptions{
Addrs: make(map[string]string),
// Disable heartbeat
HeartbeatFrequency: 1 * time.Hour,
}
for i := 0; i < 100; i++ {
opts.Addrs[fmt.Sprintf("shard%d", i)] = fmt.Sprintf(":63%02d", i)
}
ring := NewRing(opts)
defer ring.Close()
b.ResetTimer()
for i := 0; i < b.N; i++ {
ring.sharding.rebalanceLocked()
}
}
// CSC command-processing and metadata benchmarks.
// Keep results observable without allocating a new command in each iteration.
var (
cscBenchmarkString string
cscBenchmarkBool bool
cscBenchmarkError error
cscBenchmarkMeta cscCommandMeta
cscBenchmarkView *commandMetadataView
cscBenchmarkCount int
)
func BenchmarkCSCCommandError(b *testing.B) {
var active atomic.Bool
active.Store(true)
c := &baseClient{opt: &Options{Protocol: 3}, cscActive: &active}
for _, tc := range []struct {
name string
arg interface{}
}{
{"lowercase", "get"},
{"uppercase", "GET"},
{"bytes", []byte("GET")},
} {
b.Run(tc.name, func(b *testing.B) {
cmd := NewCmd(context.Background(), tc.arg, "key")
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
cscBenchmarkError = c.cscCommandError(cmd)
}
})
}
}
func BenchmarkCSCEligibility(b *testing.B) {
view := (&baseClient{}).metadataView()
for _, args := range [][]interface{}{
{"get", "key"},
{"hget", "key", "field"},
{"xinfo", "consumers", "key", "group"},
} {
b.Run(args[0].(string), func(b *testing.B) {
cmd := NewCmd(context.Background(), args...)
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
cscBenchmarkMeta, cscBenchmarkBool = cscEligibleMeta(view, cmd)
}
})
}
}
func BenchmarkBuildCacheKey(b *testing.B) {
cmd := NewStringCmd(context.Background(), "get", "key")
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
cscBenchmarkString, cscBenchmarkBool = buildCacheKeyReference(cmd)
}
}
func BenchmarkCSCEntryKey(b *testing.B) {
cmd := NewStringCmd(context.Background(), "get", "key")
prefix := cscNamespacePrefix(0, "")
fingerprint := (&baseClient{}).metadataView().cscFingerprint
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
cscBenchmarkString, cscBenchmarkBool = cscRenderEntryKey(prefix, fingerprint, cmd)
}
}
func BenchmarkCommandArgsRepeatable(b *testing.B) {
for _, size := range []int{2, 10} {
b.Run(fmt.Sprint(size), func(b *testing.B) {
args := make([]interface{}, size)
for i := range args {
args[i] = "key"
}
cmd := NewCmd(context.Background(), args...)
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
cscBenchmarkBool = commandArgsRepeatable(cmd)
}
})
}
}
func BenchmarkCSCKeynumLayout(b *testing.B) {
for _, tc := range []struct {
name string
arg interface{}
}{
{"int", 2},
{"string", "2"},
{"bytes", []byte("2")},
} {
b.Run(tc.name, func(b *testing.B) {
cmd := NewCmd(context.Background(), "zunion", tc.arg, "key1", "key2")
meta, ok := cscEligibleMeta((&baseClient{}).metadataView(), cmd)
if !ok {
b.Fatal("ZUNION must be eligible")
}
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, _, cscBenchmarkCount = cscRedisKeyLayout(meta, cmd)
}
})
}
}
func BenchmarkCSCCanExtractRedisKeys(b *testing.B) {
cmd := NewCmd(context.Background(), "mget", []byte("key"), 42)
meta, ok := cscEligibleMeta((&baseClient{}).metadataView(), cmd)
if !ok {
b.Fatal("MGET must be eligible")
}
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
cscBenchmarkBool = cscCanExtractRedisKeys(meta, cmd)
}
}
type cscBenchmarkMissPool struct{ pool.Pooler }
func (cscBenchmarkMissPool) Get(context.Context) (*pool.Conn, error) {
return nil, pool.ErrClosed
}
func BenchmarkCSCProcessCachedHit(b *testing.B) { benchmarkCSCProcessCached(b, true) }
func BenchmarkCSCProcessCachedMiss(b *testing.B) { benchmarkCSCProcessCached(b, false) }
func benchmarkCSCProcessCached(b *testing.B, hit bool) {
ctx := context.Background()
cache := NewLocalCache(CacheConfig{MaxEntries: 16})
c := &baseClient{
opt: &Options{Protocol: 3},
csc: cache,
cscKeyPrefix: cscNamespacePrefix(0, ""),
connPool: cscBenchmarkMissPool{},
staticCmdMeta: defaultCommandMetadataView(),
}
cmd := NewStringCmd(ctx, "get", "key")
view := c.metadataView()
meta, ok := cscEligibleMeta(view, cmd)
if !ok {
b.Fatal("GET must be eligible")
}
if hit {
rawKey, _ := buildCacheKeyReference(cmd)
key := cscEntryKey(c.cscKeyPrefix, view.cscFingerprint, rawKey)
if !cache.set(key, []string{cscNamespacedKey(c.cscKeyPrefix, "key")}, []byte("$5\r\nvalue\r\n")) {
b.Fatal("failed to seed cache")
}
}
// Misses stop at pool acquisition and cancel their reservation. This
// measures the cache lifecycle without a Redis server, dial, or unbounded
// fixture growth; it does not measure a successful network fetch.
var wantErr error
if !hit {
wantErr = pool.ErrClosed
}
if err := c.processCached(ctx, cmd, nil, view, meta, 0); err != wantErr {
b.Fatalf("processCached: got %v, want %v", err, wantErr)
}
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
cscBenchmarkError = c.processCached(ctx, cmd, nil, view, meta, 0)
}
b.StopTimer()
if !hit && cache.Len() != 0 {
b.Fatal("miss reservation leaked")
}
}
func BenchmarkBuildDefaultView(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
cscBenchmarkView = buildCommandMetadataView(nil, nil)
}
}
func BenchmarkTableFingerprint(b *testing.B) {
table := (&baseClient{}).metadataView().cscTable
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
cscBenchmarkString = cscTableFingerprint(table)
}
}
type testCounter struct {
mu sync.Mutex
t *testing.T
m map[string]int
}
func newTestCounter(t *testing.T) *testCounter {
return &testCounter{t: t, m: make(map[string]int)}
}
func (ct *testCounter) increment(key string) {
ct.mu.Lock()
defer ct.mu.Unlock()
ct.m[key]++
}
func (ct *testCounter) expect(values map[string]int) {
ct.mu.Lock()
defer ct.mu.Unlock()
ct.t.Helper()
if !reflect.DeepEqual(values, ct.m) {
ct.t.Errorf("expected %v != actual %v", values, ct.m)
}
}
// testOnCloseHookID is the id used by the ring-shard cleanup tests when
// registering a close hook against the internal onCloseHooks registry.
const testOnCloseHookID = "test-close-counter"
func TestRingShardsCleanup(t *testing.T) {
const (
ringShard1Name = "ringShardOne"
ringShard2Name = "ringShardTwo"
ringShard1Addr = "shard1.test"
ringShard2Addr = "shard2.test"
)
t.Run("closes unused shards", func(t *testing.T) {
closeCounter := newTestCounter(t)
ring := NewRing(&RingOptions{
Addrs: map[string]string{
ringShard1Name: ringShard1Addr,
ringShard2Name: ringShard2Addr,
},
NewClient: func(opt *Options) *Client {
c := NewClient(opt)
c.baseClient.onClose.register(testOnCloseHookID, func() error {
closeCounter.increment(opt.Addr)
return nil
})
return c
},
})
closeCounter.expect(map[string]int{})
// no change due to the same addresses
ring.SetAddrs(map[string]string{
ringShard1Name: ringShard1Addr,
ringShard2Name: ringShard2Addr,
})
closeCounter.expect(map[string]int{})
ring.SetAddrs(map[string]string{
ringShard1Name: ringShard1Addr,
})
closeCounter.expect(map[string]int{ringShard2Addr: 1})
ring.SetAddrs(map[string]string{
ringShard2Name: ringShard2Addr,
})
closeCounter.expect(map[string]int{ringShard1Addr: 1, ringShard2Addr: 1})
ring.Close()
closeCounter.expect(map[string]int{ringShard1Addr: 1, ringShard2Addr: 2})
})
t.Run("closes created shards if ring was closed", func(t *testing.T) {
createCounter := newTestCounter(t)
closeCounter := newTestCounter(t)
var (
ring *Ring
shouldClose atomic.Int32
)
ring = NewRing(&RingOptions{
Addrs: map[string]string{
ringShard1Name: ringShard1Addr,
},
NewClient: func(opt *Options) *Client {
if shouldClose.Load() != 0 {
ring.Close()
}
createCounter.increment(opt.Addr)
c := NewClient(opt)
c.baseClient.onClose.register(testOnCloseHookID, func() error {
closeCounter.increment(opt.Addr)
return nil
})
return c
},
})
createCounter.expect(map[string]int{ringShard1Addr: 1})
closeCounter.expect(map[string]int{})
shouldClose.Store(1)
ring.SetAddrs(map[string]string{
ringShard2Name: ringShard2Addr,
})
createCounter.expect(map[string]int{ringShard1Addr: 1, ringShard2Addr: 1})
closeCounter.expect(map[string]int{ringShard1Addr: 1, ringShard2Addr: 1})
})
}
//------------------------------------------------------------------------------
type timeoutErr struct {
error
}
func (e timeoutErr) Timeout() bool {
return true
}
func (e timeoutErr) Temporary() bool {
return true
}
func (e timeoutErr) Error() string {
return "i/o timeout"
}
// NonRedisEnterprise: exercises connection-pool replacement internals against a
// local Redis and requires a localhost server; not meaningful against a remote
// Enterprise cluster.
var _ = Describe("withConn", Label("NonRedisEnterprise"), func() {
var client *Client
BeforeEach(func() {
client = NewClient(&Options{
PoolSize: 1,
})
})
AfterEach(func() {
client.Close()
})
It("should replace the connection in the pool when there is no error", func() {
var conn *pool.Conn
client.withConn(ctx, func(ctx context.Context, c *pool.Conn) error {
conn = c
return nil
})
newConn, err := client.connPool.Get(ctx)
Expect(err).To(BeNil())
Expect(newConn).To(Equal(conn))
})
It("should replace the connection in the pool when there is an error not related to a bad connection", func() {
var conn *pool.Conn
client.withConn(ctx, func(ctx context.Context, c *pool.Conn) error {
conn = c
return proto.RedisError("LOADING")
})
newConn, err := client.connPool.Get(ctx)
Expect(err).To(BeNil())
Expect(newConn).To(Equal(conn))
})
It("should remove the connection from the pool when it times out", func() {
var conn *pool.Conn
client.withConn(ctx, func(ctx context.Context, c *pool.Conn) error {
conn = c
return timeoutErr{}
})
newConn, err := client.connPool.Get(ctx)
Expect(err).To(BeNil())
Expect(newConn).NotTo(Equal(conn))
Expect(client.connPool.Len()).To(Equal(1))
})
})
var _ = Describe("ClusterClient", func() {
var client *ClusterClient
BeforeEach(func() {
client = &ClusterClient{}
})
Describe("cmdSlot", func() {
It("select slot from args for GETKEYSINSLOT command", func() {
cmd := NewStringSliceCmd(ctx, "cluster", "getkeysinslot", 100, 200)
slot := client.cmdSlot(cmd, -1)
Expect(slot).To(Equal(100))
})
It("select slot from args for COUNTKEYSINSLOT command", func() {
cmd := NewStringSliceCmd(ctx, "cluster", "countkeysinslot", 100)
slot := client.cmdSlot(cmd, -1)
Expect(slot).To(Equal(100))
})
It("follows preferred random slot", func() {
cmd := NewStatusCmd(ctx, "ping")
slot := client.cmdSlot(cmd, 101)
Expect(slot).To(Equal(101))
})
})
})
var _ = Describe("isLoopback", func() {
DescribeTable(
"should correctly identify loopback addresses",
func(host string, expected bool) {
result := isLoopback(host)
Expect(result).To(Equal(expected))
},
// IP addresses
Entry("IPv4 loopback", "127.0.0.1", true),
Entry("IPv6 loopback", "::1", true),
Entry("IPv4 non-loopback", "192.168.1.1", false),
Entry("IPv6 non-loopback", "2001:db8::1", false),
// Well-known loopback hostnames
Entry("localhost lowercase", "localhost", true),
Entry("localhost uppercase", "LOCALHOST", true),
Entry("localhost mixed case", "LocalHost", true),
// Docker-specific loopbacks
Entry("host.docker.internal", "host.docker.internal", true),
Entry("HOST.DOCKER.INTERNAL", "HOST.DOCKER.INTERNAL", true),
Entry("custom.docker.internal", "custom.docker.internal", true),
Entry("app.docker.internal", "app.docker.internal", true),
// Non-loopback hostnames
Entry("redis hostname", "redis-cluster", false),
Entry("FQDN", "redis.example.com", false),
Entry("docker but not internal", "redis.docker.com", false),
// Edge cases
Entry("empty string", "", false),
Entry("invalid IP", "256.256.256.256", false),
Entry("partial docker internal", "docker.internal", false),
)
})
// TestOnCloseHooks_RunInReverseRegistrationOrder verifies that hooks are invoked in
// REVERSE registration order (LIFO): a hook registered later is a consumer of state an
// earlier registration provides, so it must run first. Concretely, an autopipeliner
// drain (registered lazily) must run before the Sentinel failover teardown (registered
// at construction) so the drain can still resolve the master address. See onCloseHooks.run.
func TestOnCloseHooks_RunInReverseRegistrationOrder(t *testing.T) {
h := &onCloseHooks{}
var calls []string
h.register("a", func() error { calls = append(calls, "a"); return nil })
h.register("b", func() error { calls = append(calls, "b"); return nil })
h.register("c", func() error { calls = append(calls, "c"); return nil })
if err := h.run(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
want := []string{"c", "b", "a"}
if !reflect.DeepEqual(calls, want) {
t.Fatalf("run order = %v, want %v (LIFO)", calls, want)
}
}
// TestOnCloseHooks_RegisterSameIDReplaces is the regression test for issue
// #3772. Registering the same id repeatedly must replace the existing
// callback rather than chain onto it, so the registry stays bounded even
// under storm-like re-registration (the exact scenario that previously leaked
// when initConn re-wrapped c.onClose on every connection init).
func TestOnCloseHooks_RegisterSameIDReplaces(t *testing.T) {
h := &onCloseHooks{}
const id = "same-id"
const iterations = 10_000
var lastSeen atomic.Int32
for i := 0; i < iterations; i++ {
i := int32(i)
h.register(id, func() error { lastSeen.Store(i); return nil })
}
if got := len(h.order); got != 1 {
t.Fatalf("order length after %d re-registrations = %d, want 1", iterations, got)
}
if got := len(h.hooks); got != 1 {
t.Fatalf("hooks map size after %d re-registrations = %d, want 1", iterations, got)
}
if err := h.run(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got := lastSeen.Load(); got != iterations-1 {
t.Fatalf("last-registered callback not invoked: lastSeen = %d, want %d", got, iterations-1)
}
}
// TestOnCloseHooks_RegisterAfterRunIsRejected pins the contract the cluster FD
// router's per-node evict hook relies on: once run has taken its snapshot the
// owner is closing, and a callback registered from then on would never fire —
// so register must report false and store nothing, instead of silently
// accepting a hook that leaks whatever it was meant to clean up (cursor bugbot
// on #4002: a node client closing between a child's publication and its evict
// hook's registration left the child in the router map for good).
func TestOnCloseHooks_RegisterAfterRunIsRejected(t *testing.T) {
h := &onCloseHooks{}
var early, late atomic.Int32
if !h.register("early", func() error { early.Add(1); return nil }) {
t.Fatal("register before run must report true")
}
if err := h.run(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got := early.Load(); got != 1 {
t.Fatalf("early hook invoked %d times, want 1", got)
}
if h.register("late", func() error { late.Add(1); return nil }) {
t.Fatal("register after run must report false: the owner is closing and the hook would never fire")
}
if _, stored := h.hooks["late"]; stored {
t.Fatal("a rejected registration must not be stored")
}
// A second run (Close is idempotent at the owner level) must not surface it.
if err := h.run(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got := late.Load(); got != 0 {
t.Fatalf("late hook invoked %d times, want 0", got)
}
}
// TestOnCloseHooks_DistinctIDsCoexist guarantees the dedup behavior does not
// discard hooks from other callers: registering new ids must never drop
// previously registered ids.
func TestOnCloseHooks_DistinctIDsCoexist(t *testing.T) {
h := &onCloseHooks{}
var aCount, bCount atomic.Int32
h.register("a", func() error { aCount.Add(1); return nil })
h.register("b", func() error { bCount.Add(1); return nil })
// Re-registering "a" must not drop "b".
h.register("a", func() error { aCount.Add(1); return nil })
if err := h.run(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if a, b := aCount.Load(), bCount.Load(); a != 1 || b != 1 {
t.Fatalf("call counts a=%d b=%d, want a=1 b=1", a, b)
}
}
// TestOnCloseHooks_Unregister verifies that unregister removes a hook and
// that running after unregister does not invoke it.
func TestOnCloseHooks_Unregister(t *testing.T) {
h := &onCloseHooks{}
var aCalled, bCalled bool
h.register("a", func() error { aCalled = true; return nil })
h.register("b", func() error { bCalled = true; return nil })
h.unregister("a")
h.unregister("missing") // no-op must not panic
if err := h.run(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if aCalled {
t.Fatal("unregistered hook was invoked")
}
if !bCalled {
t.Fatal("remaining hook was not invoked")
}
if got := len(h.order); got != 1 {
t.Fatalf("order length = %d, want 1", got)
}
}
// TestOnCloseHooks_AllRunOnError confirms every hook is invoked even if an
// earlier one returns an error, and that the first error in INVOCATION order (LIFO) is
// returned. "b" runs before "a" under LIFO, so its error is the first non-nil one.
func TestOnCloseHooks_AllRunOnError(t *testing.T) {
h := &onCloseHooks{}
var called [3]bool
errFirst := fmt.Errorf("first")
errSecond := fmt.Errorf("second")
h.register("a", func() error { called[0] = true; return errFirst })
h.register("b", func() error { called[1] = true; return errSecond })
h.register("c", func() error { called[2] = true; return nil })
// Invocation order is c, b, a (LIFO); the first non-nil error encountered is errSecond.
err := h.run()
if err != errSecond {
t.Fatalf("run() err = %v, want %v", err, errSecond)
}
for i, c := range called {
if !c {
t.Fatalf("hook %d was not invoked", i)
}
}
}
// TestOnCloseHooks_NilReceiver ensures run() on a nil registry is a safe
// no-op. baseClient embedded in Conn/Tx does initialize the registry, but
// defensive nil-safety lets future constructors add the field without
// breaking Close().
func TestOnCloseHooks_NilReceiver(t *testing.T) {
var h *onCloseHooks
if err := h.run(); err != nil {
t.Fatalf("run() on nil = %v, want nil", err)
}
}
// TestOnCloseHooks_ConcurrentRegisterSameID hammers the registry with many
// goroutines re-registering under the same id. The registry must remain
// bounded and the surviving callback must still be invoked exactly once.
func TestOnCloseHooks_ConcurrentRegisterSameID(t *testing.T) {
h := &onCloseHooks{}
const id = "hot"
const goroutines = 64
const perG = 1_000
var wg sync.WaitGroup
wg.Add(goroutines)
for g := 0; g < goroutines; g++ {
go func() {
defer wg.Done()
for i := 0; i < perG; i++ {
h.register(id, func() error { return nil })
}
}()
}
wg.Wait()
if got := len(h.order); got != 1 {
t.Fatalf("order length after concurrent storm = %d, want 1", got)
}
}
// entraidLikeProvider mimics the exact semantics of
// github.com/redis/go-redis-entraid's StreamingCredentialsProvider relevant
// to issue #3772: it deduplicates subscriptions by listener pointer
// identity, and every call to Subscribe returns a FRESH UnsubscribeFunc
// closure that removes the listener by pointer match from the shared
// listeners slice. Two unsubs obtained for the same listener are therefore
// equivalent: the first one called removes the entry, any subsequent call
// is a safe no-op.
type entraidLikeProvider struct {
mu sync.Mutex
listeners []auth.CredentialsListener
subscribeN atomic.Int32
unsubCalls atomic.Int32
}
func (p *entraidLikeProvider) Subscribe(listener auth.CredentialsListener) (auth.Credentials, auth.UnsubscribeFunc, error) {
p.subscribeN.Add(1)
p.mu.Lock()
already := false
for _, l := range p.listeners {
if l == listener {
already = true
break
}
}
if !already {
p.listeners = append(p.listeners, listener)
}
p.mu.Unlock()
unsub := func() error {
p.unsubCalls.Add(1)
p.mu.Lock()
defer p.mu.Unlock()
for i, l := range p.listeners {
if l == listener {
p.listeners = append(p.listeners[:i], p.listeners[i+1:]...)
return nil
}
}
return nil
}
return auth.NewBasicCredentials("u", "p"), unsub, nil
}
func (p *entraidLikeProvider) listenerCount() int {
p.mu.Lock()
defer p.mu.Unlock()
return len(p.listeners)
}
// stubCredentialsListener is a minimal auth.CredentialsListener used only
// for pointer-identity in the entraid-mimicking test.
type stubCredentialsListener struct{}
func (*stubCredentialsListener) OnNext(auth.Credentials) {}
func (*stubCredentialsListener) OnError(error) {}
// TestInitConn_EntraidLike_NoLeakAcrossReinits is the targeted regression
// test for issue #3772 against the real-world StreamingCredentialsProvider
// behavior implemented by go-redis-entraid. It simulates N re-initializations
// on the same logical connection (i.e. the same CredentialsListener pointer,
// which is what streaming.Manager.Listener returns from its per-connId cache),
// replacing cn.SetOnClose with each new unsubscribe closure.
//
// Invariants the fix must uphold:
// 1. Subscribe dedups: the provider's listener list stays at size 1.
// 2. Calling only the MOST RECENT unsub fully removes the listener.
// 3. All prior (orphaned) unsubs are safe no-ops after that.
// 4. No registration remains on the provider after close.
func TestInitConn_EntraidLike_NoLeakAcrossReinits(t *testing.T) {
const reinits = 1000
provider := &entraidLikeProvider{}
listener := &stubCredentialsListener{}
var latestUnsub auth.UnsubscribeFunc
orphaned := make([]auth.UnsubscribeFunc, 0, reinits-1)
for i := 0; i < reinits; i++ {
_, unsub, err := provider.Subscribe(listener)
if err != nil {
t.Fatalf("Subscribe #%d: %v", i, err)
}
if latestUnsub != nil {
// Mirror the pool.Conn.SetOnClose behavior: the previous unsub
// is dropped on the floor, only the latest one is retained.
orphaned = append(orphaned, latestUnsub)
}
latestUnsub = unsub
}
if got := provider.listenerCount(); got != 1 {
t.Fatalf("after %d Subscribes with same listener, listener count = %d, want 1", reinits, got)
}
if got := provider.subscribeN.Load(); got != int32(reinits) {
t.Fatalf("Subscribe call count = %d, want %d", got, reinits)
}
// Only the latest unsub is invoked, matching the post-fix cn.onClose.
if err := latestUnsub(); err != nil {
t.Fatalf("latest unsub returned error: %v", err)
}
if got := provider.listenerCount(); got != 0 {
t.Fatalf("listener count after latest unsub = %d, want 0", got)
}
// Every orphaned unsub must be a safe no-op (contract in auth.UnsubscribeFunc).
for i, u := range orphaned {
if err := u(); err != nil {
t.Fatalf("orphaned unsub #%d returned error: %v", i, err)
}
}
if got := provider.listenerCount(); got != 0 {
t.Fatalf("listener count after orphaned unsubs = %d, want 0", got)
}
}
// TestPubSubConn_PassesPersistedChannelsToNewConn is a regression test for
// https://github.com/redis/go-redis/issues/3806.
//
// ClusterClient.pubSub() installs a newConn closure that picks the target
// node by hash slot of channels[0]. Before the fix, PubSub.conn() only
// passed c.channels (regular SUBSCRIBE) into that closure — sharded
// SSubscribe channels stored in c.schannels were silently dropped.
//
// For an SSubscribe-only PubSub (the common ClusterClient.SSubscribe case)
// the channel list was empty on reconnect, so the closure fell through to
// nodes.Random(). The reconnected SSUBSCRIBE went to the wrong shard, the
// MOVED reply was never read, and the PubSub looked healthy while receiving
// no messages.
//
// This test exercises PubSub.conn() with a stub newConn that records the
// channel list it receives. The cluster routing layer is not under test
// here — the regression is whether the channel list reaches the closure.
func TestPubSubConn_PassesPersistedChannelsToNewConn(t *testing.T) {
const (
regularCh = "regular-ch"
shardCh = "{shardA}-ch1"
freshCh = "fresh-ch"
)
tests := []struct {
name string
channels map[string]struct{}
schannels map[string]struct{}
newChannels []string
wantIn []string // strings that MUST be present in the captured slice
}{
{
name: "subscribe-only reconnect: existing channels forwarded",
channels: map[string]struct{}{regularCh: {}},
wantIn: []string{regularCh},
},
{
// The bug from #3806: SSubscribe-only reconnect dropped
// c.schannels and reached newConn with an empty list.
name: "ssubscribe-only reconnect: schannels forwarded (#3806)",
schannels: map[string]struct{}{shardCh: {}},
wantIn: []string{shardCh},
},
{
name: "initial ssubscribe: newChannels forwarded",
newChannels: []string{shardCh},
wantIn: []string{shardCh},
},
{
name: "mixed subscribe + ssubscribe: both kinds forwarded",
channels: map[string]struct{}{regularCh: {}},
schannels: map[string]struct{}{shardCh: {}},
wantIn: []string{regularCh, shardCh},
},
{
name: "newChannels appended after persisted ones",
schannels: map[string]struct{}{shardCh: {}},
newChannels: []string{freshCh},
wantIn: []string{shardCh, freshCh},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var captured []string
stubErr := errors.New("stub: newConn does not create a real conn")
ps := &PubSub{
opt: &Options{Addr: "stub:6379"},
channels: tt.channels,
schannels: tt.schannels,
newConn: func(_ context.Context, _ string, channels []string) (*pool.Conn, error) {
captured = append([]string(nil), channels...)
return nil, stubErr
},
closeConn: func(*pool.Conn) error { return nil },
}
ps.mu.Lock()
_, err := ps.conn(context.Background(), tt.newChannels)
ps.mu.Unlock()
if !errors.Is(err, stubErr) {
t.Fatalf("conn err = %v, want stub", err)
}
for _, want := range tt.wantIn {
found := false
for _, got := range captured {
if got == want {
found = true
break
}
}
if !found {
t.Errorf("missing %q in channels passed to newConn (got %v); see #3806",
want, captured)
}
}
})
}
}
// These benchmarks measure cluster routing without a Redis server. A custom
// topology prevents discovery I/O, and each iteration creates fresh commands.
func newClusterRoutingBenchmarkClient(b *testing.B) (*ClusterClient, []string) {
b.Helper()
slots := []ClusterSlot{
{Start: 0, End: 5460, Nodes: []ClusterNode{{Addr: "127.0.0.1:1"}, {Addr: "127.0.0.1:4"}}},
{Start: 5461, End: 10922, Nodes: []ClusterNode{{Addr: "127.0.0.1:2"}, {Addr: "127.0.0.1:5"}}},
{Start: 10923, End: 16383, Nodes: []ClusterNode{{Addr: "127.0.0.1:3"}, {Addr: "127.0.0.1:6"}}},
}
c := NewClusterClient(&ClusterOptions{
ClusterSlots: func(context.Context) ([]ClusterSlot, error) { return slots, nil },
})
b.Cleanup(func() { _ = c.Close() })
if _, err := c.state.Get(context.Background()); err != nil {
b.Fatal(err)
}
keys := make([]string, 1024)
for i := range keys {
keys[i] = fmt.Sprintf("routing:key:%d", i)
}
return c, keys
}
func BenchmarkClusterRoutingGet(b *testing.B) {
c, keys := newClusterRoutingBenchmarkClient(b)
ctx := context.Background()
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
cmd := NewStringCmd(ctx, "get", keys[i%len(keys)])
decision := c.commandRoutingDecision(ctx, cmd)
if decision.policyErr != nil {
b.Fatal(decision.policyErr)
}
slot := c.cmdSlotWithDecision(cmd, &decision, -1)
if _, err := c.cmdNodeWithDecision(ctx, slot, &decision); err != nil {
b.Fatal(err)
}
}
}
func BenchmarkClusterRoutingProcessGet(b *testing.B) {
c, keys := newClusterRoutingBenchmarkClient(b)
ctx := context.Background()
state, err := c.state.Get(ctx)
if err != nil {
b.Fatal(err)
}
for _, node := range state.Masters {
node.Client.AddHook(clusterRoutingShortCircuitHook{})
}
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
cmd := NewStringCmd(ctx, "get", keys[i%len(keys)])
if err := c.process(ctx, cmd); err != nil {
b.Fatal(err)
}
}
}
func BenchmarkClusterRoutingPipeline100(b *testing.B) {
c, keys := newClusterRoutingBenchmarkClient(b)
ctx := context.Background()
cmds := make([]Cmder, 100)
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
for j := range cmds {
cmds[j] = NewStringCmd(ctx, "get", keys[(i*len(cmds)+j)%len(keys)])
}
if err := c.mapCmdsByNode(ctx, newCmdsMap(), cmds); err != nil {
b.Fatal(err)
}
}
}
func BenchmarkClusterRoutingAutoPipelineAdmission(b *testing.B) {
c, keys := newClusterRoutingBenchmarkClient(b)
ap, err := c.AutoPipelineWithOptions(&AutoPipelineOptions{
MaxBatchSize: 512, MaxConcurrentBatches: 200, Unordered: true,
})
if err != nil {
b.Fatal(err)
}
b.Cleanup(func() { _ = ap.Close() })
ctx := context.Background()
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
cmd := NewStringCmd(ctx, "get", keys[i%len(keys)])
if divert, err := ap.routeCommand(ctx, cmd); divert || err != nil {
b.Fatalf("GET routing: divert=%v, err=%v", divert, err)
}
_ = ap.shardFn(cmd)
cmd.setClusterRoutingDecision(nil)
}
}
func BenchmarkRoutingKeyPlan(b *testing.B) {
for _, n := range []int{2, 16, 100} {
b.Run(fmt.Sprintf("Range%d", n), func(b *testing.B) {
args := make([]interface{}, n+1)
args[0] = "mget"
for i := 1; i < len(args); i++ {
args[i] = fmt.Sprintf("{k}%d", i)
}
cmd := NewCmd(context.Background(), args...)
meta := defaultCommandMetadataView().routingTable["mget"]
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
plan, ok := routingResolveKeyPlan(meta, cmd)
if !ok || len(plan.positions) != n {
b.Fatal(plan, ok)
}
}
})
}
b.Run("Overlapping", func(b *testing.B) {
meta := deriveRoutingCommandMeta("overlap", &CommandInfo{KeySpecs: []KeySpec{
{Flags: []string{"RO", "access"}, BeginSearch: "index", Index: 1, FindKeys: "range", LastKey: 1, KeyStep: 1},
{Flags: []string{"RO", "access"}, BeginSearch: "index", Index: 2, FindKeys: "range", LastKey: 1, KeyStep: 1},
}})
cmd := NewCmd(context.Background(), "overlap", "a", "b", "c")
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
plan, ok := routingResolveKeyPlan(meta, cmd)
if !ok || len(plan.positions) != 3 {
b.Fatal(plan, ok)
}
}
})
}
func BenchmarkClusterRoutingAutoPipelineMap100(b *testing.B) {
c, keys := newClusterRoutingBenchmarkClient(b)
ap, err := c.AutoPipelineWithOptions(&AutoPipelineOptions{MaxBatchSize: 512, MaxConcurrentBatches: 200, Unordered: true})
if err != nil {
b.Fatal(err)
}
b.Cleanup(func() { _ = ap.Close() })
ctx := context.Background()
cmds := make([]Cmder, 100)
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
for j := range cmds {
cmd := NewStringCmd(ctx, "get", keys[(i*len(cmds)+j)%len(keys)])
if divert, err := ap.routeCommand(ctx, cmd); divert || err != nil {
b.Fatalf("GET routing: divert=%v, err=%v", divert, err)
}
_ = ap.shardFn(cmd)
cmds[j] = cmd
}
if err := c.mapCmdsByNode(ctx, newCmdsMap(), cmds); err != nil {
b.Fatal(err)
}
}
}
func BenchmarkRoutingFirstKey(b *testing.B) {
for _, tc := range []struct {
name string
key interface{}
}{
{"String", "0123456789abcdef"}, {"Bytes", []byte("0123456789abcdef")}, {"Int", 1234567890},
} {
b.Run(tc.name, func(b *testing.B) {
c := &ClusterClient{}
view := defaultCommandMetadataView()
cmd := NewCmd(context.Background(), "get", tc.key)
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
d := c.routingDecisionInView(context.Background(), cmd, view, nil)
if d.policyErr != nil {
b.Fatal(d.policyErr)
}
}
})
}
}