redis 4.0.0.rc1 → 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 (101) hide show
  1. checksums.yaml +5 -5
  2. data/CHANGELOG.md +164 -3
  3. data/README.md +127 -18
  4. data/lib/redis/client.rb +164 -93
  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 +5 -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 +125 -106
  19. data/lib/redis/connection/synchrony.rb +18 -5
  20. data/lib/redis/connection.rb +2 -0
  21. data/lib/redis/distributed.rb +995 -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 +1433 -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,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,26 +502,30 @@ 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|
454
- unless options[:tcp_keepalive][key].is_a?(Fixnum)
455
- raise "Expected the #{key.inspect} key in :tcp_keepalive to be a Fixnum"
514
+ %i[time intvl probes].each do |key|
515
+ unless options[:tcp_keepalive][key].is_a?(Integer)
516
+ raise "Expected the #{key.inspect} key in :tcp_keepalive to be an Integer"
456
517
  end
457
518
  end
458
519
 
459
- when Fixnum
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,14 +537,14 @@ 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
542
  require_relative "connection/#{driver}"
482
- rescue LoadError, NameError => e
543
+ rescue LoadError, NameError
483
544
  begin
484
- require "connection/#{driver}"
485
- rescue LoadError, NameError => e
486
- raise RuntimeError, "Cannot load driver #{driver.inspect}: #{e.message}"
545
+ require "redis/connection/#{driver}"
546
+ rescue LoadError, NameError => error
547
+ raise "Cannot load driver #{driver.inspect}: #{error.message}"
487
548
  end
488
549
  end
489
550
 
@@ -502,18 +563,16 @@ class Redis
502
563
  @options
503
564
  end
504
565
 
505
- def check(client)
506
- end
566
+ def check(client); end
507
567
 
508
568
  class Sentinel < Connector
509
569
  def initialize(options)
510
570
  super(options)
511
571
 
512
- @options[:password] = DEFAULTS.fetch(:password)
513
572
  @options[:db] = DEFAULTS.fetch(:db)
514
573
 
515
574
  @sentinels = @options.delete(:sentinels).dup
516
- @role = @options.fetch(:role, "master").to_s
575
+ @role = (@options[:role] || "master").to_s
517
576
  @master = @options[:host]
518
577
  end
519
578
 
@@ -536,13 +595,13 @@ class Redis
536
595
 
537
596
  def resolve
538
597
  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
598
+ when "master"
599
+ resolve_master
600
+ when "slave"
601
+ resolve_slave
602
+ else
603
+ raise ArgumentError, "Unknown instance role #{@role}"
604
+ end
546
605
 
547
606
  result || (raise ConnectionError, "Unable to fetch #{@role} via Sentinel.")
548
607
  end
@@ -550,10 +609,12 @@ class Redis
550
609
  def sentinel_detect
551
610
  @sentinels.each do |sentinel|
552
611
  client = Client.new(@options.merge({
553
- :host => sentinel[:host],
554
- :port => sentinel[:port],
555
- :reconnect_attempts => 0,
556
- }))
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
+ }))
557
618
 
558
619
  begin
559
620
  if result = yield(client)
@@ -575,7 +636,7 @@ class Redis
575
636
  def resolve_master
576
637
  sentinel_detect do |client|
577
638
  if reply = client.call(["sentinel", "get-master-addr-by-name", @master])
578
- {:host => reply[0], :port => reply[1]}
639
+ { host: reply[0], port: reply[1] }
579
640
  end
580
641
  end
581
642
  end
@@ -583,9 +644,19 @@ class Redis
583
644
  def resolve_slave
584
645
  sentinel_detect do |client|
585
646
  if reply = client.call(["sentinel", "slaves", @master])
586
- slave = Hash[*reply.sample]
587
-
588
- {: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
589
660
  end
590
661
  end
591
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