redis 3.0.0 → 4.2.2

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