async-redis 0.8.1 → 0.10.0

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: c61c1f4d04e59d7a09f58429a04438e674d7839bc6d6411b3fc33668d4068c4b
4
- data.tar.gz: f13579f444972e6b5a7d1388f37d20d74978e867130b5cdad303e829905a6ca6
3
+ metadata.gz: 31704f6caaeffba85223e9917ac746128c05862b53738ab42d03f0c91bd808c3
4
+ data.tar.gz: 72afd9739ce773d441870c238e9f2600a6ac3e3d46b13bb66c3a7f3febf3f4d6
5
5
  SHA512:
6
- metadata.gz: 99b7e9bb4091f09a6b51a1216a44b69ebd63918df6755d447f327b69bdf148cbf91e7a1b1a5adbe75ccff93f36be93cdf0d5034bed624743fcac23524d285df7
7
- data.tar.gz: f9afbc599da80472df731cb370c77422184c3a41a4ab0efd0df638fad31f9a68fa7cde775d7451826cf781c1d29e9c86d3d27c0a94a6b1605b121dd33dddd13f
6
+ metadata.gz: 5b12190eab39bba378d6ca1793abd3e56ab513a13af4bedfbb332d3d7e869d5b61c9d3958eaa96f22987e341e50b6acdc018c84c1d88effea3fee72fd132a7fe
7
+ data.tar.gz: '086307e4ec9c27d407cce2e61e99c6b4712e1d1fb3b8bca660c7e1a4270a9aa9061c36dd5a801e3edfa42eb52a6f41a41c858830ccc7e673e38917d0a4f9e700'
checksums.yaml.gz.sig CHANGED
Binary file
@@ -10,7 +10,7 @@
10
10
  require_relative 'context/pipeline'
11
11
  require_relative 'context/transaction'
12
12
  require_relative 'context/subscribe'
13
- require_relative 'protocol/resp2'
13
+ require_relative 'endpoint'
14
14
 
15
15
  require 'io/endpoint/host_endpoint'
16
16
  require 'async/pool/controller'
@@ -23,14 +23,69 @@ module Async
23
23
  # Legacy.
24
24
  ServerError = ::Protocol::Redis::ServerError
25
25
 
26
- def self.local_endpoint(port: 6379)
27
- ::IO::Endpoint.tcp('localhost', port)
28
- end
29
-
30
26
  class Client
31
27
  include ::Protocol::Redis::Methods
32
28
 
33
- def initialize(endpoint = Redis.local_endpoint, protocol: Protocol::RESP2, **options)
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
+
88
+ def initialize(endpoint = Endpoint.local, protocol: endpoint.protocol, **options)
34
89
  @endpoint = endpoint
35
90
  @protocol = protocol
36
91
 
@@ -42,8 +97,8 @@ module Async
42
97
 
43
98
  # @return [client] if no block provided.
44
99
  # @yield [client, task] yield the client in an async task.
45
- def self.open(*arguments, &block)
46
- client = self.new(*arguments)
100
+ def self.open(*arguments, **options, &block)
101
+ client = self.new(*arguments, **options)
47
102
 
48
103
  return client unless block_given?
49
104
 
@@ -56,61 +111,6 @@ module Async
56
111
  end.wait
57
112
  end
58
113
 
59
- def close
60
- @pool.close
61
- end
62
-
63
- def subscribe(*channels)
64
- context = Context::Subscribe.new(@pool, channels)
65
-
66
- return context unless block_given?
67
-
68
- begin
69
- yield context
70
- ensure
71
- context.close
72
- end
73
- end
74
-
75
- def transaction(&block)
76
- context = Context::Transaction.new(@pool)
77
-
78
- return context unless block_given?
79
-
80
- begin
81
- yield context
82
- ensure
83
- context.close
84
- end
85
- end
86
-
87
- alias multi transaction
88
-
89
- def pipeline(&block)
90
- context = Context::Pipeline.new(@pool)
91
-
92
- return context unless block_given?
93
-
94
- begin
95
- yield context
96
- ensure
97
- context.close
98
- end
99
- end
100
-
101
- # Deprecated.
102
- alias nested pipeline
103
-
104
- def call(*arguments)
105
- @pool.acquire do |connection|
106
- connection.write_request(arguments)
107
-
108
- connection.flush
109
-
110
- return connection.read_response
111
- end
112
- end
113
-
114
114
  protected
115
115
 
116
116
  def connect(**options)
@@ -0,0 +1,211 @@
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
+ Node = Struct.new(:id, :endpoint, :role, :health, :client)
17
+
18
+ class RangeMap
19
+ def initialize
20
+ @ranges = []
21
+ end
22
+
23
+ def add(range, value)
24
+ @ranges << [range, value]
25
+
26
+ return value
27
+ end
28
+
29
+ def find(key)
30
+ @ranges.each do |range, value|
31
+ return value if range.include?(key)
32
+ end
33
+
34
+ if block_given?
35
+ return yield
36
+ end
37
+
38
+ return nil
39
+ end
40
+
41
+ def each
42
+ @ranges.each do |range, value|
43
+ yield value
44
+ end
45
+ end
46
+
47
+ def clear
48
+ @ranges.clear
49
+ end
50
+ end
51
+
52
+ # Create a new instance of the cluster client.
53
+ #
54
+ # @property endpoints [Array(Endpoint)] The list of cluster endpoints.
55
+ def initialize(endpoints, **options)
56
+ @endpoints = endpoints
57
+ @shards = nil
58
+ end
59
+
60
+ def clients_for(*keys, role: :master, attempts: 3)
61
+ slots = slots_for(keys)
62
+
63
+ slots.each do |slot, keys|
64
+ yield client_for(slot, role), keys
65
+ end
66
+ rescue ServerError => error
67
+ Console.warn(self, error)
68
+
69
+ if error.message =~ /MOVED|ASK/
70
+ reload_cluster!
71
+
72
+ attempts -= 1
73
+
74
+ retry if attempts > 0
75
+
76
+ raise
77
+ else
78
+ raise
79
+ end
80
+ end
81
+
82
+ def client_for(slot, role = :master)
83
+ unless @shards
84
+ reload_cluster!
85
+ end
86
+
87
+ nodes = @shards.find(slot)
88
+
89
+ nodes = nodes.select{|node| node.role == role}
90
+
91
+ if node = nodes.sample
92
+ return (node.client ||= Client.new(node.endpoint))
93
+ end
94
+ end
95
+
96
+ protected
97
+
98
+ def reload_cluster!(endpoints = @endpoints)
99
+ @endpoints.each do |endpoint|
100
+ client = Client.new(endpoint)
101
+
102
+ shards = RangeMap.new
103
+ endpoints = []
104
+
105
+ client.call('CLUSTER', 'SHARDS').each do |shard|
106
+ shard = shard.each_slice(2).to_h
107
+
108
+ slots = shard['slots']
109
+ range = Range.new(*slots, exclude_end: false)
110
+
111
+ nodes = shard['nodes'].map do |node|
112
+ node = node.each_slice(2).to_h
113
+ endpoint = Endpoint.remote(node['ip'], node['port'])
114
+
115
+ # Collect all endpoints:
116
+ endpoints << endpoint
117
+
118
+ Node.new(node['id'], endpoint, node['role'].to_sym, node['health'].to_sym)
119
+ end
120
+
121
+ shards.add(range, nodes)
122
+ end
123
+
124
+ @shards = shards
125
+ # @endpoints = @endpoints | endpoints
126
+
127
+ return true
128
+ rescue Errno::ECONNREFUSED
129
+ next
130
+ end
131
+
132
+ raise ReloadError, "Failed to reload cluster configuration."
133
+ end
134
+
135
+ XMODEM_CRC16_LOOKUP = [
136
+ 0x0000, 0x1021, 0x2042, 0x3063, 0x4084, 0x50a5, 0x60c6, 0x70e7,
137
+ 0x8108, 0x9129, 0xa14a, 0xb16b, 0xc18c, 0xd1ad, 0xe1ce, 0xf1ef,
138
+ 0x1231, 0x0210, 0x3273, 0x2252, 0x52b5, 0x4294, 0x72f7, 0x62d6,
139
+ 0x9339, 0x8318, 0xb37b, 0xa35a, 0xd3bd, 0xc39c, 0xf3ff, 0xe3de,
140
+ 0x2462, 0x3443, 0x0420, 0x1401, 0x64e6, 0x74c7, 0x44a4, 0x5485,
141
+ 0xa56a, 0xb54b, 0x8528, 0x9509, 0xe5ee, 0xf5cf, 0xc5ac, 0xd58d,
142
+ 0x3653, 0x2672, 0x1611, 0x0630, 0x76d7, 0x66f6, 0x5695, 0x46b4,
143
+ 0xb75b, 0xa77a, 0x9719, 0x8738, 0xf7df, 0xe7fe, 0xd79d, 0xc7bc,
144
+ 0x48c4, 0x58e5, 0x6886, 0x78a7, 0x0840, 0x1861, 0x2802, 0x3823,
145
+ 0xc9cc, 0xd9ed, 0xe98e, 0xf9af, 0x8948, 0x9969, 0xa90a, 0xb92b,
146
+ 0x5af5, 0x4ad4, 0x7ab7, 0x6a96, 0x1a71, 0x0a50, 0x3a33, 0x2a12,
147
+ 0xdbfd, 0xcbdc, 0xfbbf, 0xeb9e, 0x9b79, 0x8b58, 0xbb3b, 0xab1a,
148
+ 0x6ca6, 0x7c87, 0x4ce4, 0x5cc5, 0x2c22, 0x3c03, 0x0c60, 0x1c41,
149
+ 0xedae, 0xfd8f, 0xcdec, 0xddcd, 0xad2a, 0xbd0b, 0x8d68, 0x9d49,
150
+ 0x7e97, 0x6eb6, 0x5ed5, 0x4ef4, 0x3e13, 0x2e32, 0x1e51, 0x0e70,
151
+ 0xff9f, 0xefbe, 0xdfdd, 0xcffc, 0xbf1b, 0xaf3a, 0x9f59, 0x8f78,
152
+ 0x9188, 0x81a9, 0xb1ca, 0xa1eb, 0xd10c, 0xc12d, 0xf14e, 0xe16f,
153
+ 0x1080, 0x00a1, 0x30c2, 0x20e3, 0x5004, 0x4025, 0x7046, 0x6067,
154
+ 0x83b9, 0x9398, 0xa3fb, 0xb3da, 0xc33d, 0xd31c, 0xe37f, 0xf35e,
155
+ 0x02b1, 0x1290, 0x22f3, 0x32d2, 0x4235, 0x5214, 0x6277, 0x7256,
156
+ 0xb5ea, 0xa5cb, 0x95a8, 0x8589, 0xf56e, 0xe54f, 0xd52c, 0xc50d,
157
+ 0x34e2, 0x24c3, 0x14a0, 0x0481, 0x7466, 0x6447, 0x5424, 0x4405,
158
+ 0xa7db, 0xb7fa, 0x8799, 0x97b8, 0xe75f, 0xf77e, 0xc71d, 0xd73c,
159
+ 0x26d3, 0x36f2, 0x0691, 0x16b0, 0x6657, 0x7676, 0x4615, 0x5634,
160
+ 0xd94c, 0xc96d, 0xf90e, 0xe92f, 0x99c8, 0x89e9, 0xb98a, 0xa9ab,
161
+ 0x5844, 0x4865, 0x7806, 0x6827, 0x18c0, 0x08e1, 0x3882, 0x28a3,
162
+ 0xcb7d, 0xdb5c, 0xeb3f, 0xfb1e, 0x8bf9, 0x9bd8, 0xabbb, 0xbb9a,
163
+ 0x4a75, 0x5a54, 0x6a37, 0x7a16, 0x0af1, 0x1ad0, 0x2ab3, 0x3a92,
164
+ 0xfd2e, 0xed0f, 0xdd6c, 0xcd4d, 0xbdaa, 0xad8b, 0x9de8, 0x8dc9,
165
+ 0x7c26, 0x6c07, 0x5c64, 0x4c45, 0x3ca2, 0x2c83, 0x1ce0, 0x0cc1,
166
+ 0xef1f, 0xff3e, 0xcf5d, 0xdf7c, 0xaf9b, 0xbfba, 0x8fd9, 0x9ff8,
167
+ 0x6e17, 0x7e36, 0x4e55, 0x5e74, 0x2e93, 0x3eb2, 0x0ed1, 0x1ef0
168
+ ].freeze
169
+
170
+ # This is the CRC16 algorithm used by Redis Cluster to hash keys.
171
+ # Copied from https://github.com/antirez/redis-rb-cluster/blob/master/crc16.rb
172
+ def crc16(bytes)
173
+ sum = 0
174
+
175
+ bytes.each_byte do |byte|
176
+ sum = ((sum << 8) & 0xffff) ^ XMODEM_CRC16_LOOKUP[((sum >> 8) ^ byte) & 0xff]
177
+ end
178
+
179
+ return sum
180
+ end
181
+
182
+ HASH_SLOTS = 16_384
183
+
184
+ public
185
+
186
+ # Return Redis::Client for a given key.
187
+ # Modified from https://github.com/antirez/redis-rb-cluster/blob/master/cluster.rb#L104-L117
188
+ def slot_for(key)
189
+ key = key.to_s
190
+
191
+ if s = key.index('{')
192
+ if e = key.index('}', s + 1) and e != s + 1
193
+ key = key[s + 1..e - 1]
194
+ end
195
+ end
196
+
197
+ return crc16(key) % HASH_SLOTS
198
+ end
199
+
200
+ def slots_for(keys)
201
+ slots = Hash.new{|hash, key| hash[key] = []}
202
+
203
+ keys.each do |key|
204
+ slots[slot_for(key)] << key
205
+ end
206
+
207
+ return slots
208
+ end
209
+ end
210
+ end
211
+ end
@@ -2,7 +2,7 @@
2
2
 
3
3
  # Released under the MIT License.
4
4
  # Copyright, 2019, by David Ortiz.
5
- # Copyright, 2019-2023, by Samuel Williams.
5
+ # Copyright, 2019-2024, by Samuel Williams.
6
6
  # Copyright, 2022, by Tim Willard.
7
7
 
8
8
  require_relative 'generic'
@@ -22,8 +22,8 @@ module Async
22
22
  end
23
23
 
24
24
  # This method just accumulates the commands and their params.
25
- def call(command, *arguments)
26
- @pipeline.call(command, *arguments)
25
+ def call(...)
26
+ @pipeline.call(...)
27
27
 
28
28
  @pipeline.flush(1)
29
29
 
@@ -46,6 +46,15 @@ module Async
46
46
  end
47
47
  end
48
48
 
49
+ def collect
50
+ if block_given?
51
+ flush
52
+ yield
53
+ end
54
+
55
+ @count.times.map{read_response}
56
+ end
57
+
49
58
  def sync
50
59
  @sync ||= Sync.new(self)
51
60
  end
@@ -73,15 +82,9 @@ module Async
73
82
  end
74
83
  end
75
84
 
76
- def collect
77
- yield
78
-
79
- @count.times.map{read_response}
80
- end
81
-
82
85
  def close
83
86
  flush
84
- ensure
87
+ ensure
85
88
  super
86
89
  end
87
90
  end
@@ -0,0 +1,272 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2024, by Samuel Williams.
5
+
6
+ require 'io/endpoint'
7
+ require 'io/endpoint/host_endpoint'
8
+ require 'io/endpoint/ssl_endpoint'
9
+
10
+ require_relative 'protocol/resp2'
11
+ require_relative 'protocol/authenticated'
12
+ require_relative 'protocol/selected'
13
+
14
+ module Async
15
+ module Redis
16
+ def self.local_endpoint(**options)
17
+ Endpoint.local(**options)
18
+ end
19
+
20
+ # Represents a way to connect to a remote Redis server.
21
+ class Endpoint < ::IO::Endpoint::Generic
22
+ LOCALHOST = URI.parse("redis://localhost").freeze
23
+
24
+ def self.local(**options)
25
+ self.new(LOCALHOST, **options)
26
+ end
27
+
28
+ def self.remote(host, port = 6379, **options)
29
+ self.new(URI.parse("redis://#{host}:#{port}"), **options)
30
+ end
31
+
32
+ SCHEMES = {
33
+ 'redis' => URI::Generic,
34
+ 'rediss' => URI::Generic,
35
+ }
36
+
37
+ def self.parse(string, endpoint = nil, **options)
38
+ url = URI.parse(string).normalize
39
+
40
+ return self.new(url, endpoint, **options)
41
+ end
42
+
43
+ # Construct an endpoint with a specified scheme, hostname, optional path, and options.
44
+ #
45
+ # @parameter scheme [String] The scheme to use, e.g. "redis" or "rediss".
46
+ # @parameter hostname [String] The hostname to connect to (or bind to).
47
+ # @parameter *options [Hash] Additional options, passed to {#initialize}.
48
+ def self.for(scheme, hostname, credentials: nil, port: nil, database: nil, **options)
49
+ uri_klass = SCHEMES.fetch(scheme.downcase) do
50
+ raise ArgumentError, "Unsupported scheme: #{scheme.inspect}"
51
+ end
52
+
53
+ if database
54
+ path = "/#{database}"
55
+ end
56
+
57
+ self.new(
58
+ uri_klass.new(scheme, credentials&.join(":"), hostname, port, nil, path, nil, nil, nil).normalize,
59
+ **options
60
+ )
61
+ end
62
+
63
+ # Coerce the given object into an endpoint.
64
+ # @parameter url [String | Endpoint] The URL or endpoint to convert.
65
+ def self.[](object)
66
+ if object.is_a?(self)
67
+ return object
68
+ else
69
+ self.parse(object.to_s)
70
+ end
71
+ end
72
+
73
+ # Create a new endpoint.
74
+ #
75
+ # @parameter url [URI] The URL to connect to.
76
+ # @parameter endpoint [Endpoint] The underlying endpoint to use.
77
+ # @parameter scheme [String] The scheme to use, e.g. "redis" or "rediss".
78
+ # @parameter hostname [String] The hostname to connect to (or bind to), overrides the URL hostname (used for SNI).
79
+ # @parameter port [Integer] The port to bind to, overrides the URL port.
80
+ def initialize(url, endpoint = nil, **options)
81
+ super(**options)
82
+
83
+ raise ArgumentError, "URL must be absolute (include scheme, host): #{url}" unless url.absolute?
84
+
85
+ @url = url
86
+
87
+ if endpoint
88
+ @endpoint = self.build_endpoint(endpoint)
89
+ else
90
+ @endpoint = nil
91
+ end
92
+ end
93
+
94
+ def to_url
95
+ url = @url.dup
96
+
97
+ unless default_port?
98
+ url.port = self.port
99
+ end
100
+
101
+ return url
102
+ end
103
+
104
+ def to_s
105
+ "\#<#{self.class} #{self.to_url} #{@options}>"
106
+ end
107
+
108
+ def inspect
109
+ "\#<#{self.class} #{self.to_url} #{@options.inspect}>"
110
+ end
111
+
112
+ attr :url
113
+
114
+ def address
115
+ endpoint.address
116
+ end
117
+
118
+ def secure?
119
+ ['rediss'].include?(self.scheme)
120
+ end
121
+
122
+ def protocol
123
+ protocol = @options.fetch(:protocol, Protocol::RESP2)
124
+
125
+ if credentials = self.credentials
126
+ protocol = Protocol::Authenticated.new(credentials, protocol)
127
+ end
128
+
129
+ if database = self.database
130
+ protocol = Protocol::Selected.new(database, protocol)
131
+ end
132
+
133
+ return protocol
134
+ end
135
+
136
+ def default_port
137
+ 6379
138
+ end
139
+
140
+ def default_port?
141
+ port == default_port
142
+ end
143
+
144
+ def port
145
+ @options[:port] || @url.port || default_port
146
+ end
147
+
148
+ # The hostname is the server we are connecting to:
149
+ def hostname
150
+ @options[:hostname] || @url.hostname
151
+ end
152
+
153
+ def scheme
154
+ @options[:scheme] || @url.scheme
155
+ end
156
+
157
+ def database
158
+ @options[:database] || extract_database(@url.path)
159
+ end
160
+
161
+ private def extract_database(path)
162
+ if path =~ /\/(\d+)$/
163
+ return $1.to_i
164
+ end
165
+ end
166
+
167
+ def credentials
168
+ @options[:credentials] || extract_userinfo(@url.userinfo)
169
+ end
170
+
171
+ private def extract_userinfo(userinfo)
172
+ if userinfo
173
+ credentials = userinfo.split(":").reject(&:empty?)
174
+
175
+ if credentials.any?
176
+ return credentials
177
+ end
178
+ end
179
+ end
180
+
181
+ def localhost?
182
+ @url.hostname =~ /^(.*?\.)?localhost\.?$/
183
+ end
184
+
185
+ # We don't try to validate peer certificates when talking to localhost because they would always be self-signed.
186
+ def ssl_verify_mode
187
+ if self.localhost?
188
+ OpenSSL::SSL::VERIFY_NONE
189
+ else
190
+ OpenSSL::SSL::VERIFY_PEER
191
+ end
192
+ end
193
+
194
+ def ssl_context
195
+ @options[:ssl_context] || OpenSSL::SSL::SSLContext.new.tap do |context|
196
+ context.set_params(
197
+ verify_mode: self.ssl_verify_mode
198
+ )
199
+ end
200
+ end
201
+
202
+ def build_endpoint(endpoint = nil)
203
+ endpoint ||= tcp_endpoint
204
+
205
+ if secure?
206
+ # Wrap it in SSL:
207
+ return ::IO::Endpoint::SSLEndpoint.new(endpoint,
208
+ ssl_context: self.ssl_context,
209
+ hostname: @url.hostname,
210
+ timeout: self.timeout,
211
+ )
212
+ end
213
+
214
+ return endpoint
215
+ end
216
+
217
+ def endpoint
218
+ @endpoint ||= build_endpoint
219
+ end
220
+
221
+ def endpoint=(endpoint)
222
+ @endpoint = build_endpoint(endpoint)
223
+ end
224
+
225
+ def bind(*arguments, &block)
226
+ endpoint.bind(*arguments, &block)
227
+ end
228
+
229
+ def connect(&block)
230
+ endpoint.connect(&block)
231
+ end
232
+
233
+ def each
234
+ return to_enum unless block_given?
235
+
236
+ self.tcp_endpoint.each do |endpoint|
237
+ yield self.class.new(@url, endpoint, **@options)
238
+ end
239
+ end
240
+
241
+ def key
242
+ [@url, @options]
243
+ end
244
+
245
+ def eql? other
246
+ self.key.eql? other.key
247
+ end
248
+
249
+ def hash
250
+ self.key.hash
251
+ end
252
+
253
+ protected
254
+
255
+ def tcp_options
256
+ options = @options.dup
257
+
258
+ options.delete(:scheme)
259
+ options.delete(:port)
260
+ options.delete(:hostname)
261
+ options.delete(:ssl_context)
262
+ options.delete(:protocol)
263
+
264
+ return options
265
+ end
266
+
267
+ def tcp_endpoint
268
+ ::IO::Endpoint.tcp(self.hostname, port, **tcp_options)
269
+ end
270
+ end
271
+ end
272
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2024, by Samuel Williams.
5
+
6
+ require 'protocol/redis'
7
+
8
+ module Async
9
+ module Redis
10
+ module Protocol
11
+ # Executes AUTH after the user has established a connection.
12
+ class Authenticated
13
+ # Authentication has failed for some reason.
14
+ class AuthenticationError < StandardError
15
+ end
16
+
17
+ # Create a new authenticated protocol.
18
+ #
19
+ # @parameter credentials [Array] The credentials to use for authentication.
20
+ # @parameter protocol [Object] The delegated protocol for connecting.
21
+ def initialize(credentials, protocol = Async::Redis::Protocol::RESP2)
22
+ @credentials = credentials
23
+ @protocol = protocol
24
+ end
25
+
26
+ attr :credentials
27
+
28
+ # Create a new client and authenticate it.
29
+ def client(stream)
30
+ client = @protocol.client(stream)
31
+
32
+ client.write_request(["AUTH", *@credentials])
33
+ response = client.read_response
34
+
35
+ if response != "OK"
36
+ raise AuthenticationError, "Could not authenticate: #{response}"
37
+ end
38
+
39
+ return client
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2024, by Samuel Williams.
5
+
6
+ require 'protocol/redis'
7
+
8
+ module Async
9
+ module Redis
10
+ module Protocol
11
+ # Executes AUTH after the user has established a connection.
12
+ class Selected
13
+ # Authentication has failed for some reason.
14
+ class SelectionError < StandardError
15
+ end
16
+
17
+ # Create a new authenticated protocol.
18
+ #
19
+ # @parameter index [Integer] The database index to select.
20
+ # @parameter protocol [Object] The delegated protocol for connecting.
21
+ def initialize(index, protocol = Async::Redis::Protocol::RESP2)
22
+ @index = index
23
+ @protocol = protocol
24
+ end
25
+
26
+ attr :index
27
+
28
+ # Create a new client and authenticate it.
29
+ def client(stream)
30
+ client = @protocol.client(stream)
31
+
32
+ client.write_request(["SELECT", @index])
33
+ response = client.read_response
34
+
35
+ if response != "OK"
36
+ raise SelectionError, "Could not select database: #{response}"
37
+ end
38
+
39
+ return client
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -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
@@ -1,10 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2018-2023, by Samuel Williams.
4
+ # Copyright, 2018-2024, by Samuel Williams.
5
5
 
6
6
  module Async
7
7
  module Redis
8
- VERSION = "0.8.1"
8
+ VERSION = "0.10.0"
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/readme.md CHANGED
@@ -6,9 +6,9 @@ An asynchronous client for Redis including TLS. Support for streaming requests a
6
6
 
7
7
  ## Usage
8
8
 
9
- Please see the [project documentation](https://github.com/socketry/async-redis) for more details.
9
+ Please see the [project documentation](https://socketry.github.io/async-redis/) for more details.
10
10
 
11
- - [Getting Started](https://github.com/socketry/async-redisguides/getting-started/index) - This guide explains how to use the `async-redis` gem to connect to a Redis server and perform basic operations.
11
+ - [Getting Started](https://socketry.github.io/async-redis/guides/getting-started/index) - This guide explains how to use the `async-redis` gem to connect to a Redis server and perform basic operations.
12
12
 
13
13
  ## Contributing
14
14
 
@@ -22,8 +22,8 @@ We welcome contributions to this project.
22
22
 
23
23
  ### Developer Certificate of Origin
24
24
 
25
- This project uses the [Developer Certificate of Origin](https://developercertificate.org/). All contributors to this project must agree to this document to have their contributions accepted.
25
+ In order to protect users of this project, we require all contributors to comply with the [Developer Certificate of Origin](https://developercertificate.org/). This ensures that all contributions are properly licensed and attributed.
26
26
 
27
- ### Contributor Covenant
27
+ ### Community Guidelines
28
28
 
29
- This project is governed by the [Contributor Covenant](https://www.contributor-covenant.org/). All contributors and participants agree to abide by its terms.
29
+ This project is best served by a collaborative and respectful environment. Treat each other professionally, respect differing viewpoints, and engage constructively. Harassment, discrimination, or harmful behavior is not tolerated. Communicate clearly, listen actively, and support one another. If any issues arise, please inform the project maintainers.
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.8.1
4
+ version: 0.10.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Samuel Williams
@@ -48,28 +48,22 @@ cert_chain:
48
48
  Q2K9NVun/S785AP05vKkXZEFYxqG6EW012U4oLcFl5MySFajYXRYbuUpH6AY+HP8
49
49
  voD0MPg1DssDLKwXyt1eKD/+Fq0bFWhwVM/1XiAXL7lyYUyOq24KHgQ2Csg=
50
50
  -----END CERTIFICATE-----
51
- date: 2024-04-24 00:00:00.000000000 Z
51
+ date: 2024-08-16 00:00:00.000000000 Z
52
52
  dependencies:
53
53
  - !ruby/object:Gem::Dependency
54
54
  name: async
55
55
  requirement: !ruby/object:Gem::Requirement
56
56
  requirements:
57
- - - ">="
58
- - !ruby/object:Gem::Version
59
- version: '1.8'
60
- - - "<"
57
+ - - "~>"
61
58
  - !ruby/object:Gem::Version
62
- version: '3.0'
59
+ version: '2.10'
63
60
  type: :runtime
64
61
  prerelease: false
65
62
  version_requirements: !ruby/object:Gem::Requirement
66
63
  requirements:
67
- - - ">="
68
- - !ruby/object:Gem::Version
69
- version: '1.8'
70
- - - "<"
64
+ - - "~>"
71
65
  - !ruby/object:Gem::Version
72
- version: '3.0'
66
+ version: '2.10'
73
67
  - !ruby/object:Gem::Dependency
74
68
  name: async-pool
75
69
  requirement: !ruby/object:Gem::Requirement
@@ -118,14 +112,14 @@ dependencies:
118
112
  requirements:
119
113
  - - "~>"
120
114
  - !ruby/object:Gem::Version
121
- version: 0.8.0
115
+ version: '0.9'
122
116
  type: :runtime
123
117
  prerelease: false
124
118
  version_requirements: !ruby/object:Gem::Requirement
125
119
  requirements:
126
120
  - - "~>"
127
121
  - !ruby/object:Gem::Version
128
- version: 0.8.0
122
+ version: '0.9'
129
123
  description:
130
124
  email:
131
125
  executables: []
@@ -134,13 +128,17 @@ extra_rdoc_files: []
134
128
  files:
135
129
  - lib/async/redis.rb
136
130
  - lib/async/redis/client.rb
131
+ - lib/async/redis/cluster_client.rb
137
132
  - lib/async/redis/context/generic.rb
138
133
  - lib/async/redis/context/pipeline.rb
139
134
  - lib/async/redis/context/subscribe.rb
140
135
  - lib/async/redis/context/transaction.rb
136
+ - lib/async/redis/endpoint.rb
141
137
  - lib/async/redis/key.rb
138
+ - lib/async/redis/protocol/authenticated.rb
142
139
  - lib/async/redis/protocol/resp2.rb
143
- - lib/async/redis/sentinels.rb
140
+ - lib/async/redis/protocol/selected.rb
141
+ - lib/async/redis/sentinel_client.rb
144
142
  - lib/async/redis/version.rb
145
143
  - license.md
146
144
  - readme.md
@@ -148,6 +146,7 @@ homepage: https://github.com/socketry/async-redis
148
146
  licenses:
149
147
  - MIT
150
148
  metadata:
149
+ documentation_uri: https://socketry.github.io/async-redis/
151
150
  source_code_uri: https://github.com/socketry/async-redis.git
152
151
  post_install_message:
153
152
  rdoc_options: []
@@ -164,7 +163,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
164
163
  - !ruby/object:Gem::Version
165
164
  version: '0'
166
165
  requirements: []
167
- rubygems_version: 3.5.3
166
+ rubygems_version: 3.5.11
168
167
  signing_key:
169
168
  specification_version: 4
170
169
  summary: A Redis client library.
metadata.gz.sig CHANGED
Binary file
@@ -1,97 +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
- @sentinel_endpoints = sentinels.map do |sentinel|
15
- ::IO::Endpoint.tcp(sentinel[:host], sentinel[:port])
16
- end
17
- @role = role
18
-
19
- @protocol = protocol
20
- @pool = connect(**options)
21
- end
22
-
23
- private
24
-
25
- # Override the parent method. The only difference is that this one needs
26
- # to resolve the master/slave address.
27
- def connect(**options)
28
- Async::Pool::Controller.wrap(**options) do
29
- endpoint = resolve_address
30
- peer = endpoint.connect
31
- stream = ::IO::Stream(peer)
32
-
33
- @protocol.client(stream)
34
- end
35
- end
36
-
37
- def resolve_address
38
- address = case @role
39
- when :master then resolve_master
40
- when :slave then resolve_slave
41
- else raise ArgumentError, "Unknown instance role #{@role}"
42
- end
43
-
44
- address or raise RuntimeError, "Unable to fetch #{@role} via Sentinel."
45
- end
46
-
47
- def resolve_master
48
- @sentinel_endpoints.each do |sentinel_endpoint|
49
- client = Client.new(sentinel_endpoint)
50
-
51
- begin
52
- address = client.call('sentinel', 'get-master-addr-by-name', @master_name)
53
- rescue Errno::ECONNREFUSED
54
- next
55
- end
56
-
57
- return ::IO::Endpoint.tcp(address[0], address[1]) if address
58
- end
59
-
60
- nil
61
- end
62
-
63
- def resolve_slave
64
- @sentinel_endpoints.each do |sentinel_endpoint|
65
- client = Client.new(sentinel_endpoint)
66
-
67
- begin
68
- reply = client.call('sentinel', 'slaves', @master_name)
69
- rescue Errno::ECONNREFUSED
70
- next
71
- end
72
-
73
- slaves = available_slaves(reply)
74
- next if slaves.empty?
75
-
76
- slave = select_slave(slaves)
77
- return ::IO::Endpoint.tcp(slave['ip'], slave['port'])
78
- end
79
-
80
- nil
81
- end
82
-
83
- def available_slaves(slaves_cmd_reply)
84
- # The reply is an array with the format: [field1, value1, field2,
85
- # value2, etc.].
86
- # When a slave is marked as down by the sentinel, the "flags" field
87
- # (comma-separated array) contains the "s_down" value.
88
- slaves_cmd_reply.map { |s| s.each_slice(2).to_h }
89
- .reject { |s| s.fetch('flags').split(',').include?('s_down') }
90
- end
91
-
92
- def select_slave(available_slaves)
93
- available_slaves.sample
94
- end
95
- end
96
- end
97
- end