mirror of
https://github.com/redis/go-redis.git
synced 2026-10-28 08:24:54 +08:00
* 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.
1663 lines
50 KiB
Go
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)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|