redis 3.0.0 → 4.2.2

Sign up to get free protection for your applications and to get access to all the features.
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