async-redis 0.9.0 → 0.10.1

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 97fe8ed81d0096bd994cbbd3511fc3acb7c0c4629cfafdac47e5c237137083f1
4
- data.tar.gz: c8a5b6a33efb6de45dc7cc2154b694fbd9337a14c485f85f1693bfeabbcc0f79
3
+ metadata.gz: da847e343c291506a20337eec6170867a0e0bf295b713ece60fd7f48928d873f
4
+ data.tar.gz: 2ea6e2720f556084f57415501a1964c0bb2a93a411a9cb52138dbb8ed141c45b
5
5
  SHA512:
6
- metadata.gz: ad448e4900402ef3e974a247c27a6a8fa5bf65d47fda67b46260830ecfb76a185c86a03ff0c38cd18ae1d628a7ce332d6952e26704d357f3858458fe593fb49d
7
- data.tar.gz: 83535dc337e792808e6e3033864e393cb075a5d709bd5a8c507577d8b792f0600388f98cd28997c891102c36dc79062c6327004359f51c4677b50bab42eced8a
6
+ metadata.gz: 9ac74325525a0dc00b06aaee1fa505566bce74f4806c07f26a8581ac7b20db79f11cbb052d6d598d02b7eec94aae6a142d88da00eb01e4b0a8d72e134ef5a798
7
+ data.tar.gz: 2b873fd8ee1cfffda74d301265ec9477a34dfe571d30d4c36d195cf39b089c22635bc74c07ade143ca46a5e169d0db226470a6149ce912be3b3da0db96e26b49
checksums.yaml.gz.sig CHANGED
Binary file
data/changes.md ADDED
@@ -0,0 +1,46 @@
1
+ # v0.10.0
2
+
3
+ ## Cluster Client
4
+
5
+ `Async::Redis::ClusterClient` is a new class that provides a high-level interface to a Redis Cluster. Due to the way clustering works, it does not provide the same interface as the `Async::Redis::Client` class. Instead, you must request an appropriate client for the key you are working with.
6
+
7
+ ```ruby
8
+ endpoints = [
9
+ Async::Redis::Endpoint.parse("redis://redis-a"),
10
+ Async::Redis::Endpoint.parse("redis://redis-b"),
11
+ Async::Redis::Endpoint.parse("redis://redis-c"),
12
+ ]
13
+
14
+ cluster_client = Async::Redis::ClusterClient.new(endpoints)
15
+
16
+ cluster_client.clients_for("key") do |client|
17
+ puts client.get("key")
18
+ end
19
+ ```
20
+
21
+ ## Sentinel Client
22
+
23
+ The previous implementation `Async::Redis::SentinelsClient` has been replaced with `Async::Redis::SentinelClient`. This new class uses `Async::Redis::Endpoint` objects to represent the sentinels and the master.
24
+
25
+ ```ruby
26
+ sentinels = [
27
+ Async::Redis::Endpoint.parse("redis://redis-sentinel-a"),
28
+ Async::Redis::Endpoint.parse("redis://redis-sentinel-b"),
29
+ Async::Redis::Endpoint.parse("redis://redis-sentinel-c"),
30
+ ]
31
+
32
+ master_client = Async::Redis::SentinelClient.new(sentinels)
33
+ slave_client = Async::Redis::SentinelClient.new(sentinels, role: :slave)
34
+
35
+ master_client.session do |session|
36
+ session.set("key", "value")
37
+ end
38
+
39
+ slave_client.session do |session|
40
+ puts session.get("key")
41
+ end
42
+ ```
43
+
44
+ ## Integration Tests
45
+
46
+ Integration tests for Redis Cluster and Sentinel have been added, using `docker-compose` to start the required services and run the tests. These tests are not part of the default test suite and must be run separately. See the documentation in the `sentinel/` and `cluster/` directories for more information.
@@ -26,6 +26,65 @@ module Async
26
26
  class Client
27
27
  include ::Protocol::Redis::Methods
28
28
 
29
+ module Methods
30
+ def subscribe(*channels)
31
+ context = Context::Subscribe.new(@pool, channels)
32
+
33
+ return context unless block_given?
34
+
35
+ begin
36
+ yield context
37
+ ensure
38
+ context.close
39
+ end
40
+ end
41
+
42
+ def transaction(&block)
43
+ context = Context::Transaction.new(@pool)
44
+
45
+ return context unless block_given?
46
+
47
+ begin
48
+ yield context
49
+ ensure
50
+ context.close
51
+ end
52
+ end
53
+
54
+ alias multi transaction
55
+
56
+ def pipeline(&block)
57
+ context = Context::Pipeline.new(@pool)
58
+
59
+ return context unless block_given?
60
+
61
+ begin
62
+ yield context
63
+ ensure
64
+ context.close
65
+ end
66
+ end
67
+
68
+ # Deprecated.
69
+ alias nested pipeline
70
+
71
+ def call(*arguments)
72
+ @pool.acquire do |connection|
73
+ connection.write_request(arguments)
74
+
75
+ connection.flush
76
+
77
+ return connection.read_response
78
+ end
79
+ end
80
+
81
+ def close
82
+ @pool.close
83
+ end
84
+ end
85
+
86
+ include Methods
87
+
29
88
  def initialize(endpoint = Endpoint.local, protocol: endpoint.protocol, **options)
30
89
  @endpoint = endpoint
31
90
  @protocol = protocol
@@ -38,8 +97,8 @@ module Async
38
97
 
39
98
  # @return [client] if no block provided.
40
99
  # @yield [client, task] yield the client in an async task.
41
- def self.open(*arguments, &block)
42
- client = self.new(*arguments)
100
+ def self.open(*arguments, **options, &block)
101
+ client = self.new(*arguments, **options)
43
102
 
44
103
  return client unless block_given?
45
104
 
@@ -52,61 +111,6 @@ module Async
52
111
  end.wait
53
112
  end
54
113
 
55
- def close
56
- @pool.close
57
- end
58
-
59
- def subscribe(*channels)
60
- context = Context::Subscribe.new(@pool, channels)
61
-
62
- return context unless block_given?
63
-
64
- begin
65
- yield context
66
- ensure
67
- context.close
68
- end
69
- end
70
-
71
- def transaction(&block)
72
- context = Context::Transaction.new(@pool)
73
-
74
- return context unless block_given?
75
-
76
- begin
77
- yield context
78
- ensure
79
- context.close
80
- end
81
- end
82
-
83
- alias multi transaction
84
-
85
- def pipeline(&block)
86
- context = Context::Pipeline.new(@pool)
87
-
88
- return context unless block_given?
89
-
90
- begin
91
- yield context
92
- ensure
93
- context.close
94
- end
95
- end
96
-
97
- # Deprecated.
98
- alias nested pipeline
99
-
100
- def call(*arguments)
101
- @pool.acquire do |connection|
102
- connection.write_request(arguments)
103
-
104
- connection.flush
105
-
106
- return connection.read_response
107
- end
108
- end
109
-
110
114
  protected
111
115
 
112
116
  def connect(**options)
@@ -0,0 +1,216 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2020, by David Ortiz.
5
+ # Copyright, 2023-2024, by Samuel Williams.
6
+
7
+ require_relative 'client'
8
+ require 'io/stream'
9
+
10
+ module Async
11
+ module Redis
12
+ class ClusterClient
13
+ class ReloadError < StandardError
14
+ end
15
+
16
+ class SlotError < StandardError
17
+ end
18
+
19
+ Node = Struct.new(:id, :endpoint, :role, :health, :client)
20
+
21
+ class RangeMap
22
+ def initialize
23
+ @ranges = []
24
+ end
25
+
26
+ def add(range, value)
27
+ @ranges << [range, value]
28
+
29
+ return value
30
+ end
31
+
32
+ def find(key)
33
+ @ranges.each do |range, value|
34
+ return value if range.include?(key)
35
+ end
36
+
37
+ if block_given?
38
+ return yield
39
+ end
40
+
41
+ return nil
42
+ end
43
+
44
+ def each
45
+ @ranges.each do |range, value|
46
+ yield value
47
+ end
48
+ end
49
+
50
+ def clear
51
+ @ranges.clear
52
+ end
53
+ end
54
+
55
+ # Create a new instance of the cluster client.
56
+ #
57
+ # @property endpoints [Array(Endpoint)] The list of cluster endpoints.
58
+ def initialize(endpoints, **options)
59
+ @endpoints = endpoints
60
+ @shards = nil
61
+ end
62
+
63
+ def clients_for(*keys, role: :master, attempts: 3)
64
+ slots = slots_for(keys)
65
+
66
+ slots.each do |slot, keys|
67
+ yield client_for(slot, role), keys
68
+ end
69
+ rescue ServerError => error
70
+ Console.warn(self, error)
71
+
72
+ if error.message =~ /MOVED|ASK/
73
+ reload_cluster!
74
+
75
+ attempts -= 1
76
+
77
+ retry if attempts > 0
78
+
79
+ raise
80
+ else
81
+ raise
82
+ end
83
+ end
84
+
85
+ def client_for(slot, role = :master)
86
+ unless @shards
87
+ reload_cluster!
88
+ end
89
+
90
+ if nodes = @shards.find(slot)
91
+ nodes = nodes.select{|node| node.role == role}
92
+ else
93
+ raise SlotError, "No nodes found for slot #{slot}"
94
+ end
95
+
96
+ if node = nodes.sample
97
+ return (node.client ||= Client.new(node.endpoint))
98
+ end
99
+ end
100
+
101
+ protected
102
+
103
+ def reload_cluster!(endpoints = @endpoints)
104
+ @endpoints.each do |endpoint|
105
+ client = Client.new(endpoint)
106
+
107
+ shards = RangeMap.new
108
+ endpoints = []
109
+
110
+ client.call('CLUSTER', 'SHARDS').each do |shard|
111
+ shard = shard.each_slice(2).to_h
112
+
113
+ slots = shard['slots']
114
+ range = Range.new(*slots)
115
+
116
+ nodes = shard['nodes'].map do |node|
117
+ node = node.each_slice(2).to_h
118
+ endpoint = Endpoint.remote(node['ip'], node['port'])
119
+
120
+ # Collect all endpoints:
121
+ endpoints << endpoint
122
+
123
+ Node.new(node['id'], endpoint, node['role'].to_sym, node['health'].to_sym)
124
+ end
125
+
126
+ shards.add(range, nodes)
127
+ end
128
+
129
+ @shards = shards
130
+ # @endpoints = @endpoints | endpoints
131
+
132
+ return true
133
+ rescue Errno::ECONNREFUSED
134
+ next
135
+ end
136
+
137
+ raise ReloadError, "Failed to reload cluster configuration."
138
+ end
139
+
140
+ XMODEM_CRC16_LOOKUP = [
141
+ 0x0000, 0x1021, 0x2042, 0x3063, 0x4084, 0x50a5, 0x60c6, 0x70e7,
142
+ 0x8108, 0x9129, 0xa14a, 0xb16b, 0xc18c, 0xd1ad, 0xe1ce, 0xf1ef,
143
+ 0x1231, 0x0210, 0x3273, 0x2252, 0x52b5, 0x4294, 0x72f7, 0x62d6,
144
+ 0x9339, 0x8318, 0xb37b, 0xa35a, 0xd3bd, 0xc39c, 0xf3ff, 0xe3de,
145
+ 0x2462, 0x3443, 0x0420, 0x1401, 0x64e6, 0x74c7, 0x44a4, 0x5485,
146
+ 0xa56a, 0xb54b, 0x8528, 0x9509, 0xe5ee, 0xf5cf, 0xc5ac, 0xd58d,
147
+ 0x3653, 0x2672, 0x1611, 0x0630, 0x76d7, 0x66f6, 0x5695, 0x46b4,
148
+ 0xb75b, 0xa77a, 0x9719, 0x8738, 0xf7df, 0xe7fe, 0xd79d, 0xc7bc,
149
+ 0x48c4, 0x58e5, 0x6886, 0x78a7, 0x0840, 0x1861, 0x2802, 0x3823,
150
+ 0xc9cc, 0xd9ed, 0xe98e, 0xf9af, 0x8948, 0x9969, 0xa90a, 0xb92b,
151
+ 0x5af5, 0x4ad4, 0x7ab7, 0x6a96, 0x1a71, 0x0a50, 0x3a33, 0x2a12,
152
+ 0xdbfd, 0xcbdc, 0xfbbf, 0xeb9e, 0x9b79, 0x8b58, 0xbb3b, 0xab1a,
153
+ 0x6ca6, 0x7c87, 0x4ce4, 0x5cc5, 0x2c22, 0x3c03, 0x0c60, 0x1c41,
154
+ 0xedae, 0xfd8f, 0xcdec, 0xddcd, 0xad2a, 0xbd0b, 0x8d68, 0x9d49,
155
+ 0x7e97, 0x6eb6, 0x5ed5, 0x4ef4, 0x3e13, 0x2e32, 0x1e51, 0x0e70,
156
+ 0xff9f, 0xefbe, 0xdfdd, 0xcffc, 0xbf1b, 0xaf3a, 0x9f59, 0x8f78,
157
+ 0x9188, 0x81a9, 0xb1ca, 0xa1eb, 0xd10c, 0xc12d, 0xf14e, 0xe16f,
158
+ 0x1080, 0x00a1, 0x30c2, 0x20e3, 0x5004, 0x4025, 0x7046, 0x6067,
159
+ 0x83b9, 0x9398, 0xa3fb, 0xb3da, 0xc33d, 0xd31c, 0xe37f, 0xf35e,
160
+ 0x02b1, 0x1290, 0x22f3, 0x32d2, 0x4235, 0x5214, 0x6277, 0x7256,
161
+ 0xb5ea, 0xa5cb, 0x95a8, 0x8589, 0xf56e, 0xe54f, 0xd52c, 0xc50d,
162
+ 0x34e2, 0x24c3, 0x14a0, 0x0481, 0x7466, 0x6447, 0x5424, 0x4405,
163
+ 0xa7db, 0xb7fa, 0x8799, 0x97b8, 0xe75f, 0xf77e, 0xc71d, 0xd73c,
164
+ 0x26d3, 0x36f2, 0x0691, 0x16b0, 0x6657, 0x7676, 0x4615, 0x5634,
165
+ 0xd94c, 0xc96d, 0xf90e, 0xe92f, 0x99c8, 0x89e9, 0xb98a, 0xa9ab,
166
+ 0x5844, 0x4865, 0x7806, 0x6827, 0x18c0, 0x08e1, 0x3882, 0x28a3,
167
+ 0xcb7d, 0xdb5c, 0xeb3f, 0xfb1e, 0x8bf9, 0x9bd8, 0xabbb, 0xbb9a,
168
+ 0x4a75, 0x5a54, 0x6a37, 0x7a16, 0x0af1, 0x1ad0, 0x2ab3, 0x3a92,
169
+ 0xfd2e, 0xed0f, 0xdd6c, 0xcd4d, 0xbdaa, 0xad8b, 0x9de8, 0x8dc9,
170
+ 0x7c26, 0x6c07, 0x5c64, 0x4c45, 0x3ca2, 0x2c83, 0x1ce0, 0x0cc1,
171
+ 0xef1f, 0xff3e, 0xcf5d, 0xdf7c, 0xaf9b, 0xbfba, 0x8fd9, 0x9ff8,
172
+ 0x6e17, 0x7e36, 0x4e55, 0x5e74, 0x2e93, 0x3eb2, 0x0ed1, 0x1ef0
173
+ ].freeze
174
+
175
+ # This is the CRC16 algorithm used by Redis Cluster to hash keys.
176
+ # Copied from https://github.com/antirez/redis-rb-cluster/blob/master/crc16.rb
177
+ def crc16(bytes)
178
+ sum = 0
179
+
180
+ bytes.each_byte do |byte|
181
+ sum = ((sum << 8) & 0xffff) ^ XMODEM_CRC16_LOOKUP[((sum >> 8) ^ byte) & 0xff]
182
+ end
183
+
184
+ return sum
185
+ end
186
+
187
+ public
188
+
189
+ HASH_SLOTS = 16_384
190
+
191
+ # Return Redis::Client for a given key.
192
+ # Modified from https://github.com/antirez/redis-rb-cluster/blob/master/cluster.rb#L104-L117
193
+ def slot_for(key)
194
+ key = key.to_s
195
+
196
+ if s = key.index('{')
197
+ if e = key.index('}', s + 1) and e != s + 1
198
+ key = key[s + 1..e - 1]
199
+ end
200
+ end
201
+
202
+ return crc16(key) % HASH_SLOTS
203
+ end
204
+
205
+ def slots_for(keys)
206
+ slots = Hash.new{|hash, key| hash[key] = []}
207
+
208
+ keys.each do |key|
209
+ slots[slot_for(key)] << key
210
+ end
211
+
212
+ return slots
213
+ end
214
+ end
215
+ end
216
+ end
@@ -23,7 +23,11 @@ module Async
23
23
 
24
24
  def self.local(**options)
25
25
  self.new(LOCALHOST, **options)
26
- end
26
+ end
27
+
28
+ def self.remote(host, port = 6379, **options)
29
+ self.new(URI.parse("redis://#{host}:#{port}"), **options)
30
+ end
27
31
 
28
32
  SCHEMES = {
29
33
  'redis' => URI::Generic,
@@ -40,7 +44,7 @@ module Async
40
44
  #
41
45
  # @parameter scheme [String] The scheme to use, e.g. "redis" or "rediss".
42
46
  # @parameter hostname [String] The hostname to connect to (or bind to).
43
- # @parameter *options [Hash] Additional options, passed to {#initialize}.
47
+ # @parameter options [Hash] Additional options, passed to {#initialize}.
44
48
  def self.for(scheme, hostname, credentials: nil, port: nil, database: nil, **options)
45
49
  uri_klass = SCHEMES.fetch(scheme.downcase) do
46
50
  raise ArgumentError, "Unsupported scheme: #{scheme.inspect}"
@@ -70,9 +74,10 @@ module Async
70
74
  #
71
75
  # @parameter url [URI] The URL to connect to.
72
76
  # @parameter endpoint [Endpoint] The underlying endpoint to use.
73
- # @parameter scheme [String] The scheme to use, e.g. "redis" or "rediss".
74
- # @parameter hostname [String] The hostname to connect to (or bind to), overrides the URL hostname (used for SNI).
75
- # @parameter port [Integer] The port to bind to, overrides the URL port.
77
+ # @option scheme [String] The scheme to use, e.g. "redis" or "rediss".
78
+ # @option hostname [String] The hostname to connect to (or bind to), overrides the URL hostname (used for SNI).
79
+ # @option port [Integer] The port to bind to, overrides the URL port.
80
+ # @option ssl_context [OpenSSL::SSL::SSLContext] The SSL context to use for secure connections.
76
81
  def initialize(url, endpoint = nil, **options)
77
82
  super(**options)
78
83
 
@@ -0,0 +1,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2020, by David Ortiz.
5
+ # Copyright, 2023-2024, by Samuel Williams.
6
+
7
+ require_relative 'client'
8
+ require 'io/stream'
9
+
10
+ module Async
11
+ module Redis
12
+ class SentinelClient
13
+ DEFAULT_MASTER_NAME = 'mymaster'
14
+
15
+ include ::Protocol::Redis::Methods
16
+ include Client::Methods
17
+
18
+ # Create a new instance of the SentinelClient.
19
+ #
20
+ # @property endpoints [Array(Endpoint)] The list of sentinel endpoints.
21
+ # @property master_name [String] The name of the master instance, defaults to 'mymaster'.
22
+ # @property role [Symbol] The role of the instance that you want to connect to, either `:master` or `:slave`.
23
+ # @property protocol [Protocol] The protocol to use when connecting to the actual Redis server, defaults to {Protocol::RESP2}.
24
+ def initialize(endpoints, master_name: DEFAULT_MASTER_NAME, role: :master, protocol: Protocol::RESP2, **options)
25
+ @endpoints = endpoints
26
+ @master_name = master_name
27
+ @role = role
28
+ @protocol = protocol
29
+
30
+ # A cache of sentinel connections.
31
+ @sentinels = {}
32
+
33
+ @pool = connect(**options)
34
+ end
35
+
36
+ # @attribute [String] The name of the master instance.
37
+ attr :master_name
38
+
39
+ # @attribute [Symbol] The role of the instance that you want to connect to.
40
+ attr :role
41
+
42
+ def resolve_address(role = @role)
43
+ case role
44
+ when :master
45
+ resolve_master
46
+ when :slave
47
+ resolve_slave
48
+ else
49
+ raise ArgumentError, "Unknown instance role #{role}"
50
+ end => address
51
+
52
+ Console.debug(self, "Resolved #{@role} address: #{address}")
53
+
54
+ address or raise RuntimeError, "Unable to fetch #{role} via Sentinel."
55
+ end
56
+
57
+ def close
58
+ super
59
+
60
+ @sentinels.each do |_, client|
61
+ client.close
62
+ end
63
+ end
64
+
65
+ def failover(name = @master_name)
66
+ sentinels do |client|
67
+ return client.call('SENTINEL', 'FAILOVER', name)
68
+ end
69
+ end
70
+
71
+ def masters
72
+ sentinels do |client|
73
+ return client.call('SENTINEL', 'MASTERS').map{|fields| fields.each_slice(2).to_h}
74
+ end
75
+ end
76
+
77
+ def master(name = @master_name)
78
+ sentinels do |client|
79
+ return client.call('SENTINEL', 'MASTER', name).each_slice(2).to_h
80
+ end
81
+ end
82
+
83
+ def resolve_master
84
+ sentinels do |client|
85
+ begin
86
+ address = client.call('SENTINEL', 'GET-MASTER-ADDR-BY-NAME', @master_name)
87
+ rescue Errno::ECONNREFUSED
88
+ next
89
+ end
90
+
91
+ return Endpoint.remote(address[0], address[1]) if address
92
+ end
93
+
94
+ return nil
95
+ end
96
+
97
+ def resolve_slave
98
+ sentinels do |client|
99
+ begin
100
+ reply = client.call('SENTINEL', 'SLAVES', @master_name)
101
+ rescue Errno::ECONNREFUSED
102
+ next
103
+ end
104
+
105
+ slaves = available_slaves(reply)
106
+ next if slaves.empty?
107
+
108
+ slave = select_slave(slaves)
109
+ return Endpoint.remote(slave['ip'], slave['port'])
110
+ end
111
+
112
+ return nil
113
+ end
114
+
115
+ protected
116
+
117
+ # Override the parent method. The only difference is that this one needs to resolve the master/slave address.
118
+ def connect(**options)
119
+ Async::Pool::Controller.wrap(**options) do
120
+ endpoint = resolve_address
121
+ peer = endpoint.connect
122
+ stream = ::IO::Stream(peer)
123
+
124
+ @protocol.client(stream)
125
+ end
126
+ end
127
+
128
+ def sentinels
129
+ @endpoints.map do |endpoint|
130
+ @sentinels[endpoint] ||= Client.new(endpoint)
131
+
132
+ yield @sentinels[endpoint]
133
+ end
134
+ end
135
+
136
+ def available_slaves(reply)
137
+ # The reply is an array with the format: [field1, value1, field2,
138
+ # value2, etc.].
139
+ # When a slave is marked as down by the sentinel, the "flags" field
140
+ # (comma-separated array) contains the "s_down" value.
141
+ slaves = reply.map{|fields| fields.each_slice(2).to_h}
142
+
143
+ slaves.reject do |slave|
144
+ slave['flags'].split(',').include?('s_down')
145
+ end
146
+ end
147
+
148
+ def select_slave(available_slaves)
149
+ available_slaves.sample
150
+ end
151
+ end
152
+ end
153
+ end
@@ -5,6 +5,6 @@
5
5
 
6
6
  module Async
7
7
  module Redis
8
- VERSION = "0.9.0"
8
+ VERSION = "0.10.1"
9
9
  end
10
10
  end
data/lib/async/redis.rb CHANGED
@@ -6,4 +6,6 @@
6
6
 
7
7
  require_relative 'redis/version'
8
8
  require_relative 'redis/client'
9
- require_relative 'redis/sentinels'
9
+
10
+ require_relative 'redis/cluster_client'
11
+ require_relative 'redis/sentinel_client'
data.tar.gz.sig CHANGED
Binary file
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: async-redis
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.9.0
4
+ version: 0.10.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Samuel Williams
@@ -48,7 +48,7 @@ cert_chain:
48
48
  Q2K9NVun/S785AP05vKkXZEFYxqG6EW012U4oLcFl5MySFajYXRYbuUpH6AY+HP8
49
49
  voD0MPg1DssDLKwXyt1eKD/+Fq0bFWhwVM/1XiAXL7lyYUyOq24KHgQ2Csg=
50
50
  -----END CERTIFICATE-----
51
- date: 2024-08-16 00:00:00.000000000 Z
51
+ date: 2024-08-22 00:00:00.000000000 Z
52
52
  dependencies:
53
53
  - !ruby/object:Gem::Dependency
54
54
  name: async
@@ -126,8 +126,10 @@ executables: []
126
126
  extensions: []
127
127
  extra_rdoc_files: []
128
128
  files:
129
+ - changes.md
129
130
  - lib/async/redis.rb
130
131
  - lib/async/redis/client.rb
132
+ - lib/async/redis/cluster_client.rb
131
133
  - lib/async/redis/context/generic.rb
132
134
  - lib/async/redis/context/pipeline.rb
133
135
  - lib/async/redis/context/subscribe.rb
@@ -137,7 +139,7 @@ files:
137
139
  - lib/async/redis/protocol/authenticated.rb
138
140
  - lib/async/redis/protocol/resp2.rb
139
141
  - lib/async/redis/protocol/selected.rb
140
- - lib/async/redis/sentinels.rb
142
+ - lib/async/redis/sentinel_client.rb
141
143
  - lib/async/redis/version.rb
142
144
  - license.md
143
145
  - readme.md
metadata.gz.sig CHANGED
Binary file
@@ -1,104 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- # Released under the MIT License.
4
- # Copyright, 2020, by David Ortiz.
5
- # Copyright, 2023-2024, by Samuel Williams.
6
-
7
- require 'io/stream'
8
-
9
- module Async
10
- module Redis
11
- class SentinelsClient < Client
12
- def initialize(master_name, sentinels, role = :master, protocol = Protocol::RESP2, **options)
13
- @master_name = master_name
14
-
15
- @sentinel_endpoints = sentinels.map do |sentinel|
16
- ::IO::Endpoint.tcp(sentinel[:host], sentinel[:port])
17
- end
18
-
19
- @role = role
20
- @protocol = protocol
21
- @pool = connect(**options)
22
- end
23
-
24
- private
25
-
26
- # Override the parent method. The only difference is that this one needs
27
- # to resolve the master/slave address.
28
- def connect(**options)
29
- Async::Pool::Controller.wrap(**options) do
30
- endpoint = resolve_address
31
- peer = endpoint.connect
32
- stream = ::IO::Stream(peer)
33
-
34
- @protocol.client(stream)
35
- end
36
- end
37
-
38
- def resolve_address
39
- case @role
40
- when :master
41
- resolve_master
42
- when :slave
43
- resolve_slave
44
- else
45
- raise ArgumentError, "Unknown instance role #{@role}"
46
- end => address
47
-
48
- address or raise RuntimeError, "Unable to fetch #{@role} via Sentinel."
49
- end
50
-
51
- def resolve_master
52
- @sentinel_endpoints.each do |sentinel_endpoint|
53
- client = Client.new(sentinel_endpoint, protocol: @protocol)
54
-
55
- begin
56
- address = client.call('sentinel', 'get-master-addr-by-name', @master_name)
57
- rescue Errno::ECONNREFUSED
58
- next
59
- end
60
-
61
- return ::IO::Endpoint.tcp(address[0], address[1]) if address
62
- end
63
-
64
- nil
65
- end
66
-
67
- def resolve_slave
68
- @sentinel_endpoints.each do |sentinel_endpoint|
69
- client = Client.new(sentinel_endpoint, protocol: @protocol)
70
-
71
- begin
72
- reply = client.call('sentinel', 'slaves', @master_name)
73
- rescue Errno::ECONNREFUSED
74
- next
75
- end
76
-
77
- slaves = available_slaves(reply)
78
- next if slaves.empty?
79
-
80
- slave = select_slave(slaves)
81
- return ::IO::Endpoint.tcp(slave['ip'], slave['port'])
82
- end
83
-
84
- nil
85
- end
86
-
87
- def available_slaves(reply)
88
- # The reply is an array with the format: [field1, value1, field2,
89
- # value2, etc.].
90
- # When a slave is marked as down by the sentinel, the "flags" field
91
- # (comma-separated array) contains the "s_down" value.
92
- slaves = reply.map{|fields| fields.each_slice(2).to_h}
93
-
94
- slaves.reject do |slave|
95
- slave['flags'].split(',').include?('s_down')
96
- end
97
- end
98
-
99
- def select_slave(available_slaves)
100
- available_slaves.sample
101
- end
102
- end
103
- end
104
- end