Skip to content

Commit e3d6819

Browse files
committed
redis-clustering support
1 parent 53fcba1 commit e3d6819

File tree

7 files changed

+174
-32
lines changed

7 files changed

+174
-32
lines changed

.github/workflows/ci.yml

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ jobs:
1414
matrix:
1515
ruby: ['2.3', '2.4', '2.5', '2.6', '2.7', '3.0', '3.1', '3.2', '3.3', '3.4', head, jruby-head, truffleruby-head]
1616
redis: ['4', '5']
17+
redis_cluster: [false, true]
1718
search: [
1819
['opensearch-ruby:2', 'opensearchproject/opensearch:2'],
1920
['opensearch-ruby:3', 'opensearchproject/opensearch:3'],
@@ -26,6 +27,23 @@ jobs:
2627
redis: '5'
2728
- ruby: '2.4'
2829
redis: '5'
30+
# redis-clustering first release is 5.x
31+
- redis: '4'
32+
redis_cluster: true
33+
# redis-clustering 5.x requires ruby >= 2.7
34+
- ruby: '2.3'
35+
redis_cluster: true
36+
- ruby: '2.4'
37+
redis_cluster: true
38+
- ruby: '2.5'
39+
redis_cluster: true
40+
- ruby: '2.6'
41+
redis_cluster: true
42+
# our usage of redis-cluster-client suffers from https://bugs.ruby-lang.org/issues/18991 in ruby <= 3.0
43+
- ruby: '2.7'
44+
redis_cluster: true
45+
- ruby: '3.0'
46+
redis_cluster: true
2947
# opensearch-ruby 2.x requires ruby >= 2.4
3048
- ruby: '2.3'
3149
search: ['opensearch-ruby:2', 'opensearchproject/opensearch:2']
@@ -49,10 +67,6 @@ jobs:
4967
- ruby: '2.5'
5068
search: ['elasticsearch:9', 'elasticsearch:9.0.2']
5169
services:
52-
redis:
53-
image: redis
54-
ports:
55-
- 6379:6379
5670
search:
5771
image: ${{ matrix.search[1] }}
5872
ports:
@@ -73,6 +87,7 @@ jobs:
7387
7488
env:
7589
REDIS_VERSION: ${{ matrix.redis }}
90+
REDIS_CLUSTER: ${{ matrix.redis_cluster && 'true' || '' }}
7691
SEARCH_GEM: ${{ matrix.search[0] }}
7792

7893
steps:
@@ -81,6 +96,14 @@ jobs:
8196
with:
8297
ruby-version: ${{ matrix.ruby }}
8398
bundler-cache: true
99+
- name: Start Redis (single instance)
100+
if: ${{ !matrix.redis_cluster }}
101+
run: |
102+
docker run -d --name redis -p 6379:6379 redis
103+
- name: Start Redis Cluster
104+
if: ${{ matrix.redis_cluster }}
105+
run: |
106+
docker compose -f docker-compose.redis-cluster.yml up -d
84107
- name: start MySQL
85108
run: sudo /etc/init.d/mysql start
86109
- run: bundle exec rspec --format doc

Gemfile

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,12 +30,20 @@ if Gem::Version.new(RUBY_VERSION) >= Gem::Version.new('2.6')
3030
gem 'simplecov-cobertura', '~> 2.1'
3131
end
3232

33-
if ENV['REDIS_VERSION']
34-
gem 'redis', "~> #{ENV['REDIS_VERSION']}"
33+
if (redis_version = ENV.fetch('REDIS_VERSION', nil))
34+
gem 'redis', "~> #{redis_version}"
3535
end
3636

37-
if ENV['SEARCH_GEM']
38-
name, version = ENV['SEARCH_GEM'].split(':')
37+
if redis_version
38+
if ENV.fetch('REDIS_CLUSTER', nil) == 'true'
39+
gem 'redis-clustering', "~> #{redis_version}"
40+
end
41+
elsif Gem::Version.new(RUBY_VERSION) >= Gem::Version.new('2.7')
42+
gem 'redis-clustering' # rubocop:disable Bundler/DuplicatedGem
43+
end
44+
45+
if (search_gem = ENV.fetch('SEARCH_GEM', nil))
46+
name, version = search_gem.split(':')
3947
gem name, "~> #{version}"
4048
else
4149
gem 'opensearch-ruby'

docker-compose.redis-cluster.yml

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
services:
2+
redis-cluster-node-0:
3+
image: docker.io/bitnami/redis-cluster:latest
4+
volumes:
5+
- redis-cluster_data-0:/bitnami/redis/data
6+
environment:
7+
- "ALLOW_EMPTY_PASSWORD=yes"
8+
- "REDIS_NODES=redis-cluster-node-0 redis-cluster-node-1 redis-cluster-node-2 redis-cluster-node-3 redis-cluster-node-4 redis-cluster-node-5"
9+
10+
redis-cluster-node-1:
11+
image: docker.io/bitnami/redis-cluster:latest
12+
volumes:
13+
- redis-cluster_data-1:/bitnami/redis/data
14+
environment:
15+
- "ALLOW_EMPTY_PASSWORD=yes"
16+
- "REDIS_NODES=redis-cluster-node-0 redis-cluster-node-1 redis-cluster-node-2 redis-cluster-node-3 redis-cluster-node-4 redis-cluster-node-5"
17+
18+
redis-cluster-node-2:
19+
image: docker.io/bitnami/redis-cluster:latest
20+
volumes:
21+
- redis-cluster_data-2:/bitnami/redis/data
22+
environment:
23+
- "ALLOW_EMPTY_PASSWORD=yes"
24+
- "REDIS_NODES=redis-cluster-node-0 redis-cluster-node-1 redis-cluster-node-2 redis-cluster-node-3 redis-cluster-node-4 redis-cluster-node-5"
25+
26+
redis-cluster-node-3:
27+
image: docker.io/bitnami/redis-cluster:latest
28+
volumes:
29+
- redis-cluster_data-3:/bitnami/redis/data
30+
environment:
31+
- "ALLOW_EMPTY_PASSWORD=yes"
32+
- "REDIS_NODES=redis-cluster-node-0 redis-cluster-node-1 redis-cluster-node-2 redis-cluster-node-3 redis-cluster-node-4 redis-cluster-node-5"
33+
34+
redis-cluster-node-4:
35+
image: docker.io/bitnami/redis-cluster:latest
36+
volumes:
37+
- redis-cluster_data-4:/bitnami/redis/data
38+
environment:
39+
- "ALLOW_EMPTY_PASSWORD=yes"
40+
- "REDIS_NODES=redis-cluster-node-0 redis-cluster-node-1 redis-cluster-node-2 redis-cluster-node-3 redis-cluster-node-4 redis-cluster-node-5"
41+
42+
redis-cluster-node-5:
43+
image: docker.io/bitnami/redis-cluster:latest
44+
volumes:
45+
- redis-cluster_data-5:/bitnami/redis/data
46+
depends_on:
47+
- redis-cluster-node-0
48+
- redis-cluster-node-1
49+
- redis-cluster-node-2
50+
- redis-cluster-node-3
51+
- redis-cluster-node-4
52+
environment:
53+
- "ALLOW_EMPTY_PASSWORD=yes"
54+
- "REDIS_CLUSTER_REPLICAS=1"
55+
- "REDIS_NODES=redis-cluster-node-0 redis-cluster-node-1 redis-cluster-node-2 redis-cluster-node-3 redis-cluster-node-4 redis-cluster-node-5"
56+
- "REDIS_CLUSTER_CREATOR=yes"
57+
ports:
58+
- "6379:6379"
59+
60+
volumes:
61+
redis-cluster_data-0:
62+
driver: local
63+
redis-cluster_data-1:
64+
driver: local
65+
redis-cluster_data-2:
66+
driver: local
67+
redis-cluster_data-3:
68+
driver: local
69+
redis-cluster_data-4:
70+
driver: local
71+
redis-cluster_data-5:
72+
driver: local

lib/faulty/patch/redis.rb

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
# frozen_string_literal: true
22

33
require 'redis'
4+
begin
5+
require 'redis-clustering'
6+
rescue LoadError
7+
nil
8+
end
49

510
class Faulty
611
module Patch

lib/faulty/storage/redis.rb

Lines changed: 26 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -293,7 +293,7 @@ def key(*parts)
293293
end
294294

295295
def ckey(circuit_name, *parts)
296-
key('circuit', circuit_name, *parts)
296+
key('circuit', "{#{circuit_name}}", *parts)
297297
end
298298

299299
# @return [String] The key for circuit options
@@ -323,7 +323,7 @@ def opened_at_key(circuit_name)
323323

324324
# Get the current key to add circuit names to
325325
def list_key
326-
key('list', current_list_block)
326+
key('list', "{#{current_list_block}}")
327327
end
328328

329329
# Get all active circuit list keys
@@ -348,7 +348,7 @@ def all_list_keys
348348
num_blocks = (options.circuit_ttl.to_f / options.list_granularity).floor + 1
349349
start_block = current_list_block - num_blocks + 1
350350
num_blocks.times.map do |i|
351-
key('list', start_block + i)
351+
key('list', "{#{start_block + i}}")
352352
end
353353
end
354354

@@ -372,11 +372,11 @@ def current_list_block
372372
# inside the block
373373
def watch_exec(key, old, &block)
374374
redis do |r|
375-
r.watch(key) do
376-
if old.include?(r.get(key))
377-
r.multi(&block)
375+
r.watch(key) do |c|
376+
if old.include?(c.get(key))
377+
c.multi(&block)
378378
else
379-
r.unwatch
379+
c.unwatch
380380
nil
381381
end
382382
end
@@ -424,11 +424,17 @@ def check_client_options!
424424
warn "Faulty error while checking client options: #{e.message}"
425425
end
426426

427-
def check_redis_options!
427+
def check_redis_options! # rubocop:disable Metrics/MethodLength
428428
gte5 = ::Redis::VERSION.to_f >= 5
429-
method = gte5 ? :config : :options
430429
ropts = redis do |r|
431-
r.instance_variable_get(:@client).public_send(method)
430+
if r.instance_of?(::Redis)
431+
method = gte5 ? :config : :options
432+
r._client.public_send(method)
433+
elsif r.instance_of?(::Redis::Cluster)
434+
r._client.config
435+
else
436+
raise TypeError, "Unsupported Redis client type: #{r.class}"
437+
end
432438
end
433439

434440
bad_timeouts = {}
@@ -445,7 +451,16 @@ def check_redis_options!
445451
MSG
446452
end
447453

448-
gt1_retry = gte5 ? ropts.retry_connecting?(1, nil) : ropts[:reconnect_attempts] > 1
454+
gt1_retry = redis do |r|
455+
if r.instance_of?(::Redis)
456+
gte5 ? ropts.retry_connecting?(1, nil) : ropts[:reconnect_attempts] > 1
457+
elsif r.instance_of?(::Redis::Cluster)
458+
ra = ropts.client_config[:reconnect_attempts]
459+
(ra.is_a?(Array) && ra.length > 1) || ra > 1
460+
else
461+
raise TypeError, "Unsupported Redis client type: #{r.class}"
462+
end
463+
end
449464
if gt1_retry
450465
warn <<~MSG
451466
Faulty recommends setting Redis reconnect_attempts to <= 1 to

spec/circuit_spec.rb

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -331,7 +331,13 @@
331331
end
332332

333333
context 'with redis storage' do
334-
let(:storage) { Faulty::Storage::Redis.new }
334+
let(:storage) do
335+
if ENV['REDIS_CLUSTER'] == 'true'
336+
Faulty::Storage::Redis.new(client: Redis::Cluster.new(timeout: 1))
337+
else
338+
Faulty::Storage::Redis.new
339+
end
340+
end
335341

336342
after { circuit.reset! }
337343

@@ -341,7 +347,11 @@
341347
context 'with fault-tolerant redis storage' do
342348
let(:storage) do
343349
Faulty::Storage::FaultTolerantProxy.new(
344-
Faulty::Storage::Redis.new,
350+
if ENV['REDIS_CLUSTER'] == 'true'
351+
Faulty::Storage::Redis.new(client: Redis::Cluster.new(timeout: 1))
352+
else
353+
Faulty::Storage::Redis.new
354+
end,
345355
notifier: Faulty::Events::Notifier.new
346356
)
347357
end

spec/storage/redis_spec.rb

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

33
require 'connection_pool'
4-
require 'redis'
54

65
RSpec.describe Faulty::Storage::Redis do
76
subject(:storage) { described_class.new(**options.merge(client: client)) }
87

98
let(:options) { {} }
10-
let(:client) { Redis.new(timeout: 1) }
9+
let(:client_options) { { timeout: 1 } }
10+
let(:client_class) do
11+
if ENV['REDIS_CLUSTER'] == 'true'
12+
require 'redis-clustering'
13+
Redis::Cluster
14+
else
15+
require 'redis'
16+
Redis
17+
end
18+
end
19+
let(:client) { client_class.new(**client_options) }
1120
let(:circuit) { Faulty::Circuit.new('test', storage: storage) }
1221

1322
after { circuit&.reset! }
1423

15-
context 'with default options' do
24+
context 'with default options', unless: ENV['REDIS_CLUSTER'] == 'true' do
1625
subject(:storage) { described_class.new }
1726

1827
it 'can add an entry' do
@@ -32,7 +41,7 @@
3241
let(:pool_size) { 100 }
3342

3443
let(:client) do
35-
ConnectionPool.new(size: pool_size, timeout: 1) { Redis.new(timeout: 1) }
44+
ConnectionPool.new(size: pool_size, timeout: 1) { client_class.new(**client_options) }
3645
end
3746

3847
it 'adds an entry' do
@@ -54,7 +63,7 @@
5463
end
5564

5665
context 'when Redis has high timeout' do
57-
let(:client) { Redis.new(timeout: 5.0) }
66+
let(:client) { client_class.new(**client_options, timeout: 5.0) }
5867

5968
it 'prints timeout warning' do
6069
timeouts = { connect_timeout: 5.0, read_timeout: 5.0, write_timeout: 5.0 }
@@ -63,7 +72,7 @@
6372
end
6473

6574
context 'when Redis has high reconnect_attempts' do
66-
let(:client) { Redis.new(timeout: 1, reconnect_attempts: 2) }
75+
let(:client) { client_class.new(**client_options, reconnect_attempts: 2) }
6776

6877
it 'prints reconnect_attempts warning' do
6978
expect { storage }.to output(/Your setting is larger/).to_stderr
@@ -72,7 +81,7 @@
7281

7382
context 'when ConnectionPool has high timeout' do
7483
let(:client) do
75-
ConnectionPool.new(timeout: 6) { Redis.new(timeout: 1) }
84+
ConnectionPool.new(timeout: 6) { client_class.new(**client_options) }
7685
end
7786

7887
it 'prints timeout warning' do
@@ -82,7 +91,7 @@
8291

8392
context 'when ConnectionPool Redis client has high timeout' do
8493
let(:client) do
85-
ConnectionPool.new(timeout: 1) { Redis.new(timeout: 7.0) }
94+
ConnectionPool.new(timeout: 1) { client_class.new(**client_options, timeout: 7.0) }
8695
end
8796

8897
it 'prints Redis timeout warning' do
@@ -106,16 +115,16 @@
106115
it 'sets opened_at to the maximum' do
107116
Timecop.freeze
108117
storage.open(circuit, Faulty.current_time)
109-
client.del('faulty:circuit:test:opened_at')
118+
client.del('faulty:circuit:{test}:opened_at')
110119
status = storage.status(circuit)
111120
expect(status.opened_at).to eq(Faulty.current_time - storage.options.circuit_ttl)
112121
end
113122
end
114123

115124
context 'when history entries are integers and floats' do
116125
it 'gets floats' do
117-
client.lpush('faulty:circuit:test:entries', '1660865630:1')
118-
client.lpush('faulty:circuit:test:entries', '1660865646.897674:1')
126+
client.lpush('faulty:circuit:{test}:entries', '1660865630:1')
127+
client.lpush('faulty:circuit:{test}:entries', '1660865646.897674:1')
119128
expect(storage.history(circuit)).to eq([[1_660_865_630.0, true], [1_660_865_646.897674, true]])
120129
end
121130
end

0 commit comments

Comments
 (0)