redis 5.4.0 → 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.
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "redis-client"
4
+ require "redis/version"
5
+
6
+ class Redis
7
+ # Reports `redis-rb` as the connecting library via `CLIENT SETINFO`.
8
+ #
9
+ # `redis-client` builds its own `LIB-NAME` as `redis-client(<driver_info>)` and always
10
+ # reports its own version as `LIB-VER`, which would identify `redis-client` rather than
11
+ # `redis-rb` to the server. We append a second pair of `CLIENT SETINFO` commands, which wins
12
+ # because the command is last-write-wins, so that `CLIENT LIST` / `CLIENT INFO` report
13
+ # `lib-name=redis-rb` and `lib-ver=<Redis::VERSION>`.
14
+ #
15
+ # Libraries built on top of `redis-rb` can still identify themselves by passing `driver_info`,
16
+ # in which case they are reported inside the parentheses, e.g. `lib-name=redis-rb(my-gem_v1.0.0)`.
17
+ # The official convention for such suffixes is `<name>_v<version>` (matching
18
+ # `(?<custom-name>[ -~]+)[ -~]v(?<custom-version>[\d\.]+)`), with `;` delimiting multiple
19
+ # suffixes and parentheses avoided inside them — see
20
+ # https://redis.io/docs/latest/commands/client-setinfo/. Passing `driver_info: false` disables
21
+ # `CLIENT SETINFO` entirely, for peers that can't tolerate unknown commands during the handshake
22
+ # (e.g. some proxies).
23
+ #
24
+ # Applications that use `redis-client` directly are left untouched: the override only applies
25
+ # to configurations whose `driver_info` was built by this module.
26
+ module LibIdentity
27
+ # Follows the official suffix convention, so that the transient pair `redis-client` itself
28
+ # builds from this value (`lib-name=redis-client(redis-rb_v<version>)`) is well-formed too.
29
+ MARKER = "redis-rb_v"
30
+ LIB_NAME = "redis-rb"
31
+ SEPARATOR = ";"
32
+ # Interpolated strings are not frozen by the magic comment; explicit freezes keep these
33
+ # constants Ractor-shareable (lazy module ivars here would raise Ractor::IsolationError).
34
+ OWN_INFO = "#{MARKER}#{Redis::VERSION}".freeze
35
+ OWN_PREFIX = "#{OWN_INFO}#{SEPARATOR}".freeze
36
+ # `CLIENT SETINFO` values must be printable ASCII with no spaces — the server rejects anything
37
+ # else, and `redis-client` swallows that error, which would silently leave `lib-name` empty.
38
+ # Parentheses are excluded as well: they delimit the suffix in `lib-name=redis-rb(<suffix>)`,
39
+ # so a suffix containing them would corrupt the reported name.
40
+ INVALID_CHARS = /(?:[^!-~]|[()])+/
41
+
42
+ class << self
43
+ # Combines redis-rb's own identity with the `driver_info` a downstream library passed in,
44
+ # so that both can be recovered when the connection prelude is built. `false` is passed
45
+ # through untouched and, being falsy, turns `CLIENT SETINFO` off wholesale (`redis-client`
46
+ # gates its own commands on `driver_info` too). Any other non-String, non-Array value is
47
+ # also passed through, leaving `redis-client` to reject it.
48
+ def driver_info(downstream = nil)
49
+ case downstream
50
+ when nil then OWN_INFO
51
+ when String then combine(downstream)
52
+ when Array then combine(downstream.join(SEPARATOR))
53
+ else downstream
54
+ end
55
+ end
56
+
57
+ # True only for values `driver_info` produced: exactly our own identity, or our identity
58
+ # followed by a downstream library's. Matching on the bare `MARKER` prefix instead would
59
+ # also capture a direct `redis-client` user whose own `driver_info` merely starts with
60
+ # `redis-rb_v` (say `redis-rb_viewer-1.0`) and would silently discard their identity.
61
+ def ours?(info)
62
+ info.is_a?(String) && (info == OWN_INFO || info.start_with?(OWN_PREFIX))
63
+ end
64
+
65
+ def setinfo_commands(info)
66
+ [
67
+ ["CLIENT", "SETINFO", "LIB-NAME", lib_name(info)].freeze,
68
+ ["CLIENT", "SETINFO", "LIB-VER", Redis::VERSION].freeze,
69
+ ].freeze
70
+ end
71
+
72
+ private
73
+
74
+ def combine(downstream)
75
+ downstream = downstream.scrub(" ") # invalid byte sequences would make gsub raise
76
+ .gsub(/\A#{INVALID_CHARS}|#{INVALID_CHARS}\z/o, "")
77
+ .gsub(INVALID_CHARS, "_")
78
+ downstream.empty? ? OWN_INFO : "#{OWN_PREFIX}#{downstream}"
79
+ end
80
+
81
+ def lib_name(info)
82
+ downstream = info == OWN_INFO ? nil : info.delete_prefix(OWN_PREFIX)
83
+ downstream.nil? || downstream.empty? ? LIB_NAME : "#{LIB_NAME}(#{downstream})".freeze
84
+ end
85
+ end
86
+
87
+ def connection_prelude
88
+ prelude = super
89
+ info = driver_info
90
+ return prelude unless LibIdentity.ours?(info)
91
+
92
+ # `CLIENT SETINFO` is last-write-wins, so appending after the commands `redis-client`
93
+ # already built overrides them, without having to recognise or remove them.
94
+ (prelude + LibIdentity.setinfo_commands(info)).freeze
95
+ end
96
+ end
97
+ end
98
+
99
+ # Prepended to the concrete config classes rather than to `RedisClient::Config::Common`, so that
100
+ # the two `redis-client` entry points are covered without altering the shared module for anything
101
+ # else that includes it. `RedisClient::SentinelConfig` is NOT a `RedisClient::Config` subclass, so
102
+ # both lines are required. `RedisClient::Cluster::Node::Config` does subclass `RedisClient::Config`
103
+ # and calls `super` in its own `connection_prelude`, so cluster nodes are covered by the first.
104
+ RedisClient::Config.prepend(Redis::LibIdentity)
105
+ RedisClient::SentinelConfig.prepend(Redis::LibIdentity)
@@ -80,7 +80,7 @@ class Redis
80
80
  end
81
81
 
82
82
  class Subscription
83
- attr :callbacks
83
+ attr_reader :callbacks
84
84
 
85
85
  def initialize
86
86
  @callbacks = {}
data/lib/redis/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Redis
4
- VERSION = '5.4.0'
4
+ VERSION = '6.0.0'
5
5
  end
data/lib/redis.rb CHANGED
@@ -1,5 +1,7 @@
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"
@@ -51,8 +53,14 @@ class Redis
51
53
  # @option options [String] :password Password to authenticate against server
52
54
  # @option options [Integer] :db (0) Database to select after connect and on reconnects
53
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.
54
58
  # @option options [String] :id ID for the client connection, assigns name to current connection by sending
55
59
  # `CLIENT SETNAME`
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.
56
64
  # @option options [Integer, Array<Integer, Float>] :reconnect_attempts Number of attempts trying to connect,
57
65
  # or a list of sleep duration between attempts.
58
66
  # @option options [Boolean] :inherit_socket (false) Whether to use socket in forked process or not
@@ -67,16 +75,29 @@ class Redis
67
75
  if ENV["REDIS_URL"] && SERVER_URL_OPTIONS.none? { |o| @options.key?(o) }
68
76
  @options[:url] = ENV["REDIS_URL"]
69
77
  end
70
- inherit_socket = @options.delete(:inherit_socket)
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 = {}
71
86
  @subscription_client = nil
72
87
 
73
- @client = initialize_client(@options)
74
- @client.inherit_socket! if inherit_socket
88
+ @client = build_client
75
89
  end
76
90
 
77
91
  # Run code without the client reconnecting
78
92
  def without_reconnect(&block)
79
- @client.disable_reconnection(&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
80
101
  end
81
102
 
82
103
  # Test whether or not the client is connected
@@ -116,7 +137,10 @@ class Redis
116
137
  end
117
138
 
118
139
  def dup
119
- self.class.new(@options)
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))
120
144
  end
121
145
 
122
146
  def connection
@@ -129,8 +153,70 @@ class Redis
129
153
  }
130
154
  end
131
155
 
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
177
+ end
178
+ end
179
+
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
191
+ end
192
+
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
199
+ end
200
+
201
+ def himport_discard_all
202
+ @monitor.synchronize do
203
+ reply = super
204
+ @himport_fieldsets.clear
205
+ reply
206
+ end
207
+ end
208
+
132
209
  private
133
210
 
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
218
+ end
219
+
134
220
  def initialize_client(options)
135
221
  if options.key?(:cluster)
136
222
  raise "Redis Cluster support was moved to the `redis-clustering` gem."
@@ -143,22 +229,64 @@ class Redis
143
229
  end
144
230
  end
145
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.
146
235
  def synchronize
147
- @monitor.synchronize { yield(@client) }
236
+ @monitor.synchronize do
237
+ with_protocol_fallback do
238
+ yield(@client)
239
+ end
240
+ end
148
241
  end
149
242
 
150
243
  def send_command(command, &block)
151
- @monitor.synchronize do
152
- @client.call_v(command, &block)
244
+ synchronize do |client|
245
+ client.call_v(command, &block)
153
246
  end
154
247
  rescue ::RedisClient::Error => error
155
248
  Client.translate_error!(error)
156
249
  end
157
250
 
158
251
  def send_blocking_command(command, timeout, &block)
159
- @monitor.synchronize do
160
- @client.blocking_call_v(timeout, command, &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
161
287
  end
288
+
289
+ raise
162
290
  end
163
291
 
164
292
  def _subscription(method, timeout, channels, block)
@@ -168,7 +296,10 @@ class Redis
168
296
  end
169
297
 
170
298
  begin
171
- @subscription_client = SubscribedClient.new(@client.pubsub)
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))
172
303
  if timeout > 0
173
304
  @subscription_client.send(method, timeout, *channels, &block)
174
305
  else
@@ -189,6 +320,7 @@ class Redis
189
320
  end
190
321
 
191
322
  require "redis/version"
323
+ require "redis/lib_identity"
192
324
  require "redis/client"
193
325
  require "redis/pipeline"
194
326
  require "redis/subscribe"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: redis
3
3
  version: !ruby/object:Gem::Version
4
- version: 5.4.0
4
+ version: 6.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ezra Zygmuntowicz
@@ -15,22 +15,22 @@ authors:
15
15
  - Pieter Noordhuis
16
16
  bindir: bin
17
17
  cert_chain: []
18
- date: 2025-02-20 00:00:00.000000000 Z
18
+ date: 2026-07-31 00:00:00.000000000 Z
19
19
  dependencies:
20
20
  - !ruby/object:Gem::Dependency
21
21
  name: redis-client
22
22
  requirement: !ruby/object:Gem::Requirement
23
23
  requirements:
24
- - - ">="
24
+ - - '='
25
25
  - !ruby/object:Gem::Version
26
- version: 0.22.0
26
+ version: 0.30.1
27
27
  type: :runtime
28
28
  prerelease: false
29
29
  version_requirements: !ruby/object:Gem::Requirement
30
30
  requirements:
31
- - - ">="
31
+ - - '='
32
32
  - !ruby/object:Gem::Version
33
- version: 0.22.0
33
+ version: 0.30.1
34
34
  description: |2
35
35
  A Ruby client that tries to match Redis' API one-to-one, while still
36
36
  providing an idiomatic interface.
@@ -54,6 +54,18 @@ files:
54
54
  - lib/redis/commands/hyper_log_log.rb
55
55
  - lib/redis/commands/keys.rb
56
56
  - lib/redis/commands/lists.rb
57
+ - lib/redis/commands/modules/json.rb
58
+ - lib/redis/commands/modules/search.rb
59
+ - lib/redis/commands/modules/search/aggregation.rb
60
+ - lib/redis/commands/modules/search/dialect.rb
61
+ - lib/redis/commands/modules/search/field.rb
62
+ - lib/redis/commands/modules/search/hybrid.rb
63
+ - lib/redis/commands/modules/search/index.rb
64
+ - lib/redis/commands/modules/search/index_definition.rb
65
+ - lib/redis/commands/modules/search/miscellaneous.rb
66
+ - lib/redis/commands/modules/search/query.rb
67
+ - lib/redis/commands/modules/search/result.rb
68
+ - lib/redis/commands/modules/search/schema.rb
57
69
  - lib/redis/commands/pubsub.rb
58
70
  - lib/redis/commands/scripting.rb
59
71
  - lib/redis/commands/server.rb
@@ -65,6 +77,7 @@ files:
65
77
  - lib/redis/distributed.rb
66
78
  - lib/redis/errors.rb
67
79
  - lib/redis/hash_ring.rb
80
+ - lib/redis/lib_identity.rb
68
81
  - lib/redis/pipeline.rb
69
82
  - lib/redis/subscribe.rb
70
83
  - lib/redis/version.rb
@@ -74,9 +87,9 @@ licenses:
74
87
  metadata:
75
88
  bug_tracker_uri: https://github.com/redis/redis-rb/issues
76
89
  changelog_uri: https://github.com/redis/redis-rb/blob/master/CHANGELOG.md
77
- documentation_uri: https://www.rubydoc.info/gems/redis/5.4.0
90
+ documentation_uri: https://www.rubydoc.info/gems/redis/6.0.0
78
91
  homepage_uri: https://github.com/redis/redis-rb
79
- source_code_uri: https://github.com/redis/redis-rb/tree/v5.4.0
92
+ source_code_uri: https://github.com/redis/redis-rb/tree/v6.0.0
80
93
  rdoc_options: []
81
94
  require_paths:
82
95
  - lib
@@ -84,7 +97,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
84
97
  requirements:
85
98
  - - ">="
86
99
  - !ruby/object:Gem::Version
87
- version: 2.6.0
100
+ version: 3.2.0
88
101
  required_rubygems_version: !ruby/object:Gem::Requirement
89
102
  requirements:
90
103
  - - ">="