redis 4.8.1 → 6.0.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 (57) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +138 -1
  3. data/README.md +285 -169
  4. data/lib/redis/client.rb +116 -611
  5. data/lib/redis/commands/bitmaps.rb +14 -4
  6. data/lib/redis/commands/cluster.rb +1 -18
  7. data/lib/redis/commands/connection.rb +5 -10
  8. data/lib/redis/commands/geo.rb +109 -7
  9. data/lib/redis/commands/hashes.rb +179 -8
  10. data/lib/redis/commands/hyper_log_log.rb +1 -1
  11. data/lib/redis/commands/keys.rb +32 -24
  12. data/lib/redis/commands/lists.rb +167 -25
  13. data/lib/redis/commands/modules/json.rb +530 -0
  14. data/lib/redis/commands/modules/search/aggregation.rb +418 -0
  15. data/lib/redis/commands/modules/search/dialect.rb +14 -0
  16. data/lib/redis/commands/modules/search/field.rb +306 -0
  17. data/lib/redis/commands/modules/search/hybrid.rb +359 -0
  18. data/lib/redis/commands/modules/search/index.rb +351 -0
  19. data/lib/redis/commands/modules/search/index_definition.rb +114 -0
  20. data/lib/redis/commands/modules/search/miscellaneous.rb +607 -0
  21. data/lib/redis/commands/modules/search/query.rb +738 -0
  22. data/lib/redis/commands/modules/search/result.rb +488 -0
  23. data/lib/redis/commands/modules/search/schema.rb +211 -0
  24. data/lib/redis/commands/modules/search.rb +19 -0
  25. data/lib/redis/commands/pubsub.rb +34 -25
  26. data/lib/redis/commands/server.rb +15 -15
  27. data/lib/redis/commands/sets.rb +76 -40
  28. data/lib/redis/commands/sorted_sets.rb +128 -19
  29. data/lib/redis/commands/streams.rb +75 -28
  30. data/lib/redis/commands/strings.rb +18 -17
  31. data/lib/redis/commands/transactions.rb +7 -31
  32. data/lib/redis/commands.rb +39 -20
  33. data/lib/redis/distributed.rb +407 -73
  34. data/lib/redis/errors.rb +20 -50
  35. data/lib/redis/hash_ring.rb +26 -26
  36. data/lib/redis/lib_identity.rb +105 -0
  37. data/lib/redis/pipeline.rb +47 -222
  38. data/lib/redis/subscribe.rb +51 -15
  39. data/lib/redis/version.rb +1 -1
  40. data/lib/redis.rb +213 -188
  41. metadata +25 -59
  42. data/lib/redis/cluster/command.rb +0 -79
  43. data/lib/redis/cluster/command_loader.rb +0 -33
  44. data/lib/redis/cluster/key_slot_converter.rb +0 -72
  45. data/lib/redis/cluster/node.rb +0 -120
  46. data/lib/redis/cluster/node_key.rb +0 -31
  47. data/lib/redis/cluster/node_loader.rb +0 -34
  48. data/lib/redis/cluster/option.rb +0 -100
  49. data/lib/redis/cluster/slot.rb +0 -86
  50. data/lib/redis/cluster/slot_loader.rb +0 -46
  51. data/lib/redis/cluster.rb +0 -315
  52. data/lib/redis/connection/command_helper.rb +0 -41
  53. data/lib/redis/connection/hiredis.rb +0 -68
  54. data/lib/redis/connection/registry.rb +0 -13
  55. data/lib/redis/connection/ruby.rb +0 -437
  56. data/lib/redis/connection/synchrony.rb +0 -148
  57. data/lib/redis/connection.rb +0 -11
@@ -4,10 +4,13 @@ class Redis
4
4
  class SubscribedClient
5
5
  def initialize(client)
6
6
  @client = client
7
+ @write_monitor = Monitor.new
7
8
  end
8
9
 
9
- def call(command)
10
- @client.process([command])
10
+ def call_v(command)
11
+ @write_monitor.synchronize do
12
+ @client.call_v(command)
13
+ end
11
14
  end
12
15
 
13
16
  def subscribe(*channels, &block)
@@ -26,12 +29,28 @@ class Redis
26
29
  subscription("psubscribe", "punsubscribe", channels, block, timeout)
27
30
  end
28
31
 
32
+ def ssubscribe(*channels, &block)
33
+ subscription("ssubscribe", "sunsubscribe", channels, block)
34
+ end
35
+
36
+ def ssubscribe_with_timeout(timeout, *channels, &block)
37
+ subscription("ssubscribe", "sunsubscribe", channels, block, timeout)
38
+ end
39
+
29
40
  def unsubscribe(*channels)
30
- call([:unsubscribe, *channels])
41
+ call_v([:unsubscribe, *channels])
31
42
  end
32
43
 
33
44
  def punsubscribe(*channels)
34
- call([:punsubscribe, *channels])
45
+ call_v([:punsubscribe, *channels])
46
+ end
47
+
48
+ def sunsubscribe(*channels)
49
+ call_v([:sunsubscribe, *channels])
50
+ end
51
+
52
+ def close
53
+ @client.close
35
54
  end
36
55
 
37
56
  protected
@@ -39,13 +58,21 @@ class Redis
39
58
  def subscription(start, stop, channels, block, timeout = 0)
40
59
  sub = Subscription.new(&block)
41
60
 
42
- unsubscribed = false
61
+ case start
62
+ when "ssubscribe" then channels.each { |c| call_v([start, c]) } # avoid cross-slot keys
63
+ else call_v([start, *channels])
64
+ end
43
65
 
44
- @client.call_loop([start, *channels], timeout) do |line|
45
- type, *rest = line
46
- sub.callbacks[type].call(*rest)
47
- unsubscribed = type == stop && rest.last == 0
48
- break if unsubscribed
66
+ while event = @client.next_event(timeout)
67
+ if event.is_a?(::RedisClient::CommandError)
68
+ raise Client::ERROR_MAPPING.fetch(event.class), event.message
69
+ end
70
+
71
+ type, *rest = event
72
+ if callback = sub.callbacks[type]
73
+ callback.call(*rest)
74
+ end
75
+ break if type == stop && rest.last == 0
49
76
  end
50
77
  # No need to unsubscribe here. The real client closes the connection
51
78
  # whenever an exception is raised (see #ensure_connected).
@@ -53,13 +80,10 @@ class Redis
53
80
  end
54
81
 
55
82
  class Subscription
56
- attr :callbacks
83
+ attr_reader :callbacks
57
84
 
58
85
  def initialize
59
- @callbacks = Hash.new do |hash, key|
60
- hash[key] = ->(*_) {}
61
- end
62
-
86
+ @callbacks = {}
63
87
  yield(self)
64
88
  end
65
89
 
@@ -86,5 +110,17 @@ class Redis
86
110
  def pmessage(&block)
87
111
  @callbacks["pmessage"] = block
88
112
  end
113
+
114
+ def ssubscribe(&block)
115
+ @callbacks["ssubscribe"] = block
116
+ end
117
+
118
+ def sunsubscribe(&block)
119
+ @callbacks["sunsubscribe"] = block
120
+ end
121
+
122
+ def smessage(&block)
123
+ @callbacks["smessage"] = block
124
+ end
89
125
  end
90
126
  end
data/lib/redis/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Redis
4
- VERSION = '4.8.1'
4
+ VERSION = '6.0.0'
5
5
  end
data/lib/redis.rb CHANGED
@@ -1,31 +1,17 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "redis-client"
4
+
3
5
  require "monitor"
4
6
  require "redis/errors"
5
7
  require "redis/commands"
6
8
 
7
9
  class Redis
8
10
  BASE_PATH = __dir__
9
- @exists_returns_integer = true
10
- @sadd_returns_boolean = true
11
-
12
11
  Deprecated = Class.new(StandardError)
13
12
 
14
13
  class << self
15
- attr_reader :exists_returns_integer
16
- attr_accessor :silence_deprecations, :raise_deprecations, :sadd_returns_boolean
17
-
18
- def exists_returns_integer=(value)
19
- unless value
20
- deprecate!(
21
- "`Redis#exists(key)` will return an Integer by default in redis-rb 4.3. The option to explicitly " \
22
- "disable this behaviour via `Redis.exists_returns_integer` will be removed in 5.0. You should use " \
23
- "`exists?` instead."
24
- )
25
- end
26
-
27
- @exists_returns_integer = value
28
- end
14
+ attr_accessor :silence_deprecations, :raise_deprecations
29
15
 
30
16
  def deprecate!(message)
31
17
  unless silence_deprecations
@@ -36,20 +22,22 @@ class Redis
36
22
  end
37
23
  end
38
24
  end
25
+ end
39
26
 
40
- def current
41
- deprecate!("`Redis.current` is deprecated and will be removed in 5.0. (called from: #{caller(1, 1).first})")
42
- @current ||= Redis.new
43
- end
44
-
45
- def current=(redis)
46
- deprecate!("`Redis.current=` is deprecated and will be removed in 5.0. (called from: #{caller(1, 1).first})")
47
- @current = redis
27
+ # soft-deprecated
28
+ # We added this back for older sidekiq releases
29
+ module Connection
30
+ class << self
31
+ def drivers
32
+ [RedisClient.default_driver]
33
+ end
48
34
  end
49
35
  end
50
36
 
51
37
  include Commands
52
38
 
39
+ SERVER_URL_OPTIONS = %i(url host port path).freeze
40
+
53
41
  # Create a new client instance
54
42
  #
55
43
  # @param [Hash] options
@@ -59,56 +47,68 @@ class Redis
59
47
  # @option options [String] :host ("127.0.0.1") server hostname
60
48
  # @option options [Integer] :port (6379) server port
61
49
  # @option options [String] :path path to server socket (overrides host and port)
62
- # @option options [Float] :timeout (5.0) timeout in seconds
50
+ # @option options [Float] :timeout (1.0) timeout in seconds
63
51
  # @option options [Float] :connect_timeout (same as timeout) timeout for initial connect in seconds
64
52
  # @option options [String] :username Username to authenticate against server
65
53
  # @option options [String] :password Password to authenticate against server
66
- # @option options [Integer] :db (0) Database to select after initial connect
67
- # @option options [Symbol] :driver Driver to use, currently supported: `:ruby`, `:hiredis`, `:synchrony`
54
+ # @option options [Integer] :db (0) Database to select after connect and on reconnects
55
+ # @option options [Symbol] :driver Driver to use, currently supported: `:ruby`, `:hiredis`
56
+ # @option options [Integer] :protocol (3) RESP protocol version to negotiate (`HELLO`). Defaults
57
+ # to RESP3; set to `2` for RESP2. Servers without RESP3 support automatically fall back to RESP2.
68
58
  # @option options [String] :id ID for the client connection, assigns name to current connection by sending
69
59
  # `CLIENT SETNAME`
70
- # @option options [Hash, Integer] :tcp_keepalive Keepalive values, if Integer `intvl` and `probe` are calculated
71
- # based on the value, if Hash `time`, `intvl` and `probes` can be specified as a Integer
72
- # @option options [Integer] :reconnect_attempts Number of attempts trying to connect
60
+ # @option options [String, Array<String>, false] :driver_info Identity a library built on top of `redis-rb`
61
+ # reports to the server via `CLIENT SETINFO`, shown as `lib-name=redis-rb(<driver_info>)` in `CLIENT LIST`.
62
+ # The recommended format is `<name>_v<version>`; an Array is joined with `;`. Pass `false` to disable
63
+ # client identification entirely.
64
+ # @option options [Integer, Array<Integer, Float>] :reconnect_attempts Number of attempts trying to connect,
65
+ # or a list of sleep duration between attempts.
73
66
  # @option options [Boolean] :inherit_socket (false) Whether to use socket in forked process or not
67
+ # @option options [String] :name The name of the server group to connect to.
74
68
  # @option options [Array] :sentinels List of sentinels to contact
75
- # @option options [Symbol] :role (:master) Role to fetch via Sentinel, either `:master` or `:slave`
76
- # @option options [Array<String, Hash{Symbol => String, Integer}>] :cluster List of cluster nodes to contact
77
- # @option options [Boolean] :replica Whether to use readonly replica nodes in Redis Cluster or not
78
- # @option options [String] :fixed_hostname Specify a FQDN if cluster mode enabled and
79
- # client has to connect nodes via single endpoint with SSL/TLS
80
- # @option options [Class] :connector Class of custom connector
81
69
  #
82
70
  # @return [Redis] a new client instance
83
71
  def initialize(options = {})
84
- @options = options.dup
85
- @cluster_mode = options.key?(:cluster)
86
- client = @cluster_mode ? Cluster : Client
87
- @original_client = @client = client.new(options)
88
- @queue = Hash.new { |h, k| h[k] = [] }
89
72
  @monitor = Monitor.new
90
- end
91
-
92
- # Run code with the client reconnecting
93
- def with_reconnect(val = true, &blk)
94
- synchronize do |client|
95
- client.with_reconnect(val, &blk)
73
+ @options = options.dup
74
+ @options[:reconnect_attempts] = 1 unless @options.key?(:reconnect_attempts)
75
+ if ENV["REDIS_URL"] && SERVER_URL_OPTIONS.none? { |o| @options.key?(o) }
76
+ @options[:url] = ENV["REDIS_URL"]
96
77
  end
78
+ # Kept as state, not just a local: the RESP3->RESP2 fallback rebuilds @client and must re-apply
79
+ # socket inheritance, otherwise fork safety would be silently lost after a downgrade.
80
+ @inherit_socket = @options.delete(:inherit_socket)
81
+ # HIMPORT fieldsets are server session state that dies with the physical connection; the
82
+ # registry remembers each prepared schema so a lost session can be repaired (see the
83
+ # himport_* overrides below). Deleted from @options so it never reaches RedisClient::Config.
84
+ @himport_auto_prepare = @options.delete(:himport_auto_prepare) != false
85
+ @himport_fieldsets = {}
86
+ @subscription_client = nil
87
+
88
+ @client = build_client
97
89
  end
98
90
 
99
91
  # Run code without the client reconnecting
100
- def without_reconnect(&blk)
101
- with_reconnect(false, &blk)
92
+ def without_reconnect(&block)
93
+ # Route through #synchronize like every other @client access: it holds @monitor and applies the
94
+ # RESP3->RESP2 fallback. disable_reconnection establishes the connection eagerly, so if that
95
+ # handshake fails the fallback rebuilds @client and the whole block re-runs against the new
96
+ # (RESP2) client — keeping disable_reconnection bound to the live @client rather than a discarded
97
+ # one. Referencing the block argument (not @client) is what makes the retry pick up the rebuild.
98
+ synchronize do |client|
99
+ client.disable_reconnection(&block)
100
+ end
102
101
  end
103
102
 
104
103
  # Test whether or not the client is connected
105
104
  def connected?
106
- @original_client.connected?
105
+ @client.connected? || @subscription_client&.connected?
107
106
  end
108
107
 
109
108
  # Disconnect the client as quickly and silently as possible.
110
109
  def close
111
- @original_client.disconnect
110
+ @client.close
111
+ @subscription_client&.close
112
112
  end
113
113
  alias disconnect! close
114
114
 
@@ -116,186 +116,211 @@ class Redis
116
116
  yield self
117
117
  end
118
118
 
119
- # @deprecated Queues a command for pipelining.
120
- #
121
- # Commands in the queue are executed with the Redis#commit method.
122
- #
123
- # See http://redis.io/topics/pipelining for more details.
124
- #
125
- def queue(*command)
126
- ::Redis.deprecate!(
127
- "Redis#queue is deprecated and will be removed in Redis 5.0.0. Use Redis#pipelined instead." \
128
- "(called from: #{caller(1, 1).first})"
129
- )
130
-
131
- synchronize do
132
- @queue[Thread.current.object_id] << command
133
- end
119
+ def _client
120
+ @client
134
121
  end
135
122
 
136
- # @deprecated Sends all commands in the queue.
137
- #
138
- # See http://redis.io/topics/pipelining for more details.
139
- #
140
- def commit
141
- ::Redis.deprecate!(
142
- "Redis#commit is deprecated and will be removed in Redis 5.0.0. Use Redis#pipelined instead. " \
143
- "(called from: #{Kernel.caller(1, 1).first})"
144
- )
145
-
123
+ def pipelined(exception: true)
146
124
  synchronize do |client|
147
- begin
148
- pipeline = Pipeline.new(client)
149
- @queue[Thread.current.object_id].each do |command|
150
- pipeline.call(command)
151
- end
152
-
153
- client.call_pipelined(pipeline)
154
- ensure
155
- @queue.delete(Thread.current.object_id)
125
+ client.pipelined(exception: exception) do |raw_pipeline|
126
+ yield PipelinedConnection.new(raw_pipeline, exception: exception)
156
127
  end
157
128
  end
158
129
  end
159
130
 
160
- def _client
161
- @client
131
+ def id
132
+ @client.id || @client.server_url
162
133
  end
163
134
 
164
- def pipelined(&block)
165
- deprecation_displayed = false
166
- if block&.arity == 0
167
- Pipeline.deprecation_warning("pipelined", Kernel.caller_locations(1, 5))
168
- deprecation_displayed = true
169
- end
135
+ def inspect
136
+ "#<Redis client v#{Redis::VERSION} for #{id}>"
137
+ end
170
138
 
171
- synchronize do |prior_client|
172
- begin
173
- pipeline = Pipeline.new(prior_client)
174
- @client = deprecation_displayed ? pipeline : DeprecatedPipeline.new(pipeline)
175
- pipelined_connection = PipelinedConnection.new(pipeline)
176
- yield pipelined_connection
177
- prior_client.call_pipeline(pipeline)
178
- ensure
179
- @client = prior_client
180
- end
181
- end
139
+ def dup
140
+ # inherit_socket and himport_auto_prepare are stripped from @options before the client config
141
+ # is built (RedisClient::Config doesn't know them); merge the live values back so the
142
+ # duplicate keeps the caller's settings instead of silently reverting to defaults.
143
+ self.class.new(@options.merge(inherit_socket: @inherit_socket, himport_auto_prepare: @himport_auto_prepare))
182
144
  end
183
145
 
184
- # Mark the start of a transaction block.
185
- #
186
- # Passing a block is optional.
187
- #
188
- # @example With a block
189
- # redis.multi do |multi|
190
- # multi.set("key", "value")
191
- # multi.incr("counter")
192
- # end # => ["OK", 6]
193
- #
194
- # @example Without a block
195
- # redis.multi
196
- # # => "OK"
197
- # redis.set("key", "value")
198
- # # => "QUEUED"
199
- # redis.incr("counter")
200
- # # => "QUEUED"
201
- # redis.exec
202
- # # => ["OK", 6]
203
- #
204
- # @yield [multi] the commands that are called inside this block are cached
205
- # and written to the server upon returning from it
206
- # @yieldparam [Redis] multi `self`
207
- #
208
- # @return [String, Array<...>]
209
- # - when a block is not given, `OK`
210
- # - when a block is given, an array with replies
211
- #
212
- # @see #watch
213
- # @see #unwatch
214
- def multi(&block)
215
- if block_given?
216
- deprecation_displayed = false
217
- if block&.arity == 0
218
- Pipeline.deprecation_warning("multi", Kernel.caller_locations(1, 5))
219
- deprecation_displayed = true
220
- end
146
+ def connection
147
+ {
148
+ host: @client.host,
149
+ port: @client.port,
150
+ db: @client.db,
151
+ id: id,
152
+ location: "#{@client.host}:#{@client.port}"
153
+ }
154
+ end
221
155
 
222
- synchronize do |prior_client|
223
- begin
224
- pipeline = Pipeline::Multi.new(prior_client)
225
- @client = deprecation_displayed ? pipeline : DeprecatedMulti.new(pipeline)
226
- pipelined_connection = PipelinedConnection.new(pipeline)
227
- yield pipelined_connection
228
- prior_client.call_pipeline(pipeline)
229
- ensure
230
- @client = prior_client
231
- end
232
- end
233
- else
234
- send_command([:multi])
156
+ # HIMPORT overrides adding fieldset-loss recovery on top of the transport-pure methods in
157
+ # Commands::Hashes. The connection can be transparently replaced under the caller (reconnect
158
+ # after a network error, failover, RESET by a proxy), destroying every prepared fieldset. The
159
+ # server then answers HIMPORT SET with "no such fieldset" — the authoritative signal that the
160
+ # session was lost. These overrides remember the last schema prepared per fieldset name and,
161
+ # on that error, re-prepare it and retry the SET exactly once. An explicitly discarded
162
+ # fieldset is removed from the registry and is never resurrected. Disable the recovery with
163
+ # `Redis.new(himport_auto_prepare: false)`; the registry is still recorded for manual use.
164
+
165
+ # Each method holds @monitor across the server command AND its registry mutation: the two must
166
+ # be atomic with respect to other threads, otherwise a himport_set failing between another
167
+ # thread's DISCARD reply and its registry delete would still see the schema and re-prepare,
168
+ # resurrecting the fieldset the discard just removed. @monitor is reentrant, so the nested
169
+ # send_command/himport_prepare calls re-enter it safely.
170
+
171
+ def himport_prepare(fieldset_name, *fields)
172
+ fields.flatten!(1)
173
+ @monitor.synchronize do
174
+ reply = super(fieldset_name, fields)
175
+ @himport_fieldsets[fieldset_name.to_s] = fields.dup.freeze
176
+ reply
235
177
  end
236
178
  end
237
179
 
238
- def id
239
- @original_client.id
180
+ def himport_set(key, fieldset_name, *values)
181
+ @monitor.synchronize do
182
+ super
183
+ rescue CommandError => error
184
+ fields = @himport_fieldsets[fieldset_name.to_s]
185
+ raise unless @himport_auto_prepare && fields && error.message.include?("no such fieldset")
186
+
187
+ himport_prepare(fieldset_name, fields)
188
+ # `super` (not himport_set) so a second failure propagates instead of recovering again.
189
+ super
190
+ end
240
191
  end
241
192
 
242
- def inspect
243
- "#<Redis client v#{Redis::VERSION} for #{id}>"
193
+ def himport_discard(fieldset_name)
194
+ @monitor.synchronize do
195
+ reply = super
196
+ @himport_fieldsets.delete(fieldset_name.to_s)
197
+ reply
198
+ end
244
199
  end
245
200
 
246
- def dup
247
- self.class.new(@options)
201
+ def himport_discard_all
202
+ @monitor.synchronize do
203
+ reply = super
204
+ @himport_fieldsets.clear
205
+ reply
206
+ end
248
207
  end
249
208
 
250
- def connection
251
- return @original_client.connection_info if @cluster_mode
209
+ private
252
210
 
253
- {
254
- host: @original_client.host,
255
- port: @original_client.port,
256
- db: @original_client.db,
257
- id: @original_client.id,
258
- location: @original_client.location
259
- }
211
+ # Builds @client from @options and applies any instance-level settings (socket inheritance) that
212
+ # live outside @options. Used both at construction and when the RESP3->RESP2 fallback rebuilds the
213
+ # client, so those settings survive a protocol downgrade.
214
+ def build_client
215
+ client = initialize_client(@options)
216
+ client.inherit_socket! if @inherit_socket
217
+ client
260
218
  end
261
219
 
262
- private
220
+ def initialize_client(options)
221
+ if options.key?(:cluster)
222
+ raise "Redis Cluster support was moved to the `redis-clustering` gem."
223
+ end
263
224
 
225
+ if options.key?(:sentinels)
226
+ Client.sentinel(**options).new_client
227
+ else
228
+ Client.config(**options).new_client
229
+ end
230
+ end
231
+
232
+ # All access to @client funnels through here: it serializes on @monitor and applies the RESP3
233
+ # protocol fallback. Routing pipelined/multi/watch (which all call synchronize) through the same
234
+ # path means they fall back to RESP2 against pre-HELLO servers just like single commands do.
264
235
  def synchronize
265
- @monitor.synchronize { yield(@client) }
236
+ @monitor.synchronize do
237
+ with_protocol_fallback do
238
+ yield(@client)
239
+ end
240
+ end
266
241
  end
267
242
 
268
243
  def send_command(command, &block)
269
- @monitor.synchronize do
270
- @client.call(command, &block)
244
+ synchronize do |client|
245
+ client.call_v(command, &block)
271
246
  end
247
+ rescue ::RedisClient::Error => error
248
+ Client.translate_error!(error)
272
249
  end
273
250
 
274
251
  def send_blocking_command(command, timeout, &block)
275
- @monitor.synchronize do
276
- @client.call_with_timeout(command, timeout, &block)
252
+ synchronize do |client|
253
+ client.blocking_call_v(timeout, command, &block)
254
+ end
255
+ rescue ::RedisClient::Error => error
256
+ Client.translate_error!(error)
257
+ end
258
+
259
+ # We default to RESP3. Servers that don't support it reject the `HELLO 3` handshake (most
260
+ # notably Redis < 6.0, which has no HELLO command). Rebuild the client as RESP2 once so those
261
+ # servers keep working without the user setting `protocol: 2`.
262
+ #
263
+ # This is the single fallback point for every client type. Each one surfaces the resp3-unsupported
264
+ # error here untranslated (still a RedisClient::Error): standalone/distributed via
265
+ # Redis::Client#call_v et al., sentinel via the plain RedisClient (which never translates), and
266
+ # cluster via Redis::Cluster::Client#handle_errors. Because every @client access — single commands,
267
+ # pipelined, multi, watch, and the pub/sub socket — flows through #synchronize, wrapping it here
268
+ # covers them all.
269
+ #
270
+ # Must be called while holding @monitor: it closes and replaces @client, so it has to be
271
+ # serialized with the command execution that uses @client. The retried block re-reads @client, so
272
+ # callers must reference the rebuilt instance (via the synchronize block argument), not a cached
273
+ # one.
274
+ def with_protocol_fallback
275
+ yield
276
+ rescue ::RedisClient::Error => error
277
+ if @options.fetch(:protocol, 3).to_i == 3 && Client.resp3_unsupported?(error)
278
+ @options = @options.merge(protocol: 2)
279
+ @client.close
280
+ @client = build_client
281
+ # Warn only once the RESP2 client is actually in place — if the rebuild itself raises we
282
+ # haven't really fallen back. Fires once per client: @options[:protocol] is now 2, so this
283
+ # branch never re-enters. Passing `protocol: 2` explicitly skips it (and silences this).
284
+ warn("Redis: the server does not support RESP3 (the HELLO 3 handshake failed); falling back " \
285
+ "to RESP2. Pass `protocol: 2` to select RESP2 explicitly and silence this warning.")
286
+ retry
277
287
  end
288
+
289
+ raise
278
290
  end
279
291
 
280
292
  def _subscription(method, timeout, channels, block)
281
- return @client.call([method] + channels) if subscribed?
282
-
283
- begin
284
- original, @client = @client, SubscribedClient.new(@client)
285
- if timeout > 0
286
- @client.send(method, timeout, *channels, &block)
287
- else
288
- @client.send(method, *channels, &block)
293
+ if block
294
+ if @subscription_client
295
+ raise SubscriptionError, "This client is already subscribed"
296
+ end
297
+
298
+ begin
299
+ # The pub/sub second socket is opened via @client.pubsub, which connects through
300
+ # ensure_connected rather than a command path. Route it through #synchronize so the same
301
+ # RESP3->RESP2 fallback applies when subscribe is the first operation against an old server.
302
+ @subscription_client = SubscribedClient.new(synchronize(&:pubsub))
303
+ if timeout > 0
304
+ @subscription_client.send(method, timeout, *channels, &block)
305
+ else
306
+ @subscription_client.send(method, *channels, &block)
307
+ end
308
+ ensure
309
+ @subscription_client&.close
310
+ @subscription_client = nil
289
311
  end
290
- ensure
291
- @client = original
312
+ else
313
+ unless @subscription_client
314
+ raise SubscriptionError, "This client is not subscribed"
315
+ end
316
+
317
+ @subscription_client.call_v([method].concat(channels))
292
318
  end
293
319
  end
294
320
  end
295
321
 
296
322
  require "redis/version"
297
- require "redis/connection"
323
+ require "redis/lib_identity"
298
324
  require "redis/client"
299
- require "redis/cluster"
300
325
  require "redis/pipeline"
301
326
  require "redis/subscribe"