solid-redis 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +293 -0
- data/lib/solid_redis/client.rb +206 -0
- data/lib/solid_redis/config.rb +156 -0
- data/lib/solid_redis/errors.rb +13 -0
- data/lib/solid_redis/pool.rb +101 -0
- data/lib/solid_redis/resp.rb +153 -0
- data/lib/solid_redis/sentinel/resolve_service.rb +78 -0
- data/lib/solid_redis/sentinel_config.rb +160 -0
- data/lib/solid_redis/sentinel_state.rb +69 -0
- data/lib/solid_redis/shareable.rb +13 -0
- data/lib/solid_redis/version.rb +5 -0
- data/lib/solid_redis.rb +27 -0
- metadata +117 -0
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidRedis
|
|
4
|
+
class Pool
|
|
5
|
+
attr_reader :size
|
|
6
|
+
|
|
7
|
+
def initialize(config, size: 5, timeout: 1.0)
|
|
8
|
+
@config = config
|
|
9
|
+
@size = Integer(size)
|
|
10
|
+
raise ArgumentError, "Pool size must be positive" unless @size.positive?
|
|
11
|
+
|
|
12
|
+
@timeout = Float(timeout)
|
|
13
|
+
raise ArgumentError, "Pool timeout must be positive" unless @timeout.positive?
|
|
14
|
+
|
|
15
|
+
@available = []
|
|
16
|
+
@created = 0
|
|
17
|
+
@mutex = Mutex.new
|
|
18
|
+
@resource = ConditionVariable.new
|
|
19
|
+
@closed = false
|
|
20
|
+
@owner = Ractor.current
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def with
|
|
24
|
+
ensure_owner!
|
|
25
|
+
client = checkout
|
|
26
|
+
yield client
|
|
27
|
+
ensure
|
|
28
|
+
checkin(client) if client
|
|
29
|
+
end
|
|
30
|
+
alias_method :then, :with
|
|
31
|
+
|
|
32
|
+
def call(*command)
|
|
33
|
+
with { |client| client.call(*command) }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def call_v(command)
|
|
37
|
+
with { |client| client.call_v(command) }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def pipelined(&block)
|
|
41
|
+
with { |client| client.pipelined(&block) }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def close
|
|
45
|
+
ensure_owner!
|
|
46
|
+
clients = @mutex.synchronize do
|
|
47
|
+
@closed = true
|
|
48
|
+
@resource.broadcast
|
|
49
|
+
@available.shift(@available.length)
|
|
50
|
+
end
|
|
51
|
+
clients.each(&:close)
|
|
52
|
+
self
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def checkout
|
|
58
|
+
deadline = monotonic_time + @timeout
|
|
59
|
+
@mutex.synchronize do
|
|
60
|
+
loop do
|
|
61
|
+
raise ClosedError, "Redis pool is closed" if @closed
|
|
62
|
+
return @available.pop unless @available.empty?
|
|
63
|
+
|
|
64
|
+
if @created < size
|
|
65
|
+
@created += 1
|
|
66
|
+
return @config.new_client
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
remaining = deadline - monotonic_time
|
|
70
|
+
unless remaining.positive?
|
|
71
|
+
raise CheckoutTimeoutError, "Redis pool checkout timed out after #{@timeout}s"
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
@resource.wait(@mutex, remaining)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def checkin(client)
|
|
80
|
+
@mutex.synchronize do
|
|
81
|
+
if @closed
|
|
82
|
+
@created -= 1
|
|
83
|
+
client.close
|
|
84
|
+
else
|
|
85
|
+
@available << client
|
|
86
|
+
@resource.signal
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def ensure_owner!
|
|
92
|
+
return if Ractor.current == @owner
|
|
93
|
+
|
|
94
|
+
raise Ractor::IsolationError, "SolidRedis::Pool cannot cross Ractor boundaries"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def monotonic_time
|
|
98
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidRedis
|
|
4
|
+
module RESP
|
|
5
|
+
CRLF = "\r\n"
|
|
6
|
+
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def encode(command)
|
|
10
|
+
command = command.flatten(1)
|
|
11
|
+
raise ArgumentError, "Redis command cannot be empty" if command.empty?
|
|
12
|
+
|
|
13
|
+
command.each_with_object(+"*#{command.length}#{CRLF}") do |argument, buffer|
|
|
14
|
+
value = encode_argument(argument)
|
|
15
|
+
buffer << "$#{value.bytesize}#{CRLF}#{value}#{CRLF}"
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def encode_argument(value)
|
|
20
|
+
case value
|
|
21
|
+
when String then value
|
|
22
|
+
when Symbol, Integer, Float then value.to_s
|
|
23
|
+
when true then "1"
|
|
24
|
+
when false then "0"
|
|
25
|
+
when nil then ""
|
|
26
|
+
else value.to_s
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
class Reader
|
|
31
|
+
def initialize(io, read_timeout:)
|
|
32
|
+
@io = io
|
|
33
|
+
@read_timeout = read_timeout
|
|
34
|
+
@buffer = +""
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def read(exception: true)
|
|
38
|
+
case (type = read_bytes(1))
|
|
39
|
+
when "+" then read_line
|
|
40
|
+
when "-" then error_response(read_line, exception)
|
|
41
|
+
when ":" then Integer(read_line)
|
|
42
|
+
when "$" then read_bulk
|
|
43
|
+
when "*" then read_array(exception)
|
|
44
|
+
when "_" then read_line && nil
|
|
45
|
+
when "#" then read_line == "t"
|
|
46
|
+
when "," then Float(read_line)
|
|
47
|
+
when "(" then Integer(read_line)
|
|
48
|
+
when "%" then read_map(exception)
|
|
49
|
+
when "~", ">" then read_collection(exception)
|
|
50
|
+
when "=" then read_verbatim
|
|
51
|
+
when "!" then error_response(read_sized_string, exception)
|
|
52
|
+
when "|" then read_map(exception) && read(exception: exception)
|
|
53
|
+
else raise ProtocolError, "Unknown RESP type byte: #{type.inspect}"
|
|
54
|
+
end
|
|
55
|
+
rescue EOFError
|
|
56
|
+
raise ConnectionError, "Redis closed the connection"
|
|
57
|
+
rescue ArgumentError => error
|
|
58
|
+
raise ProtocolError, "Invalid Redis response: #{error.message}", cause: error
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def read_bulk
|
|
64
|
+
length = Integer(read_line)
|
|
65
|
+
return if length == -1
|
|
66
|
+
|
|
67
|
+
read_sized_value(length)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def read_array(exception)
|
|
71
|
+
length = Integer(read_line)
|
|
72
|
+
return if length == -1
|
|
73
|
+
|
|
74
|
+
Array.new(length) { read(exception: exception) }
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def read_collection(exception)
|
|
78
|
+
length = Integer(read_line)
|
|
79
|
+
Array.new(length) { read(exception: exception) }
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def read_map(exception)
|
|
83
|
+
length = Integer(read_line)
|
|
84
|
+
{}.tap do |map|
|
|
85
|
+
length.times { map[read(exception: exception)] = read(exception: exception) }
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def read_verbatim
|
|
90
|
+
read_sized_string.byteslice(4..)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def read_sized_string
|
|
94
|
+
read_sized_value(Integer(read_line))
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def read_sized_value(length)
|
|
98
|
+
value = read_bytes(length)
|
|
99
|
+
actual = read_bytes(2)
|
|
100
|
+
raise ProtocolError, "Expected CRLF, got #{actual.inspect}" unless actual == CRLF
|
|
101
|
+
|
|
102
|
+
value
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def read_line
|
|
106
|
+
loop do
|
|
107
|
+
if (index = @buffer.index(CRLF))
|
|
108
|
+
return @buffer.slice!(0, index + 2).byteslice(0, index)
|
|
109
|
+
end
|
|
110
|
+
fill_buffer
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def read_bytes(length)
|
|
115
|
+
fill_buffer while @buffer.bytesize < length
|
|
116
|
+
@buffer.slice!(0, length)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def fill_buffer
|
|
120
|
+
loop do
|
|
121
|
+
if @io.respond_to?(:to_io)
|
|
122
|
+
unless IO.select([@io], nil, nil, @read_timeout)
|
|
123
|
+
raise TimeoutError, "Redis read timed out after #{@read_timeout}s"
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
chunk = @io.read_nonblock(16_384, exception: false)
|
|
127
|
+
else
|
|
128
|
+
chunk = @io.read(16_384)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
next if chunk == :wait_readable
|
|
132
|
+
raise EOFError if chunk.nil?
|
|
133
|
+
|
|
134
|
+
@buffer << chunk
|
|
135
|
+
return
|
|
136
|
+
end
|
|
137
|
+
rescue IOError, SystemCallError => error
|
|
138
|
+
raise ConnectionError, error.message, cause: error
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def error_response(message, exception)
|
|
142
|
+
error = if message.start_with?("NOAUTH", "WRONGPASS")
|
|
143
|
+
AuthenticationError.new(message)
|
|
144
|
+
else
|
|
145
|
+
CommandError.new(message)
|
|
146
|
+
end
|
|
147
|
+
raise error if exception
|
|
148
|
+
|
|
149
|
+
error
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidRedis
|
|
4
|
+
module Sentinel
|
|
5
|
+
class ResolveService < Service::Base
|
|
6
|
+
def call
|
|
7
|
+
@endpoints.each do |endpoint|
|
|
8
|
+
client = Config.new(**@specification.sentinel_client_options, **endpoint).new_client
|
|
9
|
+
target = resolve_target(client)
|
|
10
|
+
unless target
|
|
11
|
+
append_error(
|
|
12
|
+
:target_not_found,
|
|
13
|
+
"#{endpoint_label(endpoint)} does not know #{@specification.name.inspect}",
|
|
14
|
+
)
|
|
15
|
+
next
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
return {
|
|
19
|
+
target: target,
|
|
20
|
+
endpoint: endpoint,
|
|
21
|
+
discovered: discover_sentinels(client),
|
|
22
|
+
}
|
|
23
|
+
rescue SolidRedis::Error, KeyError, ArgumentError => error
|
|
24
|
+
append_error(:sentinel_unavailable, "#{endpoint_label(endpoint)}: #{error.message}")
|
|
25
|
+
sleep SentinelState::SENTINEL_DELAY
|
|
26
|
+
ensure
|
|
27
|
+
client&.close
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
nil
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def resolve_target(client)
|
|
36
|
+
if @specification.role == :master
|
|
37
|
+
resolve_master(client)
|
|
38
|
+
else
|
|
39
|
+
resolve_replica(client)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def resolve_master(client)
|
|
44
|
+
response = client.call("SENTINEL", "get-master-addr-by-name", @specification.name)
|
|
45
|
+
return unless response && response[0] && response[1]
|
|
46
|
+
|
|
47
|
+
{ host: response[0], port: Integer(response[1]) }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def resolve_replica(client)
|
|
51
|
+
replicas = client.call("SENTINEL", "replicas", @specification.name)
|
|
52
|
+
candidates = replicas.filter_map do |entry|
|
|
53
|
+
attributes = attributes(entry)
|
|
54
|
+
flags = attributes["flags"].to_s.split(",")
|
|
55
|
+
next if flags.any? { |flag| %w[s_down o_down disconnected].include?(flag) }
|
|
56
|
+
|
|
57
|
+
{ host: attributes.fetch("ip"), port: Integer(attributes.fetch("port")) }
|
|
58
|
+
end
|
|
59
|
+
candidates.sample
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def discover_sentinels(client)
|
|
63
|
+
client.call("SENTINEL", "sentinels", @specification.name).map do |entry|
|
|
64
|
+
values = attributes(entry)
|
|
65
|
+
{ host: values.fetch("ip"), port: Integer(values.fetch("port")) }
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def attributes(entry)
|
|
70
|
+
entry.is_a?(Hash) ? entry : Hash[*entry]
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def endpoint_label(endpoint)
|
|
74
|
+
"#{endpoint[:host]}:#{endpoint[:port]}"
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidRedis
|
|
4
|
+
class SentinelConfig
|
|
5
|
+
LOCAL_STATE_KEY = :solid_redis_sentinel_states
|
|
6
|
+
DEFAULT_SENTINEL_PORT = 26_379
|
|
7
|
+
|
|
8
|
+
attr_reader :name, :role, :reconnect_attempts, :redis_client_options,
|
|
9
|
+
:sentinel_client_options, :sentinel_endpoints
|
|
10
|
+
|
|
11
|
+
def initialize(
|
|
12
|
+
name:,
|
|
13
|
+
sentinels:,
|
|
14
|
+
role: :master,
|
|
15
|
+
sentinel_username: nil,
|
|
16
|
+
sentinel_password: nil,
|
|
17
|
+
sentinel_ssl: false,
|
|
18
|
+
sentinel_ssl_params: nil,
|
|
19
|
+
**redis_options
|
|
20
|
+
)
|
|
21
|
+
@name = String(name).dup.freeze
|
|
22
|
+
@role = normalize_role(role)
|
|
23
|
+
endpoints = sentinels.map { |endpoint| normalize_endpoint(endpoint) }
|
|
24
|
+
@sentinel_endpoints = Shareable.copy(endpoints, label: "sentinels")
|
|
25
|
+
raise ArgumentError, "At least one Sentinel endpoint is required" if @sentinel_endpoints.empty?
|
|
26
|
+
|
|
27
|
+
@reconnect_attempts = Integer(redis_options.fetch(:reconnect_attempts, 2))
|
|
28
|
+
raise ArgumentError, "reconnect_attempts must not be negative" if @reconnect_attempts.negative?
|
|
29
|
+
|
|
30
|
+
@redis_client_options = Shareable.copy(redis_options, label: "Redis options")
|
|
31
|
+
@sentinel_client_options = Shareable.copy(
|
|
32
|
+
sentinel_options(
|
|
33
|
+
redis_options,
|
|
34
|
+
username: sentinel_username,
|
|
35
|
+
password: sentinel_password,
|
|
36
|
+
ssl: sentinel_ssl,
|
|
37
|
+
ssl_params: sentinel_ssl_params,
|
|
38
|
+
),
|
|
39
|
+
label: "Sentinel options",
|
|
40
|
+
)
|
|
41
|
+
Ractor.make_shareable(self)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def sentinel?
|
|
45
|
+
true
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def resolved?
|
|
49
|
+
state.resolved?
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def resolve
|
|
53
|
+
state.resolve
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def reset
|
|
57
|
+
state.reset
|
|
58
|
+
self
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def host
|
|
62
|
+
resolve.host
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def port
|
|
66
|
+
resolve.port
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def path
|
|
70
|
+
nil
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def server_key
|
|
74
|
+
resolve.server_key
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def server_url
|
|
78
|
+
resolve.server_url
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def sentinels
|
|
82
|
+
state.sentinel_endpoints
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def new_client(**options)
|
|
86
|
+
Client.new(self, **options)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def new_pool(**options)
|
|
90
|
+
Pool.new(self, **options)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def notify(event, *arguments)
|
|
94
|
+
callbacks = redis_client_options[:callbacks]
|
|
95
|
+
return unless callbacks&.respond_to?(event)
|
|
96
|
+
|
|
97
|
+
callbacks.respond_with(event, *arguments)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def inspect
|
|
101
|
+
"#<#{self.class.name} name=#{name.inspect} role=#{role.inspect} sentinels=#{sentinel_endpoints.length}>"
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
private
|
|
105
|
+
|
|
106
|
+
def state
|
|
107
|
+
registry = Ractor.current[LOCAL_STATE_KEY]
|
|
108
|
+
unless registry
|
|
109
|
+
registry = { mutex: Mutex.new, states: {} }
|
|
110
|
+
Ractor.current[LOCAL_STATE_KEY] = registry
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
registry[:mutex].synchronize do
|
|
114
|
+
registry[:states][self] ||= SentinelState.new(self)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def normalize_role(role)
|
|
119
|
+
role = role.to_sym
|
|
120
|
+
role = :replica if role == :slave
|
|
121
|
+
return role if %i[master replica].include?(role)
|
|
122
|
+
|
|
123
|
+
raise ArgumentError, "role must be :master or :replica"
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def normalize_endpoint(endpoint)
|
|
127
|
+
return endpoint.transform_keys(&:to_sym) unless endpoint.is_a?(String)
|
|
128
|
+
|
|
129
|
+
uri = URI.parse(endpoint)
|
|
130
|
+
unless %w[redis rediss].include?(uri.scheme)
|
|
131
|
+
raise ArgumentError, "Unsupported Sentinel URL scheme: #{uri.scheme.inspect}"
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
{
|
|
135
|
+
host: uri.host || "127.0.0.1",
|
|
136
|
+
port: uri.port || DEFAULT_SENTINEL_PORT,
|
|
137
|
+
username: decode(uri.user),
|
|
138
|
+
password: decode(uri.password),
|
|
139
|
+
ssl: uri.scheme == "rediss",
|
|
140
|
+
}.compact
|
|
141
|
+
rescue URI::InvalidURIError => error
|
|
142
|
+
raise ArgumentError, "Invalid Sentinel URL: #{error.message}", cause: error
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def sentinel_options(redis_options, **credentials)
|
|
146
|
+
timeout = redis_options.fetch(:timeout, Config::DEFAULT_TIMEOUT)
|
|
147
|
+
{
|
|
148
|
+
**credentials,
|
|
149
|
+
connect_timeout: redis_options.fetch(:connect_timeout, timeout),
|
|
150
|
+
read_timeout: redis_options.fetch(:read_timeout, timeout),
|
|
151
|
+
write_timeout: redis_options.fetch(:write_timeout, timeout),
|
|
152
|
+
reconnect_attempts: 0,
|
|
153
|
+
}.compact
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def decode(value)
|
|
157
|
+
URI.decode_www_form_component(value) if value
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidRedis
|
|
4
|
+
class SentinelState
|
|
5
|
+
SENTINEL_DELAY = 0.05
|
|
6
|
+
|
|
7
|
+
def initialize(specification)
|
|
8
|
+
@specification = specification
|
|
9
|
+
@sentinels = specification.sentinel_endpoints.dup
|
|
10
|
+
@resolved = nil
|
|
11
|
+
@mutex = Mutex.new
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def resolve
|
|
15
|
+
@mutex.synchronize do
|
|
16
|
+
@resolved ||= resolve_target
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def reset
|
|
21
|
+
@mutex.synchronize { @resolved = nil }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def resolved?
|
|
25
|
+
@mutex.synchronize { !@resolved.nil? }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def sentinel_endpoints
|
|
29
|
+
@mutex.synchronize { @sentinels.dup }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def resolve_target
|
|
35
|
+
result = Sentinel::ResolveService.call(
|
|
36
|
+
specification: @specification,
|
|
37
|
+
endpoints: @sentinels.dup,
|
|
38
|
+
)
|
|
39
|
+
unless result.successful? && result.result
|
|
40
|
+
details = result.errors.map(&:message).join("; ")
|
|
41
|
+
raise ConnectionError, "No Sentinel available for #{@specification.name.inspect}: #{details}"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
resolution = result.result
|
|
45
|
+
resolution[:discovered].each do |attributes|
|
|
46
|
+
endpoint = Shareable.copy(
|
|
47
|
+
attributes,
|
|
48
|
+
label: "discovered Sentinel",
|
|
49
|
+
)
|
|
50
|
+
@sentinels << endpoint unless same_endpoint?(endpoint)
|
|
51
|
+
end
|
|
52
|
+
promote(resolution[:endpoint])
|
|
53
|
+
target = Config.new(**@specification.redis_client_options, **resolution[:target])
|
|
54
|
+
@specification.notify(:resolved, @specification.name, target.server_url)
|
|
55
|
+
target
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def same_endpoint?(candidate)
|
|
59
|
+
@sentinels.any? do |endpoint|
|
|
60
|
+
endpoint[:host] == candidate[:host] && endpoint[:port] == candidate[:port]
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def promote(endpoint)
|
|
65
|
+
@sentinels.delete(endpoint)
|
|
66
|
+
@sentinels.unshift(endpoint)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidRedis
|
|
4
|
+
module Shareable
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def copy(value, label:)
|
|
8
|
+
Ractor.make_shareable(value, copy: true)
|
|
9
|
+
rescue Ractor::Error, TypeError => error
|
|
10
|
+
raise ArgumentError, "#{label} must be Ractor-shareable: #{error.message}", cause: error
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
data/lib/solid_redis.rb
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "base_service"
|
|
4
|
+
require "callback_collection"
|
|
5
|
+
|
|
6
|
+
require_relative "solid_redis/version"
|
|
7
|
+
require_relative "solid_redis/errors"
|
|
8
|
+
require_relative "solid_redis/shareable"
|
|
9
|
+
require_relative "solid_redis/config"
|
|
10
|
+
require_relative "solid_redis/resp"
|
|
11
|
+
require_relative "solid_redis/client"
|
|
12
|
+
require_relative "solid_redis/sentinel/resolve_service"
|
|
13
|
+
require_relative "solid_redis/sentinel_state"
|
|
14
|
+
require_relative "solid_redis/sentinel_config"
|
|
15
|
+
require_relative "solid_redis/pool"
|
|
16
|
+
|
|
17
|
+
module SolidRedis
|
|
18
|
+
module_function
|
|
19
|
+
|
|
20
|
+
def config(**options)
|
|
21
|
+
Config.new(**options)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def sentinel(**options)
|
|
25
|
+
SentinelConfig.new(**options)
|
|
26
|
+
end
|
|
27
|
+
end
|