redis 3.0.0 → 4.5.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.
Files changed (106) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +315 -0
  3. data/README.md +301 -58
  4. data/lib/redis/client.rb +383 -88
  5. data/lib/redis/cluster/command.rb +81 -0
  6. data/lib/redis/cluster/command_loader.rb +33 -0
  7. data/lib/redis/cluster/key_slot_converter.rb +72 -0
  8. data/lib/redis/cluster/node.rb +108 -0
  9. data/lib/redis/cluster/node_key.rb +31 -0
  10. data/lib/redis/cluster/node_loader.rb +37 -0
  11. data/lib/redis/cluster/option.rb +93 -0
  12. data/lib/redis/cluster/slot.rb +86 -0
  13. data/lib/redis/cluster/slot_loader.rb +49 -0
  14. data/lib/redis/cluster.rb +291 -0
  15. data/lib/redis/connection/command_helper.rb +7 -10
  16. data/lib/redis/connection/hiredis.rb +12 -8
  17. data/lib/redis/connection/registry.rb +2 -1
  18. data/lib/redis/connection/ruby.rb +266 -74
  19. data/lib/redis/connection/synchrony.rb +41 -14
  20. data/lib/redis/connection.rb +4 -2
  21. data/lib/redis/distributed.rb +258 -76
  22. data/lib/redis/errors.rb +48 -0
  23. data/lib/redis/hash_ring.rb +31 -73
  24. data/lib/redis/pipeline.rb +74 -18
  25. data/lib/redis/subscribe.rb +24 -13
  26. data/lib/redis/version.rb +3 -1
  27. data/lib/redis.rb +2068 -464
  28. metadata +63 -160
  29. data/.gitignore +0 -10
  30. data/.order +0 -169
  31. data/.travis/Gemfile +0 -11
  32. data/.travis.yml +0 -50
  33. data/.yardopts +0 -3
  34. data/Rakefile +0 -392
  35. data/benchmarking/logging.rb +0 -62
  36. data/benchmarking/pipeline.rb +0 -51
  37. data/benchmarking/speed.rb +0 -21
  38. data/benchmarking/suite.rb +0 -24
  39. data/benchmarking/worker.rb +0 -71
  40. data/examples/basic.rb +0 -15
  41. data/examples/dist_redis.rb +0 -43
  42. data/examples/incr-decr.rb +0 -17
  43. data/examples/list.rb +0 -26
  44. data/examples/pubsub.rb +0 -31
  45. data/examples/sets.rb +0 -36
  46. data/examples/unicorn/config.ru +0 -3
  47. data/examples/unicorn/unicorn.rb +0 -20
  48. data/redis.gemspec +0 -41
  49. data/test/blocking_commands_test.rb +0 -42
  50. data/test/command_map_test.rb +0 -30
  51. data/test/commands_on_hashes_test.rb +0 -21
  52. data/test/commands_on_lists_test.rb +0 -20
  53. data/test/commands_on_sets_test.rb +0 -77
  54. data/test/commands_on_sorted_sets_test.rb +0 -109
  55. data/test/commands_on_strings_test.rb +0 -83
  56. data/test/commands_on_value_types_test.rb +0 -99
  57. data/test/connection_handling_test.rb +0 -189
  58. data/test/db/.gitignore +0 -1
  59. data/test/distributed_blocking_commands_test.rb +0 -46
  60. data/test/distributed_commands_on_hashes_test.rb +0 -10
  61. data/test/distributed_commands_on_lists_test.rb +0 -22
  62. data/test/distributed_commands_on_sets_test.rb +0 -83
  63. data/test/distributed_commands_on_sorted_sets_test.rb +0 -18
  64. data/test/distributed_commands_on_strings_test.rb +0 -48
  65. data/test/distributed_commands_on_value_types_test.rb +0 -87
  66. data/test/distributed_commands_requiring_clustering_test.rb +0 -148
  67. data/test/distributed_connection_handling_test.rb +0 -23
  68. data/test/distributed_internals_test.rb +0 -15
  69. data/test/distributed_key_tags_test.rb +0 -52
  70. data/test/distributed_persistence_control_commands_test.rb +0 -26
  71. data/test/distributed_publish_subscribe_test.rb +0 -92
  72. data/test/distributed_remote_server_control_commands_test.rb +0 -53
  73. data/test/distributed_scripting_test.rb +0 -102
  74. data/test/distributed_sorting_test.rb +0 -20
  75. data/test/distributed_test.rb +0 -58
  76. data/test/distributed_transactions_test.rb +0 -32
  77. data/test/encoding_test.rb +0 -18
  78. data/test/error_replies_test.rb +0 -59
  79. data/test/helper.rb +0 -188
  80. data/test/helper_test.rb +0 -22
  81. data/test/internals_test.rb +0 -214
  82. data/test/lint/blocking_commands.rb +0 -124
  83. data/test/lint/hashes.rb +0 -162
  84. data/test/lint/lists.rb +0 -143
  85. data/test/lint/sets.rb +0 -96
  86. data/test/lint/sorted_sets.rb +0 -201
  87. data/test/lint/strings.rb +0 -157
  88. data/test/lint/value_types.rb +0 -106
  89. data/test/persistence_control_commands_test.rb +0 -26
  90. data/test/pipelining_commands_test.rb +0 -195
  91. data/test/publish_subscribe_test.rb +0 -153
  92. data/test/remote_server_control_commands_test.rb +0 -104
  93. data/test/scripting_test.rb +0 -78
  94. data/test/sorting_test.rb +0 -45
  95. data/test/support/connection/hiredis.rb +0 -1
  96. data/test/support/connection/ruby.rb +0 -1
  97. data/test/support/connection/synchrony.rb +0 -17
  98. data/test/support/redis_mock.rb +0 -92
  99. data/test/support/wire/synchrony.rb +0 -24
  100. data/test/support/wire/thread.rb +0 -5
  101. data/test/synchrony_driver.rb +0 -57
  102. data/test/test.conf +0 -9
  103. data/test/thread_safety_test.rb +0 -32
  104. data/test/transactions_test.rb +0 -244
  105. data/test/unknown_commands_test.rb +0 -14
  106. data/test/url_param_test.rb +0 -64
data/lib/redis/client.rb CHANGED
@@ -1,17 +1,38 @@
1
- require "redis/errors"
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "errors"
4
+ require "socket"
5
+ require "cgi"
2
6
 
3
7
  class Redis
4
8
  class Client
5
-
9
+ # Defaults are also used for converting string keys to symbols.
6
10
  DEFAULTS = {
7
- :scheme => "redis",
8
- :host => "127.0.0.1",
9
- :port => 6379,
10
- :path => nil,
11
- :timeout => 5.0,
12
- :password => nil,
13
- :db => 0,
14
- }
11
+ url: -> { ENV["REDIS_URL"] },
12
+ scheme: "redis",
13
+ host: "127.0.0.1",
14
+ port: 6379,
15
+ path: nil,
16
+ read_timeout: nil,
17
+ write_timeout: nil,
18
+ connect_timeout: nil,
19
+ timeout: 5.0,
20
+ username: nil,
21
+ password: nil,
22
+ db: 0,
23
+ driver: nil,
24
+ id: nil,
25
+ tcp_keepalive: 0,
26
+ reconnect_attempts: 1,
27
+ reconnect_delay: 0,
28
+ reconnect_delay_max: 0.5,
29
+ inherit_socket: false,
30
+ logger: nil,
31
+ sentinels: nil,
32
+ role: nil
33
+ }.freeze
34
+
35
+ attr_reader :options
15
36
 
16
37
  def scheme
17
38
  @options[:scheme]
@@ -29,8 +50,20 @@ class Redis
29
50
  @options[:path]
30
51
  end
31
52
 
53
+ def read_timeout
54
+ @options[:read_timeout]
55
+ end
56
+
57
+ def connect_timeout
58
+ @options[:connect_timeout]
59
+ end
60
+
32
61
  def timeout
33
- @options[:timeout]
62
+ @options[:read_timeout]
63
+ end
64
+
65
+ def username
66
+ @options[:username]
34
67
  end
35
68
 
36
69
  def password
@@ -45,9 +78,17 @@ class Redis
45
78
  @options[:db] = db.to_i
46
79
  end
47
80
 
48
- attr :logger
49
- attr :connection
50
- attr :command_map
81
+ def driver
82
+ @options[:driver]
83
+ end
84
+
85
+ def inherit_socket?
86
+ @options[:inherit_socket]
87
+ end
88
+
89
+ attr_accessor :logger
90
+ attr_reader :connection
91
+ attr_reader :command_map
51
92
 
52
93
  def initialize(options = {})
53
94
  @options = _parse_options(options)
@@ -55,14 +96,47 @@ class Redis
55
96
  @logger = @options[:logger]
56
97
  @connection = nil
57
98
  @command_map = {}
99
+
100
+ @pending_reads = 0
101
+
102
+ @connector =
103
+ if !@options[:sentinels].nil?
104
+ Connector::Sentinel.new(@options)
105
+ elsif options.include?(:connector) && options[:connector].respond_to?(:new)
106
+ options.delete(:connector).new(@options)
107
+ else
108
+ Connector.new(@options)
109
+ end
58
110
  end
59
111
 
60
112
  def connect
61
113
  @pid = Process.pid
62
114
 
63
- establish_connection
64
- call [:auth, password] if password
65
- call [:select, db] if db != 0
115
+ # Don't try to reconnect when the connection is fresh
116
+ with_reconnect(false) do
117
+ establish_connection
118
+ if password
119
+ if username
120
+ begin
121
+ call [:auth, username, password]
122
+ rescue CommandError => err # Likely on Redis < 6
123
+ if err.message.match?(/ERR wrong number of arguments for \'auth\' command/)
124
+ call [:auth, password]
125
+ else
126
+ raise
127
+ end
128
+ end
129
+ else
130
+ call [:auth, password]
131
+ end
132
+ end
133
+
134
+ call [:readonly] if @options[:readonly]
135
+ call [:select, db] if db != 0
136
+ call [:client, :setname, @options[:id]] if @options[:id]
137
+ @connector.check(self)
138
+ end
139
+
66
140
  self
67
141
  end
68
142
 
@@ -74,21 +148,21 @@ class Redis
74
148
  path || "#{host}:#{port}"
75
149
  end
76
150
 
77
- def call(command, &block)
151
+ def call(command)
78
152
  reply = process([command]) { read }
79
153
  raise reply if reply.is_a?(CommandError)
80
154
 
81
- if block
82
- block.call(reply)
155
+ if block_given? && reply != 'QUEUED'
156
+ yield reply
83
157
  else
84
158
  reply
85
159
  end
86
160
  end
87
161
 
88
- def call_loop(command)
162
+ def call_loop(command, timeout = 0)
89
163
  error = nil
90
164
 
91
- result = without_socket_timeout do
165
+ result = with_socket_timeout(timeout) do
92
166
  process([command]) do
93
167
  loop do
94
168
  reply = read
@@ -110,11 +184,16 @@ class Redis
110
184
  end
111
185
 
112
186
  def call_pipeline(pipeline)
187
+ return [] if pipeline.futures.empty?
188
+
113
189
  with_reconnect pipeline.with_reconnect? do
114
190
  begin
115
- pipeline.finish(call_pipelined(pipeline.commands))
191
+ pipeline.finish(call_pipelined(pipeline)).tap do
192
+ self.db = pipeline.db if pipeline.db
193
+ end
116
194
  rescue ConnectionError => e
117
195
  return nil if pipeline.shutdown?
196
+
118
197
  # Assume the pipeline was sent in one piece, but execution of
119
198
  # SHUTDOWN caused none of the replies for commands that were executed
120
199
  # prior to it from coming back around.
@@ -123,8 +202,8 @@ class Redis
123
202
  end
124
203
  end
125
204
 
126
- def call_pipelined(commands)
127
- return [] if commands.empty?
205
+ def call_pipelined(pipeline)
206
+ return [] if pipeline.futures.empty?
128
207
 
129
208
  # The method #ensure_connected (called from #process) reconnects once on
130
209
  # I/O errors. To make an effort in making sure that commands are not
@@ -134,19 +213,28 @@ class Redis
134
213
  # already successfully executed commands. To circumvent this, don't retry
135
214
  # after the first reply has been read successfully.
136
215
 
216
+ commands = pipeline.commands
217
+
137
218
  result = Array.new(commands.size)
138
219
  reconnect = @reconnect
139
220
 
140
221
  begin
141
- process(commands) do
142
- result[0] = read
143
-
144
- @reconnect = false
222
+ exception = nil
145
223
 
146
- (commands.size - 1).times do |i|
147
- result[i + 1] = read
224
+ process(commands) do
225
+ pipeline.timeouts.each_with_index do |timeout, i|
226
+ reply = if timeout
227
+ with_socket_timeout(timeout) { read }
228
+ else
229
+ read
230
+ end
231
+ result[i] = reply
232
+ @reconnect = false
233
+ exception = reply if exception.nil? && reply.is_a?(CommandError)
148
234
  end
149
235
  end
236
+
237
+ raise exception if exception
150
238
  ensure
151
239
  @reconnect = reconnect
152
240
  end
@@ -154,14 +242,18 @@ class Redis
154
242
  result
155
243
  end
156
244
 
157
- def call_without_timeout(command, &blk)
158
- without_socket_timeout do
245
+ def call_with_timeout(command, timeout, &blk)
246
+ with_socket_timeout(timeout) do
159
247
  call(command, &blk)
160
248
  end
161
249
  rescue ConnectionError
162
250
  retry
163
251
  end
164
252
 
253
+ def call_without_timeout(command, &blk)
254
+ call_with_timeout(command, 0, &blk)
255
+ end
256
+
165
257
  def process(commands)
166
258
  logging(commands) do
167
259
  ensure_connected do
@@ -171,7 +263,7 @@ class Redis
171
263
  command[0] = command_map[command.first]
172
264
  end
173
265
 
174
- connection.write(command)
266
+ write(command)
175
267
  end
176
268
 
177
269
  yield if block_given?
@@ -180,12 +272,13 @@ class Redis
180
272
  end
181
273
 
182
274
  def connected?
183
- connection && connection.connected?
275
+ !!(connection && connection.connected?)
184
276
  end
185
277
 
186
278
  def disconnect
187
279
  connection.disconnect if connected?
188
280
  end
281
+ alias close disconnect
189
282
 
190
283
  def reconnect
191
284
  disconnect
@@ -194,95 +287,135 @@ class Redis
194
287
 
195
288
  def io
196
289
  yield
197
- rescue TimeoutError
198
- raise TimeoutError, "Connection timed out"
290
+ rescue TimeoutError => e1
291
+ # Add a message to the exception without destroying the original stack
292
+ e2 = TimeoutError.new("Connection timed out")
293
+ e2.set_backtrace(e1.backtrace)
294
+ raise e2
199
295
  rescue Errno::ECONNRESET, Errno::EPIPE, Errno::ECONNABORTED, Errno::EBADF, Errno::EINVAL => e
200
296
  raise ConnectionError, "Connection lost (%s)" % [e.class.name.split("::").last]
201
297
  end
202
298
 
203
299
  def read
204
300
  io do
205
- connection.read
301
+ value = connection.read
302
+ @pending_reads -= 1
303
+ value
206
304
  end
207
305
  end
208
306
 
209
307
  def write(command)
210
308
  io do
309
+ @pending_reads += 1
211
310
  connection.write(command)
212
311
  end
213
312
  end
214
313
 
215
- def without_socket_timeout
314
+ def with_socket_timeout(timeout)
216
315
  connect unless connected?
316
+ original = @options[:read_timeout]
217
317
 
218
318
  begin
219
- connection.timeout = 0
319
+ connection.timeout = timeout
320
+ @options[:read_timeout] = timeout # for reconnection
220
321
  yield
221
322
  ensure
222
- connection.timeout = timeout if connected?
323
+ connection.timeout = self.timeout if connected?
324
+ @options[:read_timeout] = original
223
325
  end
224
326
  end
225
327
 
226
- def with_reconnect(val=true)
227
- begin
228
- original, @reconnect = @reconnect, val
229
- yield
230
- ensure
231
- @reconnect = original
232
- end
328
+ def without_socket_timeout(&blk)
329
+ with_socket_timeout(0, &blk)
330
+ end
331
+
332
+ def with_reconnect(val = true)
333
+ original, @reconnect = @reconnect, val
334
+ yield
335
+ ensure
336
+ @reconnect = original
233
337
  end
234
338
 
235
339
  def without_reconnect(&blk)
236
340
  with_reconnect(false, &blk)
237
341
  end
238
342
 
239
- protected
343
+ protected
240
344
 
241
345
  def logging(commands)
242
- return yield unless @logger && @logger.debug?
346
+ return yield unless @logger&.debug?
243
347
 
244
348
  begin
245
349
  commands.each do |name, *args|
246
- @logger.debug("Redis >> #{name.to_s.upcase} #{args.map(&:to_s).join(" ")}")
350
+ logged_args = args.map do |a|
351
+ if a.respond_to?(:inspect) then a.inspect
352
+ elsif a.respond_to?(:to_s) then a.to_s
353
+ else
354
+ # handle poorly-behaved descendants of BasicObject
355
+ klass = a.instance_exec { (class << self; self end).superclass }
356
+ "\#<#{klass}:#{a.__id__}>"
357
+ end
358
+ end
359
+ @logger.debug("[Redis] command=#{name.to_s.upcase} args=#{logged_args.join(' ')}")
247
360
  end
248
361
 
249
362
  t1 = Time.now
250
363
  yield
251
364
  ensure
252
- @logger.debug("Redis >> %0.2fms" % ((Time.now - t1) * 1000)) if t1
365
+ @logger.debug("[Redis] call_time=%0.2f ms" % ((Time.now - t1) * 1000)) if t1
253
366
  end
254
367
  end
255
368
 
256
369
  def establish_connection
257
- @connection = @options[:driver].connect(@options.dup)
370
+ server = @connector.resolve.dup
258
371
 
259
- rescue TimeoutError
260
- raise CannotConnectError, "Timed out connecting to Redis on #{location}"
261
- rescue Errno::ECONNREFUSED
262
- raise CannotConnectError, "Error connecting to Redis on #{location} (ECONNREFUSED)"
372
+ @options[:host] = server[:host]
373
+ @options[:port] = Integer(server[:port]) if server.include?(:port)
374
+
375
+ @connection = @options[:driver].connect(@options)
376
+ @pending_reads = 0
377
+ rescue TimeoutError,
378
+ SocketError,
379
+ Errno::EADDRNOTAVAIL,
380
+ Errno::ECONNREFUSED,
381
+ Errno::EHOSTDOWN,
382
+ Errno::EHOSTUNREACH,
383
+ Errno::ENETUNREACH,
384
+ Errno::ENOENT,
385
+ Errno::ETIMEDOUT,
386
+ Errno::EINVAL => error
387
+
388
+ raise CannotConnectError, "Error connecting to Redis on #{location} (#{error.class})"
263
389
  end
264
390
 
265
391
  def ensure_connected
266
- tries = 0
392
+ disconnect if @pending_reads > 0
393
+
394
+ attempts = 0
267
395
 
268
396
  begin
397
+ attempts += 1
398
+
269
399
  if connected?
270
- if Process.pid != @pid
400
+ unless inherit_socket? || Process.pid == @pid
271
401
  raise InheritedError,
272
- "Tried to use a connection from a child process without reconnecting. " +
273
- "You need to reconnect to Redis after forking."
402
+ "Tried to use a connection from a child process without reconnecting. " \
403
+ "You need to reconnect to Redis after forking " \
404
+ "or set :inherit_socket to true."
274
405
  end
275
406
  else
276
407
  connect
277
408
  end
278
409
 
279
- tries += 1
280
-
281
410
  yield
282
- rescue ConnectionError
411
+ rescue BaseConnectionError
283
412
  disconnect
284
413
 
285
- if tries < 2 && @reconnect
414
+ if attempts <= @options[:reconnect_attempts] && @reconnect
415
+ sleep_t = [(@options[:reconnect_delay] * 2**(attempts - 1)),
416
+ @options[:reconnect_delay_max]].min
417
+
418
+ Kernel.sleep(sleep_t)
286
419
  retry
287
420
  else
288
421
  raise
@@ -294,9 +427,21 @@ class Redis
294
427
  end
295
428
 
296
429
  def _parse_options(options)
430
+ return options if options[:_parsed]
431
+
297
432
  defaults = DEFAULTS.dup
433
+ options = options.dup
434
+
435
+ defaults.keys.each do |key|
436
+ # Fill in defaults if needed
437
+ defaults[key] = defaults[key].call if defaults[key].respond_to?(:call)
438
+
439
+ # Symbolize only keys that are needed
440
+ options[key] = options[key.to_s] if options.key?(key.to_s)
441
+ end
298
442
 
299
- url = options[:url] || ENV["REDIS_URL"]
443
+ url = options[:url]
444
+ url = defaults[:url] if url.nil?
300
445
 
301
446
  # Override defaults from URL if given
302
447
  if url
@@ -305,57 +450,207 @@ class Redis
305
450
  uri = URI(url)
306
451
 
307
452
  if uri.scheme == "unix"
308
- defaults[:path] = uri.path
309
- else
310
- # Require the URL to have at least a host
311
- raise ArgumentError, "invalid url" unless uri.host
312
-
453
+ defaults[:path] = uri.path
454
+ elsif uri.scheme == "redis" || uri.scheme == "rediss"
313
455
  defaults[:scheme] = uri.scheme
314
- defaults[:host] = uri.host
456
+ defaults[:host] = uri.host if uri.host
315
457
  defaults[:port] = uri.port if uri.port
316
- defaults[:password] = uri.password if uri.password
458
+ defaults[:username] = CGI.unescape(uri.user) if uri.user && !uri.user.empty?
459
+ defaults[:password] = CGI.unescape(uri.password) if uri.password && !uri.password.empty?
317
460
  defaults[:db] = uri.path[1..-1].to_i if uri.path
461
+ defaults[:role] = :master
462
+ else
463
+ raise ArgumentError, "invalid uri scheme '#{uri.scheme}'"
318
464
  end
465
+
466
+ defaults[:ssl] = true if uri.scheme == "rediss"
319
467
  end
320
468
 
321
- options = defaults.merge(options)
469
+ # Use default when option is not specified or nil
470
+ defaults.keys.each do |key|
471
+ options[key] = defaults[key] if options[key].nil?
472
+ end
322
473
 
323
474
  if options[:path]
475
+ # Unix socket
324
476
  options[:scheme] = "unix"
325
477
  options.delete(:host)
326
478
  options.delete(:port)
327
479
  else
480
+ # TCP socket
328
481
  options[:host] = options[:host].to_s
329
482
  options[:port] = options[:port].to_i
330
483
  end
331
484
 
332
- options[:timeout] = options[:timeout].to_f
485
+ if options.key?(:timeout)
486
+ options[:connect_timeout] ||= options[:timeout]
487
+ options[:read_timeout] ||= options[:timeout]
488
+ options[:write_timeout] ||= options[:timeout]
489
+ end
490
+
491
+ options[:connect_timeout] = Float(options[:connect_timeout])
492
+ options[:read_timeout] = Float(options[:read_timeout])
493
+ options[:write_timeout] = Float(options[:write_timeout])
494
+
495
+ options[:reconnect_attempts] = options[:reconnect_attempts].to_i
496
+ options[:reconnect_delay] = options[:reconnect_delay].to_f
497
+ options[:reconnect_delay_max] = options[:reconnect_delay_max].to_f
498
+
333
499
  options[:db] = options[:db].to_i
334
500
  options[:driver] = _parse_driver(options[:driver]) || Connection.drivers.last
335
501
 
502
+ case options[:tcp_keepalive]
503
+ when Hash
504
+ %i[time intvl probes].each do |key|
505
+ unless options[:tcp_keepalive][key].is_a?(Integer)
506
+ raise "Expected the #{key.inspect} key in :tcp_keepalive to be an Integer"
507
+ end
508
+ end
509
+
510
+ when Integer
511
+ if options[:tcp_keepalive] >= 60
512
+ options[:tcp_keepalive] = { time: options[:tcp_keepalive] - 20, intvl: 10, probes: 2 }
513
+
514
+ elsif options[:tcp_keepalive] >= 30
515
+ options[:tcp_keepalive] = { time: options[:tcp_keepalive] - 10, intvl: 5, probes: 2 }
516
+
517
+ elsif options[:tcp_keepalive] >= 5
518
+ options[:tcp_keepalive] = { time: options[:tcp_keepalive] - 2, intvl: 2, probes: 1 }
519
+ end
520
+ end
521
+
522
+ options[:_parsed] = true
523
+
336
524
  options
337
525
  end
338
526
 
339
527
  def _parse_driver(driver)
340
528
  driver = driver.to_s if driver.is_a?(Symbol)
341
529
 
342
- if driver.kind_of?(String)
343
- case driver
344
- when "ruby"
345
- require "redis/connection/ruby"
346
- driver = Connection::Ruby
347
- when "hiredis"
348
- require "redis/connection/hiredis"
349
- driver = Connection::Hiredis
350
- when "synchrony"
351
- require "redis/connection/synchrony"
352
- driver = Connection::Synchrony
353
- else
354
- raise "Unknown driver: #{driver}"
530
+ if driver.is_a?(String)
531
+ begin
532
+ require_relative "connection/#{driver}"
533
+ rescue LoadError, NameError
534
+ begin
535
+ require "redis/connection/#{driver}"
536
+ rescue LoadError, NameError => error
537
+ raise "Cannot load driver #{driver.inspect}: #{error.message}"
538
+ end
355
539
  end
540
+
541
+ driver = Connection.const_get(driver.capitalize)
356
542
  end
357
543
 
358
544
  driver
359
545
  end
546
+
547
+ class Connector
548
+ def initialize(options)
549
+ @options = options.dup
550
+ end
551
+
552
+ def resolve
553
+ @options
554
+ end
555
+
556
+ def check(client); end
557
+
558
+ class Sentinel < Connector
559
+ def initialize(options)
560
+ super(options)
561
+
562
+ @options[:db] = DEFAULTS.fetch(:db)
563
+
564
+ @sentinels = @options.delete(:sentinels).dup
565
+ @role = (@options[:role] || "master").to_s
566
+ @master = @options[:host]
567
+ end
568
+
569
+ def check(client)
570
+ # Check the instance is really of the role we are looking for.
571
+ # We can't assume the command is supported since it was introduced
572
+ # recently and this client should work with old stuff.
573
+ begin
574
+ role = client.call([:role])[0]
575
+ rescue Redis::CommandError
576
+ # Assume the test is passed if we can't get a reply from ROLE...
577
+ role = @role
578
+ end
579
+
580
+ if role != @role
581
+ client.disconnect
582
+ raise ConnectionError, "Instance role mismatch. Expected #{@role}, got #{role}."
583
+ end
584
+ end
585
+
586
+ def resolve
587
+ result = case @role
588
+ when "master"
589
+ resolve_master
590
+ when "slave"
591
+ resolve_slave
592
+ else
593
+ raise ArgumentError, "Unknown instance role #{@role}"
594
+ end
595
+
596
+ result || (raise ConnectionError, "Unable to fetch #{@role} via Sentinel.")
597
+ end
598
+
599
+ def sentinel_detect
600
+ @sentinels.each do |sentinel|
601
+ client = Client.new(@options.merge({
602
+ host: sentinel[:host] || sentinel["host"],
603
+ port: sentinel[:port] || sentinel["port"],
604
+ username: sentinel[:username] || sentinel["username"],
605
+ password: sentinel[:password] || sentinel["password"],
606
+ reconnect_attempts: 0
607
+ }))
608
+
609
+ begin
610
+ if result = yield(client)
611
+ # This sentinel responded. Make sure we ask it first next time.
612
+ @sentinels.delete(sentinel)
613
+ @sentinels.unshift(sentinel)
614
+
615
+ return result
616
+ end
617
+ rescue BaseConnectionError
618
+ ensure
619
+ client.disconnect
620
+ end
621
+ end
622
+
623
+ raise CannotConnectError, "No sentinels available."
624
+ end
625
+
626
+ def resolve_master
627
+ sentinel_detect do |client|
628
+ if reply = client.call(["sentinel", "get-master-addr-by-name", @master])
629
+ { host: reply[0], port: reply[1] }
630
+ end
631
+ end
632
+ end
633
+
634
+ def resolve_slave
635
+ sentinel_detect do |client|
636
+ if reply = client.call(["sentinel", "slaves", @master])
637
+ slaves = reply.map { |s| s.each_slice(2).to_h }
638
+ slaves.each { |s| s['flags'] = s.fetch('flags').split(',') }
639
+ slaves.reject! { |s| s.fetch('flags').include?('s_down') }
640
+
641
+ if slaves.empty?
642
+ raise CannotConnectError, 'No slaves available.'
643
+ else
644
+ slave = slaves.sample
645
+ {
646
+ host: slave.fetch('ip'),
647
+ port: slave.fetch('port')
648
+ }
649
+ end
650
+ end
651
+ end
652
+ end
653
+ end
654
+ end
360
655
  end
361
656
  end