Skip to content

Commit 54189ec

Browse files
authored
Master and slave sentinel endpoint options. (#65)
1 parent a46c2ee commit 54189ec

File tree

6 files changed

+206
-16
lines changed

6 files changed

+206
-16
lines changed

lib/async/redis/endpoint.rb

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,11 +58,19 @@ def self.parse(string, endpoint = nil, **options)
5858
end
5959

6060
# Construct an endpoint with a specified scheme, hostname, optional path, and options.
61+
# If no scheme is provided, it will be auto-detected based on SSL context.
6162
#
62-
# @parameter scheme [String] The scheme to use, e.g. "redis" or "rediss".
63+
# @parameter scheme [String, nil] The scheme to use, e.g. "redis" or "rediss". If nil, will auto-detect.
6364
# @parameter hostname [String] The hostname to connect to (or bind to).
6465
# @parameter options [Hash] Additional options, passed to {#initialize}.
6566
def self.for(scheme, host, credentials: nil, port: nil, database: nil, **options)
67+
# Auto-detect scheme if not provided:
68+
if default_scheme = options.delete(:scheme)
69+
scheme ||= default_scheme
70+
end
71+
72+
scheme ||= options.key?(:ssl_context) ? "rediss" : "redis"
73+
6674
uri_klass = SCHEMES.fetch(scheme.downcase) do
6775
raise ArgumentError, "Unsupported scheme: #{scheme.inspect}"
6876
end

lib/async/redis/sentinel_client.rb

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,15 @@ class SentinelClient
2121
#
2222
# @property endpoints [Array(Endpoint)] The list of sentinel endpoints.
2323
# @property master_name [String] The name of the master instance, defaults to 'mymaster'.
24+
# @property master_options [Hash] Connection options for master instances.
25+
# @property slave_options [Hash] Connection options for slave instances (defaults to master_options if not specified).
2426
# @property role [Symbol] The role of the instance that you want to connect to, either `:master` or `:slave`.
25-
# @property protocol [Protocol] The protocol to use when connecting to the actual Redis server, defaults to {Protocol::RESP2}.
26-
def initialize(endpoints, master_name: DEFAULT_MASTER_NAME, role: :master, protocol: Protocol::RESP2, **options)
27+
def initialize(endpoints, master_name: DEFAULT_MASTER_NAME, master_options: nil, slave_options: nil, role: :master, **options)
2728
@endpoints = endpoints
2829
@master_name = master_name
30+
@master_options = master_options || {}
31+
@slave_options = slave_options || @master_options
2932
@role = role
30-
@protocol = protocol
3133

3234
# A cache of sentinel connections.
3335
@sentinels = {}
@@ -94,25 +96,27 @@ def master(name = @master_name)
9496
end
9597
end
9698

99+
private
100+
97101
# Resolve the master endpoint address.
98102
# @returns [Endpoint | Nil] The master endpoint or nil if not found.
99-
def resolve_master
103+
def resolve_master(options = @master_options)
100104
sentinels do |client|
101105
begin
102106
address = client.call("SENTINEL", "GET-MASTER-ADDR-BY-NAME", @master_name)
103107
rescue Errno::ECONNREFUSED
104108
next
105109
end
106110

107-
return Endpoint.remote(address[0], address[1]) if address
111+
return Endpoint.for(nil, address[0], port: address[1], **options) if address
108112
end
109113

110114
return nil
111115
end
112116

113117
# Resolve a slave endpoint address.
114118
# @returns [Endpoint | Nil] A slave endpoint or nil if not found.
115-
def resolve_slave
119+
def resolve_slave(options = @slave_options)
116120
sentinels do |client|
117121
begin
118122
reply = client.call("SENTINEL", "SLAVES", @master_name)
@@ -124,16 +128,14 @@ def resolve_slave
124128
next if slaves.empty?
125129

126130
slave = select_slave(slaves)
127-
return Endpoint.remote(slave["ip"], slave["port"])
131+
return Endpoint.for(nil, slave["ip"], port: slave["port"], **options)
128132
end
129133

130134
return nil
131135
end
132136

133-
protected
134-
135137
def assign_default_tags(tags)
136-
tags[:protocol] = @protocol.to_s
138+
tags[:role] ||= @role
137139
end
138140

139141
# Override the parent method. The only difference is that this one needs to resolve the master/slave address.
@@ -145,7 +147,7 @@ def make_pool(**options)
145147
peer = endpoint.connect
146148
stream = ::IO::Stream(peer)
147149

148-
@protocol.client(stream)
150+
endpoint.protocol.client(stream)
149151
end
150152
end
151153

releases.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
- Add agent context.
66
- Add support for pattern pub/sub.
77
- Add support for sharded pub/sub.
8+
- Add support for `master_options` and `slave_options` (and removed `protocol`) from `SentinelClient`.
89

910
## v0.11.2
1011

sentinel/test/async/redis/sentinel_client.rb

Lines changed: 71 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
require "async/redis/sentinel_client"
88
require "sus/fixtures/async"
99
require "securerandom"
10+
require "openssl"
1011

1112
describe Async::Redis::SentinelClient do
1213
include Sus::Fixtures::Async::ReactorContext
@@ -32,13 +33,18 @@
3233
expect(client.get(key)).to be == value
3334
end
3435

36+
def wait_until(duration: 0.001, repeats: 100)
37+
repeats.times do
38+
break if yield
39+
sleep duration
40+
end
41+
end
42+
3543
it "should resolve slave address" do
3644
client.set(key, value)
3745

38-
# It takes a while to replicate:
39-
while true
40-
break if slave_client.get(key) == value
41-
sleep 0.01
46+
wait_until do
47+
slave_client.get(key) == value
4248
end
4349

4450
expect(slave_client.get(key)).to be == value
@@ -51,4 +57,65 @@
5157
client.set(key, value)
5258
expect(client.get(key)).to be == value
5359
end
60+
61+
with "endpoint options" do
62+
it "uses master_options for master connections" do
63+
master_options = {database: 1}
64+
client_with_options = subject.new(sentinels, master_options: master_options, role: :master)
65+
66+
# Verify the client can set/get values (basic connectivity):
67+
client_with_options.set(key, value)
68+
expect(client_with_options.get(key)).to be == value
69+
end
70+
71+
it "uses slave_options for slave connections when specified" do
72+
slave_options = {database: 2}
73+
slave_client_with_options = subject.new(sentinels, slave_options: slave_options, role: :slave)
74+
75+
# Set data via master that also uses the same database:
76+
master_client_with_options = subject.new(sentinels, master_options: slave_options, role: :master)
77+
master_client_with_options.set(key, value)
78+
79+
# Wait for replication and verify slave can read (basic connectivity):
80+
wait_until do
81+
slave_client_with_options.get(key) == value
82+
end
83+
84+
expect(slave_client_with_options.get(key)).to be == value
85+
end
86+
87+
it "falls back to master_options for slave connections when slave_options not specified" do
88+
master_options = {database: 3}
89+
slave_client_fallback = subject.new(sentinels, master_options: master_options, role: :slave)
90+
91+
# Set data via master first:
92+
client_with_master_options = subject.new(sentinels, master_options: master_options, role: :master)
93+
client_with_master_options.set(key, value)
94+
95+
# Wait for replication and verify slave can read using master options:
96+
wait_until do
97+
slave_client_fallback.get(key) == value
98+
end
99+
100+
expect(slave_client_fallback.get(key)).to be == value
101+
end
102+
103+
it "provides correct endpoint options for master role" do
104+
master_options = {database: 1, timeout: 5}
105+
slave_options = {database: 2, timeout: 10}
106+
107+
client_with_both = subject.new(sentinels, master_options: master_options, slave_options: slave_options)
108+
109+
expect(client_with_both.instance_variable_get(:@master_options)).to be == master_options
110+
end
111+
112+
it "provides correct endpoint options for slave role" do
113+
master_options = {database: 1, timeout: 5}
114+
slave_options = {database: 2, timeout: 10}
115+
116+
client_with_both = subject.new(sentinels, master_options: master_options, slave_options: slave_options)
117+
118+
expect(client_with_both.instance_variable_get(:@slave_options)).to be == slave_options
119+
end
120+
end
54121
end

test/async/redis/endpoint.rb

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,4 +143,26 @@
143143
expect(endpoint.database).to be == 3
144144
end
145145
end
146+
147+
with "scheme auto-detection" do
148+
it "auto-detects redis scheme when no ssl_context provided" do
149+
endpoint = Async::Redis::Endpoint.for(nil, "localhost", port: 6379)
150+
expect(endpoint.scheme).to be == "redis"
151+
expect(endpoint).not.to be(:secure?)
152+
end
153+
154+
it "auto-detects rediss scheme when ssl_context provided" do
155+
ssl_context = OpenSSL::SSL::SSLContext.new
156+
endpoint = Async::Redis::Endpoint.for(nil, "localhost", ssl_context: ssl_context)
157+
expect(endpoint.scheme).to be == "rediss"
158+
expect(endpoint).to be(:secure?)
159+
end
160+
161+
it "respects explicit scheme even when ssl_context provided" do
162+
ssl_context = OpenSSL::SSL::SSLContext.new
163+
endpoint = Async::Redis::Endpoint.for("redis", "localhost", ssl_context: ssl_context)
164+
expect(endpoint.scheme).to be == "redis"
165+
expect(endpoint).not.to be(:secure?) # scheme takes precedence
166+
end
167+
end
146168
end
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
# frozen_string_literal: true
2+
3+
# Released under the MIT License.
4+
# Copyright, 2024-2025, by Samuel Williams.
5+
6+
require "async/redis/sentinel_client"
7+
require "sus/fixtures/async"
8+
require "openssl"
9+
10+
describe Async::Redis::SentinelClient do
11+
include Sus::Fixtures::Async::ReactorContext
12+
13+
let(:sentinels) {[
14+
Async::Redis::Endpoint.parse("redis://localhost:26379")
15+
]}
16+
17+
with "initialization" do
18+
it "can be created with basic parameters" do
19+
client = subject.new(sentinels)
20+
expect(client.master_name).to be == "mymaster"
21+
expect(client.role).to be == :master
22+
end
23+
24+
it "accepts master_options parameter" do
25+
master_options = {database: 1, timeout: 5}
26+
client = subject.new(sentinels, master_options: master_options)
27+
28+
# Test that the options are used by checking the instance variables
29+
expect(client.instance_variable_get(:@master_options)).to be == master_options
30+
end
31+
32+
it "accepts slave_options parameter" do
33+
master_options = {database: 1}
34+
slave_options = {database: 2, timeout: 10}
35+
client = subject.new(sentinels, master_options: master_options, slave_options: slave_options)
36+
37+
expect(client.instance_variable_get(:@slave_options)).to be == slave_options
38+
end
39+
40+
it "uses master_options as fallback for slaves when slave_options not provided" do
41+
master_options = {database: 1, timeout: 5}
42+
client = subject.new(sentinels, master_options: master_options)
43+
44+
expect(client.instance_variable_get(:@slave_options)).to be == master_options
45+
end
46+
47+
it "handles empty options gracefully" do
48+
client = subject.new(sentinels)
49+
50+
expect(client.instance_variable_get(:@master_options)).to be == {}
51+
expect(client.instance_variable_get(:@slave_options)).to be == {}
52+
end
53+
end
54+
55+
with "endpoint options by role" do
56+
let(:master_options) {{database: 1, timeout: 5}}
57+
let(:slave_options) {{database: 2, timeout: 10}}
58+
let(:client) {subject.new(sentinels, master_options: master_options, slave_options: slave_options)}
59+
60+
it "stores master options correctly" do
61+
expect(client.instance_variable_get(:@master_options)).to be == master_options
62+
end
63+
64+
it "stores slave options correctly" do
65+
expect(client.instance_variable_get(:@slave_options)).to be == slave_options
66+
end
67+
end
68+
69+
with "role-specific scheme handling" do
70+
it "creates correct endpoint scheme for master with SSL" do
71+
ssl_context = OpenSSL::SSL::SSLContext.new
72+
master_options = {ssl_context: ssl_context}
73+
74+
endpoint = Async::Redis::Endpoint.for(nil, "localhost", **master_options)
75+
expect(endpoint.scheme).to be == "rediss"
76+
end
77+
78+
it "creates different schemes for master vs slave when options differ" do
79+
ssl_context = OpenSSL::SSL::SSLContext.new
80+
master_options = {ssl_context: ssl_context} # SSL enabled
81+
slave_options = {database: 1} # No SSL
82+
83+
master_endpoint = Async::Redis::Endpoint.for(nil, "localhost", **master_options)
84+
slave_endpoint = Async::Redis::Endpoint.for(nil, "localhost", **slave_options)
85+
86+
expect(master_endpoint.scheme).to be == "rediss"
87+
expect(slave_endpoint.scheme).to be == "redis"
88+
end
89+
end
90+
end

0 commit comments

Comments
 (0)