async-redis 0.9.0 → 0.10.1

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 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