Skip to content

Commit 0dcafb0

Browse files
authored
Add multiple replicas test (#35)
1 parent 518716c commit 0dcafb0

15 files changed

+190
-68
lines changed

.github/workflows/test.yaml

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,7 @@ jobs:
3535
matrix:
3636
redis: ['6.2.7', '7.0.1']
3737
ruby: ['3.1', '3.0', '2.7']
38-
#driver: ['ruby', 'hiredis'] # FIXME: freaky
39-
driver: ['ruby']
38+
driver: ['ruby'] # FIXME: freaky hiredis
4039
docker: ['docker-compose.yaml', 'docker-compose.ssl.yaml']
4140
os: ['ubuntu-latest']
4241
runs-on: ${{ matrix.os }}
@@ -64,3 +63,33 @@ jobs:
6463
run: bundle exec rake test
6564
- name: Stop containers
6665
run: docker compose -f $DOCKER_COMPOSE_FILE down
66+
multiple-replicas:
67+
name: Multiple Replicas
68+
timeout-minutes: 15
69+
strategy:
70+
fail-fast: false
71+
runs-on: ubuntu-latest
72+
env:
73+
REDIS_VERSION: '7.0.1'
74+
REDIS_REPLICA_SIZE: '2'
75+
DOCKER_COMPOSE_FILE: 'docker-compose.replica.yaml'
76+
steps:
77+
- name: Check out code
78+
uses: actions/checkout@v3
79+
- name: Set up Ruby
80+
uses: ruby/setup-ruby@v1
81+
with:
82+
ruby-version: '3.1'
83+
bundler-cache: true
84+
- name: Pull Docker images
85+
run: docker pull redis:$REDIS_VERSION
86+
- name: Run containers
87+
run: docker compose -f $DOCKER_COMPOSE_FILE up -d
88+
- name: Wait for Redis cluster to be ready
89+
run: bundle exec rake wait
90+
- name: Print containers
91+
run: docker compose -f $DOCKER_COMPOSE_FILE ps
92+
- name: Run minitest
93+
run: bundle exec rake test
94+
- name: Stop containers
95+
run: docker compose -f $DOCKER_COMPOSE_FILE down

Rakefile

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ task :wait do
1616
$LOAD_PATH.unshift(File.expand_path('test', __dir__))
1717
require 'constants'
1818
require 'cluster_controller'
19-
::ClusterController.new(TEST_NODE_URIS, **TEST_GENERIC_OPTIONS)
20-
.wait_for_cluster_to_be_ready
19+
::ClusterController.new(
20+
TEST_NODE_URIS,
21+
replica_size: TEST_REPLICA_SIZE,
22+
**TEST_GENERIC_OPTIONS
23+
).wait_for_cluster_to_be_ready
2124
end

docker-compose.replica.yaml

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
---
2+
# 3 shards with double replicas
3+
services:
4+
node1: &node
5+
image: "redis:${REDIS_VERSION:-7}"
6+
command: >
7+
redis-server
8+
--maxmemory 64mb
9+
--maxmemory-policy allkeys-lru
10+
--appendonly yes
11+
--cluster-enabled yes
12+
--cluster-config-file nodes.conf
13+
--cluster-node-timeout 5000
14+
healthcheck:
15+
test: ["CMD", "redis-cli", "ping"]
16+
interval: "7s"
17+
timeout: "5s"
18+
retries: 10
19+
ports:
20+
- "6379:6379"
21+
node2:
22+
<<: *node
23+
ports:
24+
- "6380:6379"
25+
node3:
26+
<<: *node
27+
ports:
28+
- "6381:6379"
29+
node4:
30+
<<: *node
31+
ports:
32+
- "6382:6379"
33+
node5:
34+
<<: *node
35+
ports:
36+
- "6383:6379"
37+
node6:
38+
<<: *node
39+
ports:
40+
- "6384:6379"
41+
node7:
42+
<<: *node
43+
ports:
44+
- "6385:6379"
45+
node8:
46+
<<: *node
47+
ports:
48+
- "6386:6379"
49+
node9:
50+
<<: *node
51+
ports:
52+
- "6387:6379"
53+
clustering:
54+
image: "redis:${REDIS_VERSION:-7}"
55+
command: >
56+
bash -c "apt-get update > /dev/null
57+
&& apt-get install --no-install-recommends --no-install-suggests -y dnsutils > /dev/null
58+
&& rm -rf /var/lib/apt/lists/*
59+
&& yes yes | redis-cli --cluster create
60+
$$(dig node1 +short):6379
61+
$$(dig node2 +short):6379
62+
$$(dig node3 +short):6379
63+
$$(dig node4 +short):6379
64+
$$(dig node5 +short):6379
65+
$$(dig node6 +short):6379
66+
$$(dig node7 +short):6379
67+
$$(dig node8 +short):6379
68+
$$(dig node9 +short):6379
69+
--cluster-replicas 2"
70+
depends_on:
71+
node1:
72+
condition: service_healthy
73+
node2:
74+
condition: service_healthy
75+
node3:
76+
condition: service_healthy
77+
node4:
78+
condition: service_healthy
79+
node5:
80+
condition: service_healthy
81+
node6:
82+
condition: service_healthy
83+
node7:
84+
condition: service_healthy
85+
node8:
86+
condition: service_healthy
87+
node9:
88+
condition: service_healthy

lib/redis_client/cluster/node.rb

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -129,19 +129,17 @@ def call_primary(method, *command, **kwargs, &block)
129129
def call_replica(method, *command, **kwargs, &block)
130130
return call_primary(method, *command, **kwargs, &block) if replica_disabled?
131131

132+
replica_node_keys = @replications.values.map(&:sample)
132133
try_map do |node_key, client|
133-
next if primary?(node_key)
134+
next if primary?(node_key) || !replica_node_keys.include?(node_key)
134135

135136
client.send(method, *command, **kwargs, &block)
136137
end.values
137138
end
138139

139140
def scale_reading_clients
140-
clients = @clients.select do |node_key, _|
141-
replica_disabled? ? primary?(node_key) : replica?(node_key)
142-
end
143-
144-
clients.values.sort_by do |client|
141+
keys = replica_disabled? ? @replications.keys : @replications.values.map(&:first)
142+
@clients.select { |k, _| keys.include?(k) }.values.sort_by do |client|
145143
::RedisClient::Cluster::NodeKey.build_from_host_port(client.config.host, client.config.port)
146144
end
147145
end
@@ -184,7 +182,7 @@ def primary?(node_key)
184182
end
185183

186184
def replica?(node_key)
187-
!(@replications.nil? || @replications.size.zero?) && @replications[node_key].size.zero?
185+
!(@replications.nil? || @replications.size.zero?) && !@replications.key?(node_key)
188186
end
189187

190188
def build_slot_node_mappings(node_info)
@@ -203,7 +201,6 @@ def build_replication_mappings(node_info)
203201
node_info.each_with_object(Hash.new { |h, k| h[k] = [] }) do |info, acc|
204202
primary_info = dict[info[:primary_id]]
205203
acc[primary_info[:node_key]] << info[:node_key] unless primary_info.nil?
206-
acc[info[:node_key]]
207204
end
208205
end
209206

test/cluster_controller.rb

Lines changed: 7 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@
22

33
class ClusterController
44
SLOT_SIZE = 16_384
5+
SHARD_SIZE = 3
56

6-
def initialize(node_addrs, timeout: 30.0, reconnect_attempts: 3, **kwargs)
7-
raise 'Redis Cluster requires at least 3 master nodes.' if node_addrs.size < 3
7+
def initialize(node_addrs, replica_size: 1, timeout: 30.0, reconnect_attempts: 3, **kwargs)
8+
raise "Redis Cluster requires at least #{SHARD_SIZE} master nodes." if node_addrs.size < SHARD_SIZE
89

10+
@replica_size = replica_size
911
@timeout = kwargs[:timeout]
1012
@clients = node_addrs.map do |addr|
1113
::RedisClient.new(
@@ -199,7 +201,7 @@ def wait_cluster_building(clients, max_attempts: 600)
199201
def wait_replication(clients, max_attempts: 600)
200202
wait_for_state(clients, max_attempts) do |client|
201203
flags = hashify_cluster_node_flags(client)
202-
flags.values.count { |f| f == 'slave' } == 3
204+
flags.values.count { |f| f == 'slave' } == @replica_size * SHARD_SIZE
203205
end
204206
end
205207

@@ -266,17 +268,11 @@ def hashify_node_map(client)
266268
end
267269

268270
def take_masters(clients)
269-
size = clients.size / 2
270-
return clients if size < 3
271-
272-
clients.take(size)
271+
clients.take(SHARD_SIZE)
273272
end
274273

275274
def take_slaves(clients)
276-
size = clients.size / 2
277-
return [] if size < 3
278-
279-
clients[size..size * 2]
275+
clients[SHARD_SIZE..]
280276
end
281277

282278
def take_replication_pairs(clients)

test/constants.rb

Lines changed: 32 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,35 @@
11
# frozen_string_literal: true
22

3+
# rubocop:disable Lint/UnderscorePrefixedVariableName
4+
35
require 'redis_client'
46

57
TEST_REDIS_HOST = '127.0.0.1'
6-
TEST_REDIS_PORTS = (6379..6384).freeze
8+
TEST_REDIS_PORT = 6379
79
TEST_TIMEOUT_SEC = 5.0
810
TEST_RECONNECT_ATTEMPTS = 3
911

12+
_new_raw_cli = ->(**opts) { ::RedisClient.config(host: TEST_REDIS_HOST, port: TEST_REDIS_PORT, **opts).new_client }
13+
_test_cert_path = ->(f) { File.expand_path(File.join('ssl_certs', f), __dir__) }
14+
15+
TEST_SSL_PARAMS = {
16+
ca_file: _test_cert_path.call('redis-rb-ca.crt'),
17+
cert: _test_cert_path.call('redis-rb-cert.crt'),
18+
key: _test_cert_path.call('redis-rb-cert.key')
19+
}.freeze
20+
21+
_base_opts = {
22+
timeout: TEST_TIMEOUT_SEC,
23+
reconnect_attempts: TEST_RECONNECT_ATTEMPTS
24+
}.freeze
25+
26+
_ssl_opts = {
27+
ssl: true,
28+
ssl_params: TEST_SSL_PARAMS
29+
}.freeze
30+
1031
begin
11-
::RedisClient.config(
12-
host: TEST_REDIS_HOST,
13-
port: TEST_REDIS_PORTS.first,
14-
timeout: TEST_TIMEOUT_SEC
15-
).new_client.call('PING')
32+
_new_raw_cli.call(**_base_opts).call('PING')
1633
TEST_REDIS_SCHEME = 'redis'
1734
rescue ::RedisClient::ConnectionError => e
1835
raise e if e.message != 'Connection reset by peer'
@@ -21,28 +38,17 @@
2138
end
2239

2340
TEST_REDIS_SSL = TEST_REDIS_SCHEME == 'rediss'
24-
TEST_REPLICA_SIZE = 1
25-
TEST_NUMBER_OF_REPLICAS = 3
2641
TEST_FIXED_HOSTNAME = TEST_REDIS_SSL ? TEST_REDIS_HOST : nil
2742

43+
TEST_SHARD_SIZE = 3
44+
TEST_REPLICA_SIZE = ENV.fetch('REDIS_REPLICA_SIZE', '1').to_i
45+
TEST_NUMBER_OF_REPLICAS = TEST_REPLICA_SIZE * TEST_SHARD_SIZE
46+
TEST_NUMBER_OF_NODES = TEST_SHARD_SIZE + TEST_NUMBER_OF_REPLICAS
47+
48+
TEST_REDIS_PORTS = TEST_REDIS_PORT.upto(TEST_REDIS_PORT + TEST_NUMBER_OF_NODES - 1).to_a.freeze
2849
TEST_NODE_URIS = TEST_REDIS_PORTS.map { |v| "#{TEST_REDIS_SCHEME}://#{TEST_REDIS_HOST}:#{v}" }.freeze
2950
TEST_NODE_OPTIONS = TEST_REDIS_PORTS.to_h { |v| ["#{TEST_REDIS_HOST}:#{v}", { host: TEST_REDIS_HOST, port: v }] }.freeze
3051

31-
GET_TEST_CERT_PATH = ->(f) { File.expand_path(File.join('ssl_certs', f), __dir__) }
32-
TEST_GENERIC_OPTIONS = if TEST_REDIS_SSL
33-
{
34-
timeout: TEST_TIMEOUT_SEC,
35-
reconnect_attempts: TEST_RECONNECT_ATTEMPTS,
36-
ssl: true,
37-
ssl_params: {
38-
ca_file: GET_TEST_CERT_PATH.call('redis-rb-ca.crt'),
39-
cert: GET_TEST_CERT_PATH.call('redis-rb-cert.crt'),
40-
key: GET_TEST_CERT_PATH.call('redis-rb-cert.key')
41-
}
42-
}.freeze
43-
else
44-
{
45-
timeout: TEST_TIMEOUT_SEC,
46-
reconnect_attempts: TEST_RECONNECT_ATTEMPTS
47-
}.freeze
48-
end
52+
TEST_GENERIC_OPTIONS = TEST_REDIS_SSL ? _base_opts.merge(_ssl_opts).freeze : _base_opts
53+
54+
# rubocop:enable Lint/UnderscorePrefixedVariableName

test/redis_client/cluster/test_command.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
class RedisClient
1010
class Cluster
11-
class TestCommand < Minitest::Test
11+
class TestCommand < TestingWrapper
1212
def setup
1313
@raw_clients = TEST_NODE_URIS.map { |addr| ::RedisClient.config(url: addr, **TEST_GENERIC_OPTIONS).new_client }
1414
end

test/redis_client/cluster/test_errors.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
class RedisClient
77
class Cluster
8-
class TestErrors < Minitest::Test
8+
class TestErrors < TestingWrapper
99
DummyError = Struct.new('DummyError', :message)
1010

1111
def test_initial_setup_error

test/redis_client/cluster/test_key_slot_converter.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
class RedisClient
77
class Cluster
8-
class TestKeySlotConverter < Minitest::Test
8+
class TestKeySlotConverter < TestingWrapper
99
def setup
1010
@raw_clients = TEST_NODE_URIS.map { |addr| ::RedisClient.config(url: addr, **TEST_GENERIC_OPTIONS).new_client }
1111
end

test/redis_client/cluster/test_node.rb

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
class RedisClient
1111
class Cluster
1212
class Node
13-
class TestConfig < Minitest::Test
13+
class TestConfig < TestingWrapper
1414
def test_connection_prelude
1515
[
1616
{ params: { scale_read: true }, want: [%w[HELLO 3], %w[READONLY]] },
@@ -24,7 +24,7 @@ def test_connection_prelude
2424
end
2525
end
2626

27-
class TestNode < Minitest::Test
27+
class TestNode < TestingWrapper
2828
def setup
2929
@test_config = ::RedisClient::ClusterConfig.new(
3030
nodes: TEST_NODE_URIS,
@@ -232,22 +232,22 @@ def test_call_primary
232232

233233
def test_call_replica
234234
want = (1..(@test_node_info.count { |info| info[:role] == 'master' })).map { |_| 'PONG' }
235+
235236
got = @test_node.call_replica(:call, 'PING')
236237
assert_equal(want, got, 'Case: primary only')
237238

238-
want = (1..(@test_node_info.count { |info| info[:role] == 'slave' })).map { |_| 'PONG' }
239239
got = @test_node_with_scale_read.call_replica(:call, 'PING')
240240
assert_equal(want, got, 'Case: scale read')
241241
end
242242

243-
def test_scale_reading_clients
243+
def test_scale_reading_clients # rubocop:disable Metrics/CyclomaticComplexity
244244
want = @test_node_info.select { |info| info[:role] == 'master' }.map { |info| info[:node_key] }.sort
245245
got = @test_node.scale_reading_clients.map { |client| "#{client.config.host}:#{client.config.port}" }
246246
assert_equal(want, got, 'Case: primary only')
247247

248-
want = @test_node_info.select { |info| info[:role] == 'slave' }.map { |info| info[:node_key] }.sort
248+
want = @test_node_info.select { |info| info[:role] == 'slave' }.map { |info| info[:node_key] }
249249
got = @test_node_with_scale_read.scale_reading_clients.map { |client| "#{client.config.host}:#{client.config.port}" }
250-
assert_equal(want, got, 'Case: scale read')
250+
got.each { |e| assert_includes(want, e, 'Case: scale read') }
251251
end
252252

253253
def test_slot_exists?
@@ -271,11 +271,12 @@ def test_find_node_key_of_replica
271271
got = @test_node.find_node_key_of_replica(sample_slot)
272272
assert_equal(sample_node[:node_key], got, 'Case: primary only')
273273

274-
sample_replica = @test_node_info.find { |info| info[:role] == 'slave' }
275-
sample_primary = @test_node_info.find { |info| info[:id] == sample_replica[:primary_id] }
274+
sample_replicas = @test_node_info.select { |info| info[:role] == 'slave' }
275+
sample_primary = @test_node_info.find { |info| info[:id] == sample_replicas.first[:primary_id] }
276276
sample_slot = sample_primary[:slots].first.first
277277
got = @test_node_with_scale_read.find_node_key_of_replica(sample_slot)
278-
assert_equal(sample_replica[:node_key], got, 'Case: scale read')
278+
want = sample_replicas.map { |info| info[:node_key] }
279+
assert_includes(want, got, 'Case: scale read')
279280
end
280281

281282
def test_update_slot

0 commit comments

Comments
 (0)