redis 3.3.5 → 4.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. checksums.yaml +5 -5
  2. data/CHANGELOG.md +161 -2
  3. data/README.md +144 -79
  4. data/lib/redis/client.rb +166 -90
  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 +6 -5
  17. data/lib/redis/connection/registry.rb +2 -1
  18. data/lib/redis/connection/ruby.rb +128 -129
  19. data/lib/redis/connection/synchrony.rb +21 -8
  20. data/lib/redis/connection.rb +4 -2
  21. data/lib/redis/distributed.rb +194 -72
  22. data/lib/redis/errors.rb +48 -0
  23. data/lib/redis/hash_ring.rb +30 -73
  24. data/lib/redis/pipeline.rb +55 -15
  25. data/lib/redis/subscribe.rb +11 -12
  26. data/lib/redis/version.rb +3 -1
  27. data/lib/redis.rb +1451 -403
  28. metadata +49 -202
  29. data/.gitignore +0 -16
  30. data/.travis/Gemfile +0 -11
  31. data/.travis.yml +0 -89
  32. data/.yardopts +0 -3
  33. data/Gemfile +0 -4
  34. data/Rakefile +0 -87
  35. data/benchmarking/logging.rb +0 -71
  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/consistency.rb +0 -114
  42. data/examples/dist_redis.rb +0 -43
  43. data/examples/incr-decr.rb +0 -17
  44. data/examples/list.rb +0 -26
  45. data/examples/pubsub.rb +0 -37
  46. data/examples/sentinel/sentinel.conf +0 -9
  47. data/examples/sentinel/start +0 -49
  48. data/examples/sentinel.rb +0 -41
  49. data/examples/sets.rb +0 -36
  50. data/examples/unicorn/config.ru +0 -3
  51. data/examples/unicorn/unicorn.rb +0 -20
  52. data/redis.gemspec +0 -44
  53. data/test/bitpos_test.rb +0 -69
  54. data/test/blocking_commands_test.rb +0 -42
  55. data/test/client_test.rb +0 -59
  56. data/test/command_map_test.rb +0 -30
  57. data/test/commands_on_hashes_test.rb +0 -21
  58. data/test/commands_on_hyper_log_log_test.rb +0 -21
  59. data/test/commands_on_lists_test.rb +0 -20
  60. data/test/commands_on_sets_test.rb +0 -77
  61. data/test/commands_on_sorted_sets_test.rb +0 -137
  62. data/test/commands_on_strings_test.rb +0 -101
  63. data/test/commands_on_value_types_test.rb +0 -133
  64. data/test/connection_handling_test.rb +0 -277
  65. data/test/connection_test.rb +0 -57
  66. data/test/db/.gitkeep +0 -0
  67. data/test/distributed_blocking_commands_test.rb +0 -46
  68. data/test/distributed_commands_on_hashes_test.rb +0 -10
  69. data/test/distributed_commands_on_hyper_log_log_test.rb +0 -33
  70. data/test/distributed_commands_on_lists_test.rb +0 -22
  71. data/test/distributed_commands_on_sets_test.rb +0 -83
  72. data/test/distributed_commands_on_sorted_sets_test.rb +0 -18
  73. data/test/distributed_commands_on_strings_test.rb +0 -59
  74. data/test/distributed_commands_on_value_types_test.rb +0 -95
  75. data/test/distributed_commands_requiring_clustering_test.rb +0 -164
  76. data/test/distributed_connection_handling_test.rb +0 -23
  77. data/test/distributed_internals_test.rb +0 -79
  78. data/test/distributed_key_tags_test.rb +0 -52
  79. data/test/distributed_persistence_control_commands_test.rb +0 -26
  80. data/test/distributed_publish_subscribe_test.rb +0 -92
  81. data/test/distributed_remote_server_control_commands_test.rb +0 -66
  82. data/test/distributed_scripting_test.rb +0 -102
  83. data/test/distributed_sorting_test.rb +0 -20
  84. data/test/distributed_test.rb +0 -58
  85. data/test/distributed_transactions_test.rb +0 -32
  86. data/test/encoding_test.rb +0 -18
  87. data/test/error_replies_test.rb +0 -59
  88. data/test/fork_safety_test.rb +0 -65
  89. data/test/helper.rb +0 -232
  90. data/test/helper_test.rb +0 -24
  91. data/test/internals_test.rb +0 -417
  92. data/test/lint/blocking_commands.rb +0 -150
  93. data/test/lint/hashes.rb +0 -162
  94. data/test/lint/hyper_log_log.rb +0 -60
  95. data/test/lint/lists.rb +0 -143
  96. data/test/lint/sets.rb +0 -140
  97. data/test/lint/sorted_sets.rb +0 -316
  98. data/test/lint/strings.rb +0 -260
  99. data/test/lint/value_types.rb +0 -122
  100. data/test/persistence_control_commands_test.rb +0 -26
  101. data/test/pipelining_commands_test.rb +0 -242
  102. data/test/publish_subscribe_test.rb +0 -282
  103. data/test/remote_server_control_commands_test.rb +0 -118
  104. data/test/scanning_test.rb +0 -413
  105. data/test/scripting_test.rb +0 -78
  106. data/test/sentinel_command_test.rb +0 -80
  107. data/test/sentinel_test.rb +0 -255
  108. data/test/sorting_test.rb +0 -59
  109. data/test/ssl_test.rb +0 -73
  110. data/test/support/connection/hiredis.rb +0 -1
  111. data/test/support/connection/ruby.rb +0 -1
  112. data/test/support/connection/synchrony.rb +0 -17
  113. data/test/support/redis_mock.rb +0 -130
  114. data/test/support/ssl/gen_certs.sh +0 -31
  115. data/test/support/ssl/trusted-ca.crt +0 -25
  116. data/test/support/ssl/trusted-ca.key +0 -27
  117. data/test/support/ssl/trusted-cert.crt +0 -81
  118. data/test/support/ssl/trusted-cert.key +0 -28
  119. data/test/support/ssl/untrusted-ca.crt +0 -26
  120. data/test/support/ssl/untrusted-ca.key +0 -27
  121. data/test/support/ssl/untrusted-cert.crt +0 -82
  122. data/test/support/ssl/untrusted-cert.key +0 -28
  123. data/test/support/wire/synchrony.rb +0 -24
  124. data/test/support/wire/thread.rb +0 -5
  125. data/test/synchrony_driver.rb +0 -88
  126. data/test/test.conf.erb +0 -9
  127. data/test/thread_safety_test.rb +0 -62
  128. data/test/transactions_test.rb +0 -264
  129. data/test/unknown_commands_test.rb +0 -14
  130. data/test/url_param_test.rb +0 -138
data/lib/redis/client.rb CHANGED
@@ -1,29 +1,38 @@
1
- require "redis/errors"
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "errors"
2
4
  require "socket"
3
5
  require "cgi"
4
6
 
5
7
  class Redis
6
8
  class Client
7
-
9
+ # Defaults are also used for converting string keys to symbols.
8
10
  DEFAULTS = {
9
- :url => lambda { ENV["REDIS_URL"] },
10
- :scheme => "redis",
11
- :host => "127.0.0.1",
12
- :port => 6379,
13
- :path => nil,
14
- :timeout => 5.0,
15
- :password => nil,
16
- :db => 0,
17
- :driver => nil,
18
- :id => nil,
19
- :tcp_keepalive => 0,
20
- :reconnect_attempts => 1,
21
- :inherit_socket => false
22
- }
23
-
24
- def options
25
- Marshal.load(Marshal.dump(@options))
26
- end
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
27
36
 
28
37
  def scheme
29
38
  @options[:scheme]
@@ -53,6 +62,10 @@ class Redis
53
62
  @options[:read_timeout]
54
63
  end
55
64
 
65
+ def username
66
+ @options[:username]
67
+ end
68
+
56
69
  def password
57
70
  @options[:password]
58
71
  end
@@ -86,11 +99,14 @@ class Redis
86
99
 
87
100
  @pending_reads = 0
88
101
 
89
- if options.include?(:sentinels)
90
- @connector = Connector::Sentinel.new(@options)
91
- else
92
- @connector = Connector.new(@options)
93
- end
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
94
110
  end
95
111
 
96
112
  def connect
@@ -99,7 +115,33 @@ class Redis
99
115
  # Don't try to reconnect when the connection is fresh
100
116
  with_reconnect(false) do
101
117
  establish_connection
102
- call [:auth, password] if password
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
+ elsif err.message.match?(/WRONGPASS invalid username-password pair/)
126
+ begin
127
+ call [:auth, password]
128
+ rescue CommandError
129
+ raise err
130
+ end
131
+ ::Kernel.warn(
132
+ "[redis-rb] The Redis connection was configured with username #{username.inspect}, but" \
133
+ " the provided password was for the default user. This will start failing in redis-rb 4.6."
134
+ )
135
+ else
136
+ raise
137
+ end
138
+ end
139
+ else
140
+ call [:auth, password]
141
+ end
142
+ end
143
+
144
+ call [:readonly] if @options[:readonly]
103
145
  call [:select, db] if db != 0
104
146
  call [:client, :setname, @options[:id]] if @options[:id]
105
147
  @connector.check(self)
@@ -120,7 +162,7 @@ class Redis
120
162
  reply = process([command]) { read }
121
163
  raise reply if reply.is_a?(CommandError)
122
164
 
123
- if block_given?
165
+ if block_given? && reply != 'QUEUED'
124
166
  yield reply
125
167
  else
126
168
  reply
@@ -152,13 +194,16 @@ class Redis
152
194
  end
153
195
 
154
196
  def call_pipeline(pipeline)
197
+ return [] if pipeline.futures.empty?
198
+
155
199
  with_reconnect pipeline.with_reconnect? do
156
200
  begin
157
- pipeline.finish(call_pipelined(pipeline.commands)).tap do
201
+ pipeline.finish(call_pipelined(pipeline)).tap do
158
202
  self.db = pipeline.db if pipeline.db
159
203
  end
160
204
  rescue ConnectionError => e
161
205
  return nil if pipeline.shutdown?
206
+
162
207
  # Assume the pipeline was sent in one piece, but execution of
163
208
  # SHUTDOWN caused none of the replies for commands that were executed
164
209
  # prior to it from coming back around.
@@ -167,8 +212,8 @@ class Redis
167
212
  end
168
213
  end
169
214
 
170
- def call_pipelined(commands)
171
- return [] if commands.empty?
215
+ def call_pipelined(pipeline)
216
+ return [] if pipeline.futures.empty?
172
217
 
173
218
  # The method #ensure_connected (called from #process) reconnects once on
174
219
  # I/O errors. To make an effort in making sure that commands are not
@@ -178,6 +223,8 @@ class Redis
178
223
  # already successfully executed commands. To circumvent this, don't retry
179
224
  # after the first reply has been read successfully.
180
225
 
226
+ commands = pipeline.commands
227
+
181
228
  result = Array.new(commands.size)
182
229
  reconnect = @reconnect
183
230
 
@@ -185,13 +232,14 @@ class Redis
185
232
  exception = nil
186
233
 
187
234
  process(commands) do
188
- result[0] = read
189
-
190
- @reconnect = false
191
-
192
- (commands.size - 1).times do |i|
193
- reply = read
194
- result[i + 1] = reply
235
+ pipeline.timeouts.each_with_index do |timeout, i|
236
+ reply = if timeout
237
+ with_socket_timeout(timeout) { read }
238
+ else
239
+ read
240
+ end
241
+ result[i] = reply
242
+ @reconnect = false
195
243
  exception = reply if exception.nil? && reply.is_a?(CommandError)
196
244
  end
197
245
  end
@@ -234,12 +282,13 @@ class Redis
234
282
  end
235
283
 
236
284
  def connected?
237
- !! (connection && connection.connected?)
285
+ !!(connection && connection.connected?)
238
286
  end
239
287
 
240
288
  def disconnect
241
289
  connection.disconnect if connected?
242
290
  end
291
+ alias close disconnect
243
292
 
244
293
  def reconnect
245
294
  disconnect
@@ -274,12 +323,15 @@ class Redis
274
323
 
275
324
  def with_socket_timeout(timeout)
276
325
  connect unless connected?
326
+ original = @options[:read_timeout]
277
327
 
278
328
  begin
279
329
  connection.timeout = timeout
330
+ @options[:read_timeout] = timeout # for reconnection
280
331
  yield
281
332
  ensure
282
333
  connection.timeout = self.timeout if connected?
334
+ @options[:read_timeout] = original
283
335
  end
284
336
  end
285
337
 
@@ -287,30 +339,27 @@ class Redis
287
339
  with_socket_timeout(0, &blk)
288
340
  end
289
341
 
290
- def with_reconnect(val=true)
291
- begin
292
- original, @reconnect = @reconnect, val
293
- yield
294
- ensure
295
- @reconnect = original
296
- end
342
+ def with_reconnect(val = true)
343
+ original, @reconnect = @reconnect, val
344
+ yield
345
+ ensure
346
+ @reconnect = original
297
347
  end
298
348
 
299
349
  def without_reconnect(&blk)
300
350
  with_reconnect(false, &blk)
301
351
  end
302
352
 
303
- protected
353
+ protected
304
354
 
305
355
  def logging(commands)
306
- return yield unless @logger && @logger.debug?
356
+ return yield unless @logger&.debug?
307
357
 
308
358
  begin
309
359
  commands.each do |name, *args|
310
360
  logged_args = args.map do |a|
311
- case
312
- when a.respond_to?(:inspect) then a.inspect
313
- when a.respond_to?(:to_s) then a.to_s
361
+ if a.respond_to?(:inspect) then a.inspect
362
+ elsif a.respond_to?(:to_s) then a.to_s
314
363
  else
315
364
  # handle poorly-behaved descendants of BasicObject
316
365
  klass = a.instance_exec { (class << self; self end).superclass }
@@ -336,13 +385,17 @@ class Redis
336
385
  @connection = @options[:driver].connect(@options)
337
386
  @pending_reads = 0
338
387
  rescue TimeoutError,
388
+ SocketError,
389
+ Errno::EADDRNOTAVAIL,
339
390
  Errno::ECONNREFUSED,
340
391
  Errno::EHOSTDOWN,
341
392
  Errno::EHOSTUNREACH,
342
393
  Errno::ENETUNREACH,
343
- Errno::ETIMEDOUT
394
+ Errno::ENOENT,
395
+ Errno::ETIMEDOUT,
396
+ Errno::EINVAL => error
344
397
 
345
- raise CannotConnectError, "Error connecting to Redis on #{location} (#{$!.class})"
398
+ raise CannotConnectError, "Error connecting to Redis on #{location} (#{error.class})"
346
399
  end
347
400
 
348
401
  def ensure_connected
@@ -356,9 +409,9 @@ class Redis
356
409
  if connected?
357
410
  unless inherit_socket? || Process.pid == @pid
358
411
  raise InheritedError,
359
- "Tried to use a connection from a child process without reconnecting. " +
360
- "You need to reconnect to Redis after forking " +
361
- "or set :inherit_socket to true."
412
+ "Tried to use a connection from a child process without reconnecting. " \
413
+ "You need to reconnect to Redis after forking " \
414
+ "or set :inherit_socket to true."
362
415
  end
363
416
  else
364
417
  connect
@@ -369,6 +422,10 @@ class Redis
369
422
  disconnect
370
423
 
371
424
  if attempts <= @options[:reconnect_attempts] && @reconnect
425
+ sleep_t = [(@options[:reconnect_delay] * 2**(attempts - 1)),
426
+ @options[:reconnect_delay_max]].min
427
+
428
+ Kernel.sleep(sleep_t)
372
429
  retry
373
430
  else
374
431
  raise
@@ -387,15 +444,14 @@ class Redis
387
444
 
388
445
  defaults.keys.each do |key|
389
446
  # Fill in defaults if needed
390
- if defaults[key].respond_to?(:call)
391
- defaults[key] = defaults[key].call
392
- end
447
+ defaults[key] = defaults[key].call if defaults[key].respond_to?(:call)
393
448
 
394
449
  # Symbolize only keys that are needed
395
- options[key] = options[key.to_s] if options.has_key?(key.to_s)
450
+ options[key] = options[key.to_s] if options.key?(key.to_s)
396
451
  end
397
452
 
398
- url = options[:url] || defaults[:url]
453
+ url = options[:url]
454
+ url = defaults[:url] if url.nil?
399
455
 
400
456
  # Override defaults from URL if given
401
457
  if url
@@ -404,12 +460,13 @@ class Redis
404
460
  uri = URI(url)
405
461
 
406
462
  if uri.scheme == "unix"
407
- defaults[:path] = uri.path
463
+ defaults[:path] = uri.path
408
464
  elsif uri.scheme == "redis" || uri.scheme == "rediss"
409
465
  defaults[:scheme] = uri.scheme
410
466
  defaults[:host] = uri.host if uri.host
411
467
  defaults[:port] = uri.port if uri.port
412
- defaults[:password] = CGI.unescape(uri.password) if uri.password
468
+ defaults[:username] = CGI.unescape(uri.user) if uri.user && !uri.user.empty?
469
+ defaults[:password] = CGI.unescape(uri.password) if uri.password && !uri.password.empty?
413
470
  defaults[:db] = uri.path[1..-1].to_i if uri.path
414
471
  defaults[:role] = :master
415
472
  else
@@ -435,7 +492,7 @@ class Redis
435
492
  options[:port] = options[:port].to_i
436
493
  end
437
494
 
438
- if options.has_key?(:timeout)
495
+ if options.key?(:timeout)
439
496
  options[:connect_timeout] ||= options[:timeout]
440
497
  options[:read_timeout] ||= options[:timeout]
441
498
  options[:write_timeout] ||= options[:timeout]
@@ -445,12 +502,16 @@ class Redis
445
502
  options[:read_timeout] = Float(options[:read_timeout])
446
503
  options[:write_timeout] = Float(options[:write_timeout])
447
504
 
505
+ options[:reconnect_attempts] = options[:reconnect_attempts].to_i
506
+ options[:reconnect_delay] = options[:reconnect_delay].to_f
507
+ options[:reconnect_delay_max] = options[:reconnect_delay_max].to_f
508
+
448
509
  options[:db] = options[:db].to_i
449
510
  options[:driver] = _parse_driver(options[:driver]) || Connection.drivers.last
450
511
 
451
512
  case options[:tcp_keepalive]
452
513
  when Hash
453
- [:time, :intvl, :probes].each do |key|
514
+ %i[time intvl probes].each do |key|
454
515
  unless options[:tcp_keepalive][key].is_a?(Integer)
455
516
  raise "Expected the #{key.inspect} key in :tcp_keepalive to be an Integer"
456
517
  end
@@ -458,13 +519,13 @@ class Redis
458
519
 
459
520
  when Integer
460
521
  if options[:tcp_keepalive] >= 60
461
- options[:tcp_keepalive] = {:time => options[:tcp_keepalive] - 20, :intvl => 10, :probes => 2}
522
+ options[:tcp_keepalive] = { time: options[:tcp_keepalive] - 20, intvl: 10, probes: 2 }
462
523
 
463
524
  elsif options[:tcp_keepalive] >= 30
464
- options[:tcp_keepalive] = {:time => options[:tcp_keepalive] - 10, :intvl => 5, :probes => 2}
525
+ options[:tcp_keepalive] = { time: options[:tcp_keepalive] - 10, intvl: 5, probes: 2 }
465
526
 
466
527
  elsif options[:tcp_keepalive] >= 5
467
- options[:tcp_keepalive] = {:time => options[:tcp_keepalive] - 2, :intvl => 2, :probes => 1}
528
+ options[:tcp_keepalive] = { time: options[:tcp_keepalive] - 2, intvl: 2, probes: 1 }
468
529
  end
469
530
  end
470
531
 
@@ -476,13 +537,18 @@ class Redis
476
537
  def _parse_driver(driver)
477
538
  driver = driver.to_s if driver.is_a?(Symbol)
478
539
 
479
- if driver.kind_of?(String)
540
+ if driver.is_a?(String)
480
541
  begin
481
- require "redis/connection/#{driver}"
482
- driver = Connection.const_get(driver.capitalize)
542
+ require_relative "connection/#{driver}"
483
543
  rescue LoadError, NameError
484
- raise RuntimeError, "Cannot load driver #{driver.inspect}"
544
+ begin
545
+ require "redis/connection/#{driver}"
546
+ rescue LoadError, NameError => error
547
+ raise "Cannot load driver #{driver.inspect}: #{error.message}"
548
+ end
485
549
  end
550
+
551
+ driver = Connection.const_get(driver.capitalize)
486
552
  end
487
553
 
488
554
  driver
@@ -497,18 +563,16 @@ class Redis
497
563
  @options
498
564
  end
499
565
 
500
- def check(client)
501
- end
566
+ def check(client); end
502
567
 
503
568
  class Sentinel < Connector
504
569
  def initialize(options)
505
570
  super(options)
506
571
 
507
- @options[:password] = DEFAULTS.fetch(:password)
508
572
  @options[:db] = DEFAULTS.fetch(:db)
509
573
 
510
574
  @sentinels = @options.delete(:sentinels).dup
511
- @role = @options.fetch(:role, "master").to_s
575
+ @role = (@options[:role] || "master").to_s
512
576
  @master = @options[:host]
513
577
  end
514
578
 
@@ -531,13 +595,13 @@ class Redis
531
595
 
532
596
  def resolve
533
597
  result = case @role
534
- when "master"
535
- resolve_master
536
- when "slave"
537
- resolve_slave
538
- else
539
- raise ArgumentError, "Unknown instance role #{@role}"
540
- end
598
+ when "master"
599
+ resolve_master
600
+ when "slave"
601
+ resolve_slave
602
+ else
603
+ raise ArgumentError, "Unknown instance role #{@role}"
604
+ end
541
605
 
542
606
  result || (raise ConnectionError, "Unable to fetch #{@role} via Sentinel.")
543
607
  end
@@ -545,10 +609,12 @@ class Redis
545
609
  def sentinel_detect
546
610
  @sentinels.each do |sentinel|
547
611
  client = Client.new(@options.merge({
548
- :host => sentinel[:host],
549
- :port => sentinel[:port],
550
- :reconnect_attempts => 0,
551
- }))
612
+ host: sentinel[:host] || sentinel["host"],
613
+ port: sentinel[:port] || sentinel["port"],
614
+ username: sentinel[:username] || sentinel["username"],
615
+ password: sentinel[:password] || sentinel["password"],
616
+ reconnect_attempts: 0
617
+ }))
552
618
 
553
619
  begin
554
620
  if result = yield(client)
@@ -570,7 +636,7 @@ class Redis
570
636
  def resolve_master
571
637
  sentinel_detect do |client|
572
638
  if reply = client.call(["sentinel", "get-master-addr-by-name", @master])
573
- {:host => reply[0], :port => reply[1]}
639
+ { host: reply[0], port: reply[1] }
574
640
  end
575
641
  end
576
642
  end
@@ -578,9 +644,19 @@ class Redis
578
644
  def resolve_slave
579
645
  sentinel_detect do |client|
580
646
  if reply = client.call(["sentinel", "slaves", @master])
581
- slave = Hash[*reply.sample]
582
-
583
- {:host => slave.fetch("ip"), :port => slave.fetch("port")}
647
+ slaves = reply.map { |s| s.each_slice(2).to_h }
648
+ slaves.each { |s| s['flags'] = s.fetch('flags').split(',') }
649
+ slaves.reject! { |s| s.fetch('flags').include?('s_down') }
650
+
651
+ if slaves.empty?
652
+ raise CannotConnectError, 'No slaves available.'
653
+ else
654
+ slave = slaves.sample
655
+ {
656
+ host: slave.fetch('ip'),
657
+ port: slave.fetch('port')
658
+ }
659
+ end
584
660
  end
585
661
  end
586
662
  end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../errors'
4
+
5
+ class Redis
6
+ class Cluster
7
+ # Keep details about Redis commands for Redis Cluster Client.
8
+ # @see https://redis.io/commands/command
9
+ class Command
10
+ def initialize(details)
11
+ @details = pick_details(details)
12
+ end
13
+
14
+ def extract_first_key(command)
15
+ i = determine_first_key_position(command)
16
+ return '' if i == 0
17
+
18
+ key = command[i].to_s
19
+ hash_tag = extract_hash_tag(key)
20
+ hash_tag.empty? ? key : hash_tag
21
+ end
22
+
23
+ def should_send_to_master?(command)
24
+ dig_details(command, :write)
25
+ end
26
+
27
+ def should_send_to_slave?(command)
28
+ dig_details(command, :readonly)
29
+ end
30
+
31
+ private
32
+
33
+ def pick_details(details)
34
+ details.map do |command, detail|
35
+ [command, {
36
+ first_key_position: detail[:first],
37
+ write: detail[:flags].include?('write'),
38
+ readonly: detail[:flags].include?('readonly')
39
+ }]
40
+ end.to_h
41
+ end
42
+
43
+ def dig_details(command, key)
44
+ name = command.first.to_s
45
+ return unless @details.key?(name)
46
+
47
+ @details.fetch(name).fetch(key)
48
+ end
49
+
50
+ def determine_first_key_position(command)
51
+ case command.first.to_s.downcase
52
+ when 'eval', 'evalsha', 'migrate', 'zinterstore', 'zunionstore' then 3
53
+ when 'object' then 2
54
+ when 'memory'
55
+ command[1].to_s.casecmp('usage').zero? ? 2 : 0
56
+ when 'scan', 'sscan', 'hscan', 'zscan'
57
+ determine_optional_key_position(command, 'match')
58
+ when 'xread', 'xreadgroup'
59
+ determine_optional_key_position(command, 'streams')
60
+ else
61
+ dig_details(command, :first_key_position).to_i
62
+ end
63
+ end
64
+
65
+ def determine_optional_key_position(command, option_name)
66
+ idx = command.map(&:to_s).map(&:downcase).index(option_name)
67
+ idx.nil? ? 0 : idx + 1
68
+ end
69
+
70
+ # @see https://redis.io/topics/cluster-spec#keys-hash-tags Keys hash tags
71
+ def extract_hash_tag(key)
72
+ s = key.index('{')
73
+ e = key.index('}', s.to_i + 1)
74
+
75
+ return '' if s.nil? || e.nil?
76
+
77
+ key[s + 1..e - 1]
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../errors'
4
+
5
+ class Redis
6
+ class Cluster
7
+ # Load details about Redis commands for Redis Cluster Client
8
+ # @see https://redis.io/commands/command
9
+ module CommandLoader
10
+ module_function
11
+
12
+ def load(nodes)
13
+ nodes.each do |node|
14
+ begin
15
+ return fetch_command_details(node)
16
+ rescue CannotConnectError, ConnectionError, CommandError
17
+ next # can retry on another node
18
+ end
19
+ end
20
+
21
+ raise CannotConnectError, 'Redis client could not connect to any cluster nodes'
22
+ end
23
+
24
+ def fetch_command_details(node)
25
+ node.call(%i[command]).map do |reply|
26
+ [reply[0], { arity: reply[1], flags: reply[2], first: reply[3], last: reply[4], step: reply[5] }]
27
+ end.to_h
28
+ end
29
+
30
+ private_class_method :fetch_command_details
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Redis
4
+ class Cluster
5
+ # Key to slot converter for Redis Cluster Client
6
+ #
7
+ # We can test it by `CLUSTER KEYSLOT` command.
8
+ #
9
+ # @see https://github.com/antirez/redis-rb-cluster
10
+ # Reference implementation in Ruby
11
+ # @see https://redis.io/topics/cluster-spec#appendix
12
+ # Reference implementation in ANSI C
13
+ # @see https://redis.io/commands/cluster-keyslot
14
+ # CLUSTER KEYSLOT command reference
15
+ #
16
+ # Copyright (C) 2013 Salvatore Sanfilippo <antirez@gmail.com>
17
+ module KeySlotConverter
18
+ XMODEM_CRC16_LOOKUP = [
19
+ 0x0000, 0x1021, 0x2042, 0x3063, 0x4084, 0x50a5, 0x60c6, 0x70e7,
20
+ 0x8108, 0x9129, 0xa14a, 0xb16b, 0xc18c, 0xd1ad, 0xe1ce, 0xf1ef,
21
+ 0x1231, 0x0210, 0x3273, 0x2252, 0x52b5, 0x4294, 0x72f7, 0x62d6,
22
+ 0x9339, 0x8318, 0xb37b, 0xa35a, 0xd3bd, 0xc39c, 0xf3ff, 0xe3de,
23
+ 0x2462, 0x3443, 0x0420, 0x1401, 0x64e6, 0x74c7, 0x44a4, 0x5485,
24
+ 0xa56a, 0xb54b, 0x8528, 0x9509, 0xe5ee, 0xf5cf, 0xc5ac, 0xd58d,
25
+ 0x3653, 0x2672, 0x1611, 0x0630, 0x76d7, 0x66f6, 0x5695, 0x46b4,
26
+ 0xb75b, 0xa77a, 0x9719, 0x8738, 0xf7df, 0xe7fe, 0xd79d, 0xc7bc,
27
+ 0x48c4, 0x58e5, 0x6886, 0x78a7, 0x0840, 0x1861, 0x2802, 0x3823,
28
+ 0xc9cc, 0xd9ed, 0xe98e, 0xf9af, 0x8948, 0x9969, 0xa90a, 0xb92b,
29
+ 0x5af5, 0x4ad4, 0x7ab7, 0x6a96, 0x1a71, 0x0a50, 0x3a33, 0x2a12,
30
+ 0xdbfd, 0xcbdc, 0xfbbf, 0xeb9e, 0x9b79, 0x8b58, 0xbb3b, 0xab1a,
31
+ 0x6ca6, 0x7c87, 0x4ce4, 0x5cc5, 0x2c22, 0x3c03, 0x0c60, 0x1c41,
32
+ 0xedae, 0xfd8f, 0xcdec, 0xddcd, 0xad2a, 0xbd0b, 0x8d68, 0x9d49,
33
+ 0x7e97, 0x6eb6, 0x5ed5, 0x4ef4, 0x3e13, 0x2e32, 0x1e51, 0x0e70,
34
+ 0xff9f, 0xefbe, 0xdfdd, 0xcffc, 0xbf1b, 0xaf3a, 0x9f59, 0x8f78,
35
+ 0x9188, 0x81a9, 0xb1ca, 0xa1eb, 0xd10c, 0xc12d, 0xf14e, 0xe16f,
36
+ 0x1080, 0x00a1, 0x30c2, 0x20e3, 0x5004, 0x4025, 0x7046, 0x6067,
37
+ 0x83b9, 0x9398, 0xa3fb, 0xb3da, 0xc33d, 0xd31c, 0xe37f, 0xf35e,
38
+ 0x02b1, 0x1290, 0x22f3, 0x32d2, 0x4235, 0x5214, 0x6277, 0x7256,
39
+ 0xb5ea, 0xa5cb, 0x95a8, 0x8589, 0xf56e, 0xe54f, 0xd52c, 0xc50d,
40
+ 0x34e2, 0x24c3, 0x14a0, 0x0481, 0x7466, 0x6447, 0x5424, 0x4405,
41
+ 0xa7db, 0xb7fa, 0x8799, 0x97b8, 0xe75f, 0xf77e, 0xc71d, 0xd73c,
42
+ 0x26d3, 0x36f2, 0x0691, 0x16b0, 0x6657, 0x7676, 0x4615, 0x5634,
43
+ 0xd94c, 0xc96d, 0xf90e, 0xe92f, 0x99c8, 0x89e9, 0xb98a, 0xa9ab,
44
+ 0x5844, 0x4865, 0x7806, 0x6827, 0x18c0, 0x08e1, 0x3882, 0x28a3,
45
+ 0xcb7d, 0xdb5c, 0xeb3f, 0xfb1e, 0x8bf9, 0x9bd8, 0xabbb, 0xbb9a,
46
+ 0x4a75, 0x5a54, 0x6a37, 0x7a16, 0x0af1, 0x1ad0, 0x2ab3, 0x3a92,
47
+ 0xfd2e, 0xed0f, 0xdd6c, 0xcd4d, 0xbdaa, 0xad8b, 0x9de8, 0x8dc9,
48
+ 0x7c26, 0x6c07, 0x5c64, 0x4c45, 0x3ca2, 0x2c83, 0x1ce0, 0x0cc1,
49
+ 0xef1f, 0xff3e, 0xcf5d, 0xdf7c, 0xaf9b, 0xbfba, 0x8fd9, 0x9ff8,
50
+ 0x6e17, 0x7e36, 0x4e55, 0x5e74, 0x2e93, 0x3eb2, 0x0ed1, 0x1ef0
51
+ ].freeze
52
+
53
+ HASH_SLOTS = 16_384
54
+
55
+ module_function
56
+
57
+ # Convert key into slot.
58
+ #
59
+ # @param key [String] the key of the redis command
60
+ #
61
+ # @return [Integer] slot number
62
+ def convert(key)
63
+ crc = 0
64
+ key.each_byte do |b|
65
+ crc = ((crc << 8) & 0xffff) ^ XMODEM_CRC16_LOOKUP[((crc >> 8) ^ b) & 0xff]
66
+ end
67
+
68
+ crc % HASH_SLOTS
69
+ end
70
+ end
71
+ end
72
+ end