redis 4.0.0.rc1 → 4.4.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 (101) hide show
  1. checksums.yaml +5 -5
  2. data/CHANGELOG.md +143 -3
  3. data/README.md +127 -18
  4. data/lib/redis/client.rb +150 -93
  5. data/lib/redis/cluster/command.rb +81 -0
  6. data/lib/redis/cluster/command_loader.rb +34 -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 +3 -2
  16. data/lib/redis/connection/hiredis.rb +4 -3
  17. data/lib/redis/connection/registry.rb +2 -1
  18. data/lib/redis/connection/ruby.rb +123 -105
  19. data/lib/redis/connection/synchrony.rb +18 -5
  20. data/lib/redis/connection.rb +2 -0
  21. data/lib/redis/distributed.rb +955 -0
  22. data/lib/redis/errors.rb +48 -0
  23. data/lib/redis/hash_ring.rb +89 -0
  24. data/lib/redis/pipeline.rb +55 -9
  25. data/lib/redis/subscribe.rb +11 -12
  26. data/lib/redis/version.rb +3 -1
  27. data/lib/redis.rb +1242 -381
  28. metadata +34 -141
  29. data/.gitignore +0 -16
  30. data/.travis/Gemfile +0 -11
  31. data/.travis.yml +0 -71
  32. data/.yardopts +0 -3
  33. data/Gemfile +0 -3
  34. data/benchmarking/logging.rb +0 -71
  35. data/benchmarking/pipeline.rb +0 -51
  36. data/benchmarking/speed.rb +0 -21
  37. data/benchmarking/suite.rb +0 -24
  38. data/benchmarking/worker.rb +0 -71
  39. data/examples/basic.rb +0 -15
  40. data/examples/consistency.rb +0 -114
  41. data/examples/incr-decr.rb +0 -17
  42. data/examples/list.rb +0 -26
  43. data/examples/pubsub.rb +0 -37
  44. data/examples/sentinel/sentinel.conf +0 -9
  45. data/examples/sentinel/start +0 -49
  46. data/examples/sentinel.rb +0 -41
  47. data/examples/sets.rb +0 -36
  48. data/examples/unicorn/config.ru +0 -3
  49. data/examples/unicorn/unicorn.rb +0 -20
  50. data/makefile +0 -42
  51. data/redis.gemspec +0 -40
  52. data/test/bitpos_test.rb +0 -63
  53. data/test/blocking_commands_test.rb +0 -183
  54. data/test/client_test.rb +0 -59
  55. data/test/command_map_test.rb +0 -28
  56. data/test/commands_on_hashes_test.rb +0 -174
  57. data/test/commands_on_hyper_log_log_test.rb +0 -70
  58. data/test/commands_on_lists_test.rb +0 -154
  59. data/test/commands_on_sets_test.rb +0 -208
  60. data/test/commands_on_sorted_sets_test.rb +0 -444
  61. data/test/commands_on_strings_test.rb +0 -338
  62. data/test/commands_on_value_types_test.rb +0 -246
  63. data/test/connection_handling_test.rb +0 -275
  64. data/test/db/.gitkeep +0 -0
  65. data/test/encoding_test.rb +0 -14
  66. data/test/error_replies_test.rb +0 -57
  67. data/test/fork_safety_test.rb +0 -60
  68. data/test/helper.rb +0 -179
  69. data/test/helper_test.rb +0 -22
  70. data/test/internals_test.rb +0 -435
  71. data/test/persistence_control_commands_test.rb +0 -24
  72. data/test/pipelining_commands_test.rb +0 -238
  73. data/test/publish_subscribe_test.rb +0 -280
  74. data/test/remote_server_control_commands_test.rb +0 -175
  75. data/test/scanning_test.rb +0 -407
  76. data/test/scripting_test.rb +0 -76
  77. data/test/sentinel_command_test.rb +0 -78
  78. data/test/sentinel_test.rb +0 -253
  79. data/test/sorting_test.rb +0 -57
  80. data/test/ssl_test.rb +0 -69
  81. data/test/support/connection/hiredis.rb +0 -1
  82. data/test/support/connection/ruby.rb +0 -1
  83. data/test/support/connection/synchrony.rb +0 -17
  84. data/test/support/redis_mock.rb +0 -130
  85. data/test/support/ssl/gen_certs.sh +0 -31
  86. data/test/support/ssl/trusted-ca.crt +0 -25
  87. data/test/support/ssl/trusted-ca.key +0 -27
  88. data/test/support/ssl/trusted-cert.crt +0 -81
  89. data/test/support/ssl/trusted-cert.key +0 -28
  90. data/test/support/ssl/untrusted-ca.crt +0 -26
  91. data/test/support/ssl/untrusted-ca.key +0 -27
  92. data/test/support/ssl/untrusted-cert.crt +0 -82
  93. data/test/support/ssl/untrusted-cert.key +0 -28
  94. data/test/support/wire/synchrony.rb +0 -24
  95. data/test/support/wire/thread.rb +0 -5
  96. data/test/synchrony_driver.rb +0 -85
  97. data/test/test.conf.erb +0 -9
  98. data/test/thread_safety_test.rb +0 -60
  99. data/test/transactions_test.rb +0 -262
  100. data/test/unknown_commands_test.rb +0 -12
  101. data/test/url_param_test.rb +0 -136
data/lib/redis/client.rb CHANGED
@@ -1,29 +1,38 @@
1
+ # frozen_string_literal: true
2
+
1
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,19 @@ 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 # Likely on Redis < 6
123
+ call [:auth, password]
124
+ end
125
+ else
126
+ call [:auth, password]
127
+ end
128
+ end
129
+
130
+ call [:readonly] if @options[:readonly]
103
131
  call [:select, db] if db != 0
104
132
  call [:client, :setname, @options[:id]] if @options[:id]
105
133
  @connector.check(self)
@@ -120,7 +148,7 @@ class Redis
120
148
  reply = process([command]) { read }
121
149
  raise reply if reply.is_a?(CommandError)
122
150
 
123
- if block_given?
151
+ if block_given? && reply != 'QUEUED'
124
152
  yield reply
125
153
  else
126
154
  reply
@@ -152,13 +180,16 @@ class Redis
152
180
  end
153
181
 
154
182
  def call_pipeline(pipeline)
183
+ return [] if pipeline.futures.empty?
184
+
155
185
  with_reconnect pipeline.with_reconnect? do
156
186
  begin
157
- pipeline.finish(call_pipelined(pipeline.commands)).tap do
187
+ pipeline.finish(call_pipelined(pipeline)).tap do
158
188
  self.db = pipeline.db if pipeline.db
159
189
  end
160
190
  rescue ConnectionError => e
161
191
  return nil if pipeline.shutdown?
192
+
162
193
  # Assume the pipeline was sent in one piece, but execution of
163
194
  # SHUTDOWN caused none of the replies for commands that were executed
164
195
  # prior to it from coming back around.
@@ -167,8 +198,8 @@ class Redis
167
198
  end
168
199
  end
169
200
 
170
- def call_pipelined(commands)
171
- return [] if commands.empty?
201
+ def call_pipelined(pipeline)
202
+ return [] if pipeline.futures.empty?
172
203
 
173
204
  # The method #ensure_connected (called from #process) reconnects once on
174
205
  # I/O errors. To make an effort in making sure that commands are not
@@ -178,6 +209,8 @@ class Redis
178
209
  # already successfully executed commands. To circumvent this, don't retry
179
210
  # after the first reply has been read successfully.
180
211
 
212
+ commands = pipeline.commands
213
+
181
214
  result = Array.new(commands.size)
182
215
  reconnect = @reconnect
183
216
 
@@ -185,13 +218,14 @@ class Redis
185
218
  exception = nil
186
219
 
187
220
  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
221
+ pipeline.timeouts.each_with_index do |timeout, i|
222
+ reply = if timeout
223
+ with_socket_timeout(timeout) { read }
224
+ else
225
+ read
226
+ end
227
+ result[i] = reply
228
+ @reconnect = false
195
229
  exception = reply if exception.nil? && reply.is_a?(CommandError)
196
230
  end
197
231
  end
@@ -234,12 +268,13 @@ class Redis
234
268
  end
235
269
 
236
270
  def connected?
237
- !! (connection && connection.connected?)
271
+ !!(connection && connection.connected?)
238
272
  end
239
273
 
240
274
  def disconnect
241
275
  connection.disconnect if connected?
242
276
  end
277
+ alias close disconnect
243
278
 
244
279
  def reconnect
245
280
  disconnect
@@ -274,12 +309,15 @@ class Redis
274
309
 
275
310
  def with_socket_timeout(timeout)
276
311
  connect unless connected?
312
+ original = @options[:read_timeout]
277
313
 
278
314
  begin
279
315
  connection.timeout = timeout
316
+ @options[:read_timeout] = timeout # for reconnection
280
317
  yield
281
318
  ensure
282
319
  connection.timeout = self.timeout if connected?
320
+ @options[:read_timeout] = original
283
321
  end
284
322
  end
285
323
 
@@ -287,30 +325,27 @@ class Redis
287
325
  with_socket_timeout(0, &blk)
288
326
  end
289
327
 
290
- def with_reconnect(val=true)
291
- begin
292
- original, @reconnect = @reconnect, val
293
- yield
294
- ensure
295
- @reconnect = original
296
- end
328
+ def with_reconnect(val = true)
329
+ original, @reconnect = @reconnect, val
330
+ yield
331
+ ensure
332
+ @reconnect = original
297
333
  end
298
334
 
299
335
  def without_reconnect(&blk)
300
336
  with_reconnect(false, &blk)
301
337
  end
302
338
 
303
- protected
339
+ protected
304
340
 
305
341
  def logging(commands)
306
- return yield unless @logger && @logger.debug?
342
+ return yield unless @logger&.debug?
307
343
 
308
344
  begin
309
345
  commands.each do |name, *args|
310
346
  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
347
+ if a.respond_to?(:inspect) then a.inspect
348
+ elsif a.respond_to?(:to_s) then a.to_s
314
349
  else
315
350
  # handle poorly-behaved descendants of BasicObject
316
351
  klass = a.instance_exec { (class << self; self end).superclass }
@@ -336,13 +371,17 @@ class Redis
336
371
  @connection = @options[:driver].connect(@options)
337
372
  @pending_reads = 0
338
373
  rescue TimeoutError,
374
+ SocketError,
375
+ Errno::EADDRNOTAVAIL,
339
376
  Errno::ECONNREFUSED,
340
377
  Errno::EHOSTDOWN,
341
378
  Errno::EHOSTUNREACH,
342
379
  Errno::ENETUNREACH,
343
- Errno::ETIMEDOUT
380
+ Errno::ENOENT,
381
+ Errno::ETIMEDOUT,
382
+ Errno::EINVAL => error
344
383
 
345
- raise CannotConnectError, "Error connecting to Redis on #{location} (#{$!.class})"
384
+ raise CannotConnectError, "Error connecting to Redis on #{location} (#{error.class})"
346
385
  end
347
386
 
348
387
  def ensure_connected
@@ -356,9 +395,9 @@ class Redis
356
395
  if connected?
357
396
  unless inherit_socket? || Process.pid == @pid
358
397
  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."
398
+ "Tried to use a connection from a child process without reconnecting. " \
399
+ "You need to reconnect to Redis after forking " \
400
+ "or set :inherit_socket to true."
362
401
  end
363
402
  else
364
403
  connect
@@ -369,6 +408,10 @@ class Redis
369
408
  disconnect
370
409
 
371
410
  if attempts <= @options[:reconnect_attempts] && @reconnect
411
+ sleep_t = [(@options[:reconnect_delay] * 2**(attempts - 1)),
412
+ @options[:reconnect_delay_max]].min
413
+
414
+ Kernel.sleep(sleep_t)
372
415
  retry
373
416
  else
374
417
  raise
@@ -387,15 +430,14 @@ class Redis
387
430
 
388
431
  defaults.keys.each do |key|
389
432
  # Fill in defaults if needed
390
- if defaults[key].respond_to?(:call)
391
- defaults[key] = defaults[key].call
392
- end
433
+ defaults[key] = defaults[key].call if defaults[key].respond_to?(:call)
393
434
 
394
435
  # Symbolize only keys that are needed
395
- options[key] = options[key.to_s] if options.has_key?(key.to_s)
436
+ options[key] = options[key.to_s] if options.key?(key.to_s)
396
437
  end
397
438
 
398
- url = options[:url] || defaults[:url]
439
+ url = options[:url]
440
+ url = defaults[:url] if url.nil?
399
441
 
400
442
  # Override defaults from URL if given
401
443
  if url
@@ -404,12 +446,13 @@ class Redis
404
446
  uri = URI(url)
405
447
 
406
448
  if uri.scheme == "unix"
407
- defaults[:path] = uri.path
449
+ defaults[:path] = uri.path
408
450
  elsif uri.scheme == "redis" || uri.scheme == "rediss"
409
451
  defaults[:scheme] = uri.scheme
410
452
  defaults[:host] = uri.host if uri.host
411
453
  defaults[:port] = uri.port if uri.port
412
- defaults[:password] = CGI.unescape(uri.password) if uri.password
454
+ defaults[:username] = CGI.unescape(uri.user) if uri.user && !uri.user.empty?
455
+ defaults[:password] = CGI.unescape(uri.password) if uri.password && !uri.password.empty?
413
456
  defaults[:db] = uri.path[1..-1].to_i if uri.path
414
457
  defaults[:role] = :master
415
458
  else
@@ -435,7 +478,7 @@ class Redis
435
478
  options[:port] = options[:port].to_i
436
479
  end
437
480
 
438
- if options.has_key?(:timeout)
481
+ if options.key?(:timeout)
439
482
  options[:connect_timeout] ||= options[:timeout]
440
483
  options[:read_timeout] ||= options[:timeout]
441
484
  options[:write_timeout] ||= options[:timeout]
@@ -445,26 +488,30 @@ class Redis
445
488
  options[:read_timeout] = Float(options[:read_timeout])
446
489
  options[:write_timeout] = Float(options[:write_timeout])
447
490
 
491
+ options[:reconnect_attempts] = options[:reconnect_attempts].to_i
492
+ options[:reconnect_delay] = options[:reconnect_delay].to_f
493
+ options[:reconnect_delay_max] = options[:reconnect_delay_max].to_f
494
+
448
495
  options[:db] = options[:db].to_i
449
496
  options[:driver] = _parse_driver(options[:driver]) || Connection.drivers.last
450
497
 
451
498
  case options[:tcp_keepalive]
452
499
  when Hash
453
- [:time, :intvl, :probes].each do |key|
454
- unless options[:tcp_keepalive][key].is_a?(Fixnum)
455
- raise "Expected the #{key.inspect} key in :tcp_keepalive to be a Fixnum"
500
+ %i[time intvl probes].each do |key|
501
+ unless options[:tcp_keepalive][key].is_a?(Integer)
502
+ raise "Expected the #{key.inspect} key in :tcp_keepalive to be an Integer"
456
503
  end
457
504
  end
458
505
 
459
- when Fixnum
506
+ when Integer
460
507
  if options[:tcp_keepalive] >= 60
461
- options[:tcp_keepalive] = {:time => options[:tcp_keepalive] - 20, :intvl => 10, :probes => 2}
508
+ options[:tcp_keepalive] = { time: options[:tcp_keepalive] - 20, intvl: 10, probes: 2 }
462
509
 
463
510
  elsif options[:tcp_keepalive] >= 30
464
- options[:tcp_keepalive] = {:time => options[:tcp_keepalive] - 10, :intvl => 5, :probes => 2}
511
+ options[:tcp_keepalive] = { time: options[:tcp_keepalive] - 10, intvl: 5, probes: 2 }
465
512
 
466
513
  elsif options[:tcp_keepalive] >= 5
467
- options[:tcp_keepalive] = {:time => options[:tcp_keepalive] - 2, :intvl => 2, :probes => 1}
514
+ options[:tcp_keepalive] = { time: options[:tcp_keepalive] - 2, intvl: 2, probes: 1 }
468
515
  end
469
516
  end
470
517
 
@@ -476,14 +523,14 @@ class Redis
476
523
  def _parse_driver(driver)
477
524
  driver = driver.to_s if driver.is_a?(Symbol)
478
525
 
479
- if driver.kind_of?(String)
526
+ if driver.is_a?(String)
480
527
  begin
481
528
  require_relative "connection/#{driver}"
482
- rescue LoadError, NameError => e
529
+ rescue LoadError, NameError
483
530
  begin
484
- require "connection/#{driver}"
485
- rescue LoadError, NameError => e
486
- raise RuntimeError, "Cannot load driver #{driver.inspect}: #{e.message}"
531
+ require "redis/connection/#{driver}"
532
+ rescue LoadError, NameError => error
533
+ raise "Cannot load driver #{driver.inspect}: #{error.message}"
487
534
  end
488
535
  end
489
536
 
@@ -502,18 +549,16 @@ class Redis
502
549
  @options
503
550
  end
504
551
 
505
- def check(client)
506
- end
552
+ def check(client); end
507
553
 
508
554
  class Sentinel < Connector
509
555
  def initialize(options)
510
556
  super(options)
511
557
 
512
- @options[:password] = DEFAULTS.fetch(:password)
513
558
  @options[:db] = DEFAULTS.fetch(:db)
514
559
 
515
560
  @sentinels = @options.delete(:sentinels).dup
516
- @role = @options.fetch(:role, "master").to_s
561
+ @role = (@options[:role] || "master").to_s
517
562
  @master = @options[:host]
518
563
  end
519
564
 
@@ -536,13 +581,13 @@ class Redis
536
581
 
537
582
  def resolve
538
583
  result = case @role
539
- when "master"
540
- resolve_master
541
- when "slave"
542
- resolve_slave
543
- else
544
- raise ArgumentError, "Unknown instance role #{@role}"
545
- end
584
+ when "master"
585
+ resolve_master
586
+ when "slave"
587
+ resolve_slave
588
+ else
589
+ raise ArgumentError, "Unknown instance role #{@role}"
590
+ end
546
591
 
547
592
  result || (raise ConnectionError, "Unable to fetch #{@role} via Sentinel.")
548
593
  end
@@ -550,10 +595,12 @@ class Redis
550
595
  def sentinel_detect
551
596
  @sentinels.each do |sentinel|
552
597
  client = Client.new(@options.merge({
553
- :host => sentinel[:host],
554
- :port => sentinel[:port],
555
- :reconnect_attempts => 0,
556
- }))
598
+ host: sentinel[:host] || sentinel["host"],
599
+ port: sentinel[:port] || sentinel["port"],
600
+ username: sentinel[:username] || sentinel["username"],
601
+ password: sentinel[:password] || sentinel["password"],
602
+ reconnect_attempts: 0
603
+ }))
557
604
 
558
605
  begin
559
606
  if result = yield(client)
@@ -575,7 +622,7 @@ class Redis
575
622
  def resolve_master
576
623
  sentinel_detect do |client|
577
624
  if reply = client.call(["sentinel", "get-master-addr-by-name", @master])
578
- {:host => reply[0], :port => reply[1]}
625
+ { host: reply[0], port: reply[1] }
579
626
  end
580
627
  end
581
628
  end
@@ -583,9 +630,19 @@ class Redis
583
630
  def resolve_slave
584
631
  sentinel_detect do |client|
585
632
  if reply = client.call(["sentinel", "slaves", @master])
586
- slave = Hash[*reply.sample]
587
-
588
- {:host => slave.fetch("ip"), :port => slave.fetch("port")}
633
+ slaves = reply.map { |s| s.each_slice(2).to_h }
634
+ slaves.each { |s| s['flags'] = s.fetch('flags').split(',') }
635
+ slaves.reject! { |s| s.fetch('flags').include?('s_down') }
636
+
637
+ if slaves.empty?
638
+ raise CannotConnectError, 'No slaves available.'
639
+ else
640
+ slave = slaves.sample
641
+ {
642
+ host: slave.fetch('ip'),
643
+ port: slave.fetch('port')
644
+ }
645
+ end
589
646
  end
590
647
  end
591
648
  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,34 @@
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
+ details = {}
14
+
15
+ nodes.each do |node|
16
+ details = fetch_command_details(node)
17
+ details.empty? ? next : break
18
+ end
19
+
20
+ details
21
+ end
22
+
23
+ def fetch_command_details(node)
24
+ node.call(%i[command]).map do |reply|
25
+ [reply[0], { arity: reply[1], flags: reply[2], first: reply[3], last: reply[4], step: reply[5] }]
26
+ end.to_h
27
+ rescue CannotConnectError, ConnectionError, CommandError
28
+ {} # can retry on another node
29
+ end
30
+
31
+ private_class_method :fetch_command_details
32
+ end
33
+ end
34
+ 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