redis-client 0.19.1 → 0.22.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 920d97dcf258796ba88bb2f087fa89c1595dd245f67292c7432893848baacd0b
4
- data.tar.gz: 07151d3aa036f7c513e7baa2cc94c22a7f9b4af8a09e132b1943716cd458c408
3
+ metadata.gz: 6471d100e48137355f30f150526beba636372c0a8ab89b1d05c9870f156bd01c
4
+ data.tar.gz: 6c5a17e18eabfdb54e500a1f8deec09dbe1aba9234214e34f0ade2fea170b669
5
5
  SHA512:
6
- metadata.gz: a6f664cf2c5fe77115606dc15e031e6bccc55c440b55d521ef5429653acb89bf55597dd4e5cd4892fb61f9452790a2ca41e32e72c6f824c7da9609987b14ff42
7
- data.tar.gz: c6a34fe93aac6d5ae5c8995881e17581e64a762d0fdc3d3fe1772b07b1759196b2ca5a27e14e00c826399d03a1259f799c816ac1ca1ee001314d9b32f341ecb8
6
+ metadata.gz: 7b2253e3c62b2cdce341b210ceeebb25f37484161a356c3982d747884870acb040d071471ca50d0abac605a9e759a89d678fbaf19fd1a21cf0b2bfe3c5fd2472
7
+ data.tar.gz: 8b44296f79fa5591b5776ca6a236b1d2cbce76f9f56a008ab564d2ae8381b1da66385c74f0fe7c00d244bd418f1f7e564b8c198ade7c43588f45e25caa94581e
data/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  # Unreleased
2
2
 
3
+ # 0.22.0
4
+
5
+ - Made various performance optimizations to the Ruby driver. See #184.
6
+ - Always assume UTF-8 encoding instead of relying on `Encoding.default_external`.
7
+ - Add `exception` flag in `pipelined` allowing failed commands to be returned in the result array when set to `false`. See #187.
8
+
9
+ # 0.21.1
10
+
11
+ - Handle unresolved Sentinel master/replica error when displaying server URL in exceptions. See #182.
12
+
13
+ # 0.21.0
14
+
15
+ - Include redis server URL in most error messages. See #178.
16
+ - Close Redis Sentinel connection after resolving role. See #176.
17
+
18
+ # 0.20.0
19
+
20
+ - Accept `unix://` schemes as well as simple paths in the `url:` config parameter. #170.
21
+ - Make basic usage Ractor compatible.
22
+
3
23
  # 0.19.1
4
24
 
5
25
  - Fixed a bug in `hiredis-client` that could cause a crash if interrupted by `Timeout.timeout` or other `Thread#raise` based mecanism.
data/Gemfile CHANGED
@@ -6,7 +6,7 @@ source "https://rubygems.org"
6
6
  gemspec name: "redis-client"
7
7
 
8
8
  gem "minitest"
9
- gem "rake", "~> 13.1"
9
+ gem "rake", "~> 13.2"
10
10
  gem "rake-compiler"
11
11
  gem "rubocop"
12
12
  gem "rubocop-minitest"
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- redis-client (0.19.1)
4
+ redis-client (0.22.0)
5
5
  connection_pool
6
6
 
7
7
  GEM
@@ -13,34 +13,40 @@ GEM
13
13
  connection_pool (2.4.1)
14
14
  hiredis (0.6.3)
15
15
  hiredis (0.6.3-java)
16
- minitest (5.15.0)
17
- parallel (1.22.1)
18
- parser (3.1.2.1)
16
+ json (2.7.1)
17
+ json (2.7.1-java)
18
+ minitest (5.22.3)
19
+ parallel (1.24.0)
20
+ parser (3.3.0.5)
19
21
  ast (~> 2.4.1)
22
+ racc
23
+ racc (1.7.3)
24
+ racc (1.7.3-java)
20
25
  rainbow (3.1.1)
21
- rake (13.1.0)
22
- rake-compiler (1.2.5)
26
+ rake (13.2.1)
27
+ rake-compiler (1.2.7)
23
28
  rake
24
29
  redis (4.6.0)
25
- regexp_parser (2.5.0)
26
- rexml (3.2.5)
27
- rubocop (1.28.2)
30
+ regexp_parser (2.9.0)
31
+ rexml (3.2.6)
32
+ rubocop (1.50.2)
33
+ json (~> 2.3)
28
34
  parallel (~> 1.10)
29
- parser (>= 3.1.0.0)
35
+ parser (>= 3.2.0.0)
30
36
  rainbow (>= 2.2.2, < 4.0)
31
37
  regexp_parser (>= 1.8, < 3.0)
32
- rexml
33
- rubocop-ast (>= 1.17.0, < 2.0)
38
+ rexml (>= 3.2.5, < 4.0)
39
+ rubocop-ast (>= 1.28.0, < 2.0)
34
40
  ruby-progressbar (~> 1.7)
35
- unicode-display_width (>= 1.4.0, < 3.0)
36
- rubocop-ast (1.17.0)
37
- parser (>= 3.1.1.0)
38
- rubocop-minitest (0.19.1)
39
- rubocop (>= 0.90, < 2.0)
40
- ruby-progressbar (1.11.0)
41
- stackprof (0.2.25)
41
+ unicode-display_width (>= 2.4.0, < 3.0)
42
+ rubocop-ast (1.30.0)
43
+ parser (>= 3.2.1.0)
44
+ rubocop-minitest (0.30.0)
45
+ rubocop (>= 1.39, < 2.0)
46
+ ruby-progressbar (1.13.0)
47
+ stackprof (0.2.26)
42
48
  toxiproxy (2.0.2)
43
- unicode-display_width (2.2.0)
49
+ unicode-display_width (2.5.0)
44
50
 
45
51
  PLATFORMS
46
52
  ruby
@@ -53,7 +59,7 @@ DEPENDENCIES
53
59
  byebug
54
60
  hiredis
55
61
  minitest
56
- rake (~> 13.1)
62
+ rake (~> 13.2)
57
63
  rake-compiler
58
64
  redis (~> 4.6)
59
65
  redis-client!
data/README.md CHANGED
@@ -62,7 +62,9 @@ redis.call("GET", "mykey")
62
62
 
63
63
  ### Configuration
64
64
 
65
- - `url`: A Redis connection URL, e.g. `redis://example.com:6379/5`, a `rediss://` scheme enable SSL, and the path is interpreted as a database number.
65
+ - `url`: A Redis connection URL, e.g. `redis://example.com:6379/5` - a `rediss://` scheme enables SSL, and the path is interpreted as a database number.
66
+ To connect to UNIX domain sockets, the `url` can also just be a path, and the database specified as query parameter: `/run/redis/foo.sock?db=5`, or optionally
67
+ have a `unix://` scheme: `unix:///run/redis/foo.sock?db=5`
66
68
  Note that all other configurations take precedence, e.g. `RedisClient.config(url: "redis://localhost:3000", port: 6380)` will connect on port `6380`.
67
69
  - `host`: The server hostname or IP address. Defaults to `"localhost"`.
68
70
  - `port`: The server port. Defaults to `6379`.
@@ -330,6 +332,27 @@ end
330
332
  # => ["OK", 1]
331
333
  ```
332
334
 
335
+ #### Exception management
336
+
337
+ The `exception` flag in the `#pipelined` method of `RedisClient` is a feature that modifies the pipeline execution
338
+ behavior. When set to `false`, it doesn't raise an exception when a command error occurs. Instead, it allows the
339
+ pipeline to execute all commands, and any failed command will be available in the returned array. (Defaults to `true`)
340
+
341
+ ```ruby
342
+ results = redis.pipelined(exception: false) do |pipeline|
343
+ pipeline.call("SET", "foo", "bar") # => nil
344
+ pipeline.call("DOESNOTEXIST", 12) # => nil
345
+ pipeline.call("INCR", "baz") # => nil
346
+ end
347
+ # results => ["OK", #<RedisClient::CommandError: ERR unknown command 'DOESNOTEXIST', with args beginning with: '12'>, 2]
348
+
349
+ results.each do |result|
350
+ if result.is_a?(RedisClient::CommandError)
351
+ # Do something with the failed result
352
+ end
353
+ end
354
+ ```
355
+
333
356
  ### Transactions
334
357
 
335
358
  You can use [`MULTI/EXEC` to run a number of commands in an atomic fashion](https://redis.io/topics/transactions).
data/Rakefile CHANGED
@@ -71,12 +71,15 @@ namespace :hiredis do
71
71
  end
72
72
  end
73
73
 
74
- benchmark_suites = %w(single pipelined)
74
+ benchmark_suites = %w(single pipelined drivers)
75
75
  benchmark_modes = %i[ruby yjit hiredis]
76
76
  namespace :benchmark do
77
77
  benchmark_suites.each do |suite|
78
78
  benchmark_modes.each do |mode|
79
+ next if suite == "drivers" && mode == :hiredis
80
+
79
81
  name = "#{suite}_#{mode}"
82
+ desc name
80
83
  task name do
81
84
  output_path = "benchmark/#{name}.md"
82
85
  sh "rm", "-f", output_path
@@ -91,6 +91,10 @@ class RedisClient
91
91
  @username || DEFAULT_USERNAME
92
92
  end
93
93
 
94
+ def resolved?
95
+ true
96
+ end
97
+
94
98
  def sentinel?
95
99
  false
96
100
  end
@@ -124,10 +128,17 @@ class RedisClient
124
128
 
125
129
  def server_url
126
130
  if path
127
- "#{path}/#{db}"
131
+ url = "unix://#{path}"
132
+ if db != 0
133
+ url = "#{url}?db=#{db}"
134
+ end
128
135
  else
129
- "redis#{'s' if ssl?}://#{host}:#{port}/#{db}"
136
+ url = "redis#{'s' if ssl?}://#{host}:#{port}"
137
+ if db != 0
138
+ url = "#{url}/#{db}"
139
+ end
130
140
  end
141
+ url
131
142
  end
132
143
 
133
144
  private
@@ -151,6 +162,12 @@ class RedisClient
151
162
  if @db && @db != 0
152
163
  prelude << ["SELECT", @db.to_s]
153
164
  end
165
+
166
+ # Deep freeze all the strings and commands
167
+ prelude.map! do |commands|
168
+ commands = commands.map { |str| str.frozen? ? str : str.dup.freeze }
169
+ commands.freeze
170
+ end
154
171
  prelude.freeze
155
172
  end
156
173
  end
@@ -176,15 +193,20 @@ class RedisClient
176
193
  }.compact.merge(kwargs)
177
194
  host ||= url_config.host
178
195
  port ||= url_config.port
196
+ path ||= url_config.path
179
197
  username ||= url_config.username
180
198
  password ||= url_config.password
181
199
  end
182
200
 
183
201
  super(username: username, password: password, **kwargs)
184
202
 
185
- @host = host || DEFAULT_HOST
186
- @port = Integer(port || DEFAULT_PORT)
187
- @path = path
203
+ if @path = path
204
+ @host = nil
205
+ @port = nil
206
+ else
207
+ @host = host || DEFAULT_HOST
208
+ @port = Integer(port || DEFAULT_PORT)
209
+ end
188
210
  end
189
211
  end
190
212
  end
@@ -32,14 +32,15 @@ class RedisClient
32
32
  @pending_reads -= 1
33
33
  if result.is_a?(Error)
34
34
  result._set_command(command)
35
+ result._set_config(config)
35
36
  raise result
36
37
  else
37
38
  result
38
39
  end
39
40
  end
40
41
 
41
- def call_pipelined(commands, timeouts)
42
- exception = nil
42
+ def call_pipelined(commands, timeouts, exception: true)
43
+ first_exception = nil
43
44
 
44
45
  size = commands.size
45
46
  results = Array.new(commands.size)
@@ -50,15 +51,24 @@ class RedisClient
50
51
  timeout = timeouts && timeouts[index]
51
52
  result = read(timeout)
52
53
  @pending_reads -= 1
53
- if result.is_a?(Error)
54
+
55
+ # A multi/exec command can return an array of results.
56
+ # An error from a multi/exec command is handled in Multi#_coerce!.
57
+ if result.is_a?(Array)
58
+ result.each do |res|
59
+ res._set_config(config) if res.is_a?(Error)
60
+ end
61
+ elsif result.is_a?(Error)
54
62
  result._set_command(commands[index])
55
- exception ||= result
63
+ result._set_config(config)
64
+ first_exception ||= result
56
65
  end
66
+
57
67
  results[index] = result
58
68
  end
59
69
 
60
- if exception
61
- raise exception
70
+ if first_exception && exception
71
+ raise first_exception
62
72
  else
63
73
  results
64
74
  end
@@ -47,8 +47,8 @@ class RedisClient
47
47
  end
48
48
  ruby2_keywords :with if respond_to?(:ruby2_keywords, true)
49
49
 
50
- def pipelined
51
- @client.pipelined { |p| yield @_pipeline_class.new(p) }
50
+ def pipelined(exception: true)
51
+ @client.pipelined(exception: exception) { |p| yield @_pipeline_class.new(p) }
52
52
  end
53
53
 
54
54
  def multi(**kwargs)
@@ -10,14 +10,80 @@ class RedisClient
10
10
 
11
11
  attr_accessor :read_timeout, :write_timeout
12
12
 
13
- def initialize(io, read_timeout:, write_timeout:, chunk_size: 4096)
14
- @io = io
15
- @buffer = "".b
16
- @offset = 0
17
- @chunk_size = chunk_size
18
- @read_timeout = read_timeout
19
- @write_timeout = write_timeout
20
- @blocking_reads = false
13
+ if String.method_defined?(:byteindex) # Ruby 3.2+
14
+ ENCODING = Encoding::UTF_8
15
+
16
+ def initialize(io, read_timeout:, write_timeout:, chunk_size: 4096)
17
+ @io = io
18
+ @buffer = +""
19
+ @offset = 0
20
+ @chunk_size = chunk_size
21
+ @read_timeout = read_timeout
22
+ @write_timeout = write_timeout
23
+ @blocking_reads = false
24
+ end
25
+
26
+ def gets_chomp
27
+ fill_buffer(false) if @offset >= @buffer.bytesize
28
+ until eol_index = @buffer.byteindex(EOL, @offset)
29
+ fill_buffer(false)
30
+ end
31
+
32
+ line = @buffer.byteslice(@offset, eol_index - @offset)
33
+ @offset = eol_index + EOL_SIZE
34
+ line
35
+ end
36
+
37
+ def read_chomp(bytes)
38
+ ensure_remaining(bytes + EOL_SIZE)
39
+ str = @buffer.byteslice(@offset, bytes)
40
+ @offset += bytes + EOL_SIZE
41
+ str
42
+ end
43
+
44
+ private def ensure_line
45
+ fill_buffer(false) if @offset >= @buffer.bytesize
46
+ until @buffer.byteindex(EOL, @offset)
47
+ fill_buffer(false)
48
+ end
49
+ end
50
+ else
51
+ ENCODING = Encoding::BINARY
52
+
53
+ def initialize(io, read_timeout:, write_timeout:, chunk_size: 4096)
54
+ @io = io
55
+ @buffer = "".b
56
+ @offset = 0
57
+ @chunk_size = chunk_size
58
+ @read_timeout = read_timeout
59
+ @write_timeout = write_timeout
60
+ @blocking_reads = false
61
+ end
62
+
63
+ def gets_chomp
64
+ fill_buffer(false) if @offset >= @buffer.bytesize
65
+ until eol_index = @buffer.index(EOL, @offset)
66
+ fill_buffer(false)
67
+ end
68
+
69
+ line = @buffer.byteslice(@offset, eol_index - @offset)
70
+ @offset = eol_index + EOL_SIZE
71
+ line
72
+ end
73
+
74
+ def read_chomp(bytes)
75
+ ensure_remaining(bytes + EOL_SIZE)
76
+ str = @buffer.byteslice(@offset, bytes)
77
+ @offset += bytes + EOL_SIZE
78
+ str.force_encoding(Encoding::UTF_8)
79
+ end
80
+
81
+ private def ensure_line
82
+ fill_buffer(false) if @offset >= @buffer.bytesize
83
+ until @buffer.index(EOL, @offset)
84
+ fill_buffer(false)
85
+ end
86
+ end
21
87
  end
22
88
 
23
89
  def close
@@ -82,28 +148,35 @@ class RedisClient
82
148
  end
83
149
 
84
150
  def getbyte
85
- ensure_remaining(1)
86
- byte = @buffer.getbyte(@offset)
151
+ unless byte = @buffer.getbyte(@offset)
152
+ ensure_remaining(1)
153
+ byte = @buffer.getbyte(@offset)
154
+ end
87
155
  @offset += 1
88
156
  byte
89
157
  end
90
158
 
91
- def gets_chomp
92
- fill_buffer(false) if @offset >= @buffer.bytesize
93
- until eol_index = @buffer.index(EOL, @offset)
94
- fill_buffer(false)
95
- end
159
+ def gets_integer
160
+ int = 0
161
+ offset = @offset
162
+ while true
163
+ chr = @buffer.getbyte(offset)
96
164
 
97
- line = @buffer.byteslice(@offset, eol_index - @offset)
98
- @offset = eol_index + EOL_SIZE
99
- line
100
- end
165
+ if chr
166
+ if chr == 13 # "\r".ord
167
+ @offset = offset + 2
168
+ break
169
+ else
170
+ int = (int * 10) + chr - 48
171
+ end
172
+ offset += 1
173
+ else
174
+ ensure_line
175
+ return gets_integer
176
+ end
177
+ end
101
178
 
102
- def read_chomp(bytes)
103
- ensure_remaining(bytes + EOL_SIZE)
104
- str = @buffer.byteslice(@offset, bytes)
105
- @offset += bytes + EOL_SIZE
106
- str
179
+ int
107
180
  end
108
181
 
109
182
  private
@@ -117,7 +190,8 @@ class RedisClient
117
190
 
118
191
  def fill_buffer(strict, size = @chunk_size)
119
192
  remaining = size
120
- empty_buffer = @offset >= @buffer.bytesize
193
+ start = @offset - @buffer.bytesize
194
+ empty_buffer = start >= 0
121
195
 
122
196
  loop do
123
197
  bytes = if empty_buffer
@@ -126,15 +200,6 @@ class RedisClient
126
200
  @io.read_nonblock([remaining, @chunk_size].max, exception: false)
127
201
  end
128
202
  case bytes
129
- when String
130
- if empty_buffer
131
- @offset = 0
132
- empty_buffer = false
133
- else
134
- @buffer << bytes
135
- end
136
- remaining -= bytes.bytesize
137
- return if !strict || remaining <= 0
138
203
  when :wait_readable
139
204
  unless @io.to_io.wait_readable(@read_timeout)
140
205
  raise ReadTimeoutError, "Waited #{@read_timeout} seconds" unless @blocking_reads
@@ -144,7 +209,15 @@ class RedisClient
144
209
  when nil
145
210
  raise EOFError
146
211
  else
147
- raise "Unexpected `read_nonblock` return: #{bytes.inspect}"
212
+ if empty_buffer
213
+ @offset = start
214
+ empty_buffer = false
215
+ @buffer.force_encoding(ENCODING) unless @buffer.encoding == ENCODING
216
+ else
217
+ @buffer << bytes.force_encoding(ENCODING)
218
+ end
219
+ remaining -= bytes.bytesize
220
+ return if !strict || remaining <= 0
148
221
  end
149
222
  end
150
223
  end
@@ -10,12 +10,12 @@ class RedisClient
10
10
 
11
11
  EOL = "\r\n".b.freeze
12
12
  EOL_SIZE = EOL.bytesize
13
- DUMP_TYPES = { # rubocop:disable Style/MutableConstant
13
+ DUMP_TYPES = {
14
14
  String => :dump_string,
15
15
  Symbol => :dump_symbol,
16
16
  Integer => :dump_numeric,
17
17
  Float => :dump_numeric,
18
- }
18
+ }.freeze
19
19
  PARSER_TYPES = {
20
20
  '#' => :parse_boolean,
21
21
  '$' => :parse_blob,
@@ -57,7 +57,7 @@ class RedisClient
57
57
  def dump_any(object, buffer)
58
58
  method = DUMP_TYPES.fetch(object.class) do |unexpected_class|
59
59
  if superclass = DUMP_TYPES.keys.find { |t| t > unexpected_class }
60
- DUMP_TYPES[unexpected_class] = DUMP_TYPES[superclass]
60
+ DUMP_TYPES[superclass]
61
61
  else
62
62
  raise TypeError, "Unsupported command argument type: #{unexpected_class}"
63
63
  end
@@ -111,15 +111,39 @@ class RedisClient
111
111
 
112
112
  def parse(io)
113
113
  type = io.getbyte
114
- method = PARSER_TYPES.fetch(type) do
114
+ if type == 35 # '#'.ord
115
+ parse_boolean(io)
116
+ elsif type == 36 # '$'.ord
117
+ parse_blob(io)
118
+ elsif type == 43 # '+'.ord
119
+ parse_string(io)
120
+ elsif type == 61 # '='.ord
121
+ parse_verbatim_string(io)
122
+ elsif type == 45 # '-'.ord
123
+ parse_error(io)
124
+ elsif type == 58 # ':'.ord
125
+ parse_integer(io)
126
+ elsif type == 40 # '('.ord
127
+ parse_integer(io)
128
+ elsif type == 44 # ','.ord
129
+ parse_double(io)
130
+ elsif type == 95 # '_'.ord
131
+ parse_null(io)
132
+ elsif type == 42 # '*'.ord
133
+ parse_array(io)
134
+ elsif type == 37 # '%'.ord
135
+ parse_map(io)
136
+ elsif type == 126 # '~'.ord
137
+ parse_set(io)
138
+ elsif type == 62 # '>'.ord
139
+ parse_array(io)
140
+ else
115
141
  raise UnknownType, "Unknown sigil type: #{type.chr.inspect}"
116
142
  end
117
- send(method, io)
118
143
  end
119
144
 
120
145
  def parse_string(io)
121
146
  str = io.gets_chomp
122
- str.force_encoding(Encoding.default_external)
123
147
  str.force_encoding(Encoding::BINARY) unless str.valid_encoding?
124
148
  str.freeze
125
149
  end
@@ -140,17 +164,17 @@ class RedisClient
140
164
  end
141
165
 
142
166
  def parse_array(io)
143
- parse_sequence(io, parse_integer(io))
167
+ parse_sequence(io, io.gets_integer)
144
168
  end
145
169
 
146
170
  def parse_set(io)
147
- parse_sequence(io, parse_integer(io))
171
+ parse_sequence(io, io.gets_integer)
148
172
  end
149
173
 
150
174
  def parse_map(io)
151
175
  hash = {}
152
- parse_integer(io).times do
153
- hash[parse(io)] = parse(io)
176
+ io.gets_integer.times do
177
+ hash[parse(io).freeze] = parse(io)
154
178
  end
155
179
  hash
156
180
  end
@@ -192,11 +216,10 @@ class RedisClient
192
216
  end
193
217
 
194
218
  def parse_blob(io)
195
- bytesize = parse_integer(io)
219
+ bytesize = io.gets_integer
196
220
  return if bytesize < 0 # RESP2 nil type
197
221
 
198
222
  str = io.read_chomp(bytesize)
199
- str.force_encoding(Encoding.default_external)
200
223
  str.force_encoding(Encoding::BINARY) unless str.valid_encoding?
201
224
  str
202
225
  end
@@ -40,6 +40,8 @@ class RedisClient
40
40
 
41
41
  SUPPORTS_RESOLV_TIMEOUT = Socket.method(:tcp).parameters.any? { |p| p.last == :resolv_timeout }
42
42
 
43
+ attr_reader :config
44
+
43
45
  def initialize(config, connect_timeout:, read_timeout:, write_timeout:)
44
46
  super()
45
47
  @config = config
@@ -72,8 +74,8 @@ class RedisClient
72
74
  buffer = RESP3.dump(command)
73
75
  begin
74
76
  @io.write(buffer)
75
- rescue SystemCallError, IOError => error
76
- raise ConnectionError, error.message
77
+ rescue SystemCallError, IOError, OpenSSL::SSL::SSLError => error
78
+ raise ConnectionError.with_config(error.message, config)
77
79
  end
78
80
  end
79
81
 
@@ -84,8 +86,8 @@ class RedisClient
84
86
  end
85
87
  begin
86
88
  @io.write(buffer)
87
- rescue SystemCallError, IOError => error
88
- raise ConnectionError, error.message
89
+ rescue SystemCallError, IOError, OpenSSL::SSL::SSLError => error
90
+ raise ConnectionError.with_config(error.message, config)
89
91
  end
90
92
  end
91
93
 
@@ -96,9 +98,9 @@ class RedisClient
96
98
  @io.with_timeout(timeout) { RESP3.load(@io) }
97
99
  end
98
100
  rescue RedisClient::RESP3::UnknownType => error
99
- raise RedisClient::ProtocolError, error.message
101
+ raise RedisClient::ProtocolError.with_config(error.message, config)
100
102
  rescue SystemCallError, IOError, OpenSSL::SSL::SSLError => error
101
- raise ConnectionError, error.message
103
+ raise ConnectionError.with_config(error.message, config)
102
104
  end
103
105
 
104
106
  def measure_round_trip_delay
@@ -130,9 +132,9 @@ class RedisClient
130
132
  loop do
131
133
  case status = socket.connect_nonblock(exception: false)
132
134
  when :wait_readable
133
- socket.to_io.wait_readable(@connect_timeout) or raise CannotConnectError
135
+ socket.to_io.wait_readable(@connect_timeout) or raise CannotConnectError.with_config("", config)
134
136
  when :wait_writable
135
- socket.to_io.wait_writable(@connect_timeout) or raise CannotConnectError
137
+ socket.to_io.wait_writable(@connect_timeout) or raise CannotConnectError.with_config("", config)
136
138
  when socket
137
139
  break
138
140
  else
@@ -112,6 +112,12 @@ class RedisClient
112
112
  end
113
113
  end
114
114
 
115
+ def resolved?
116
+ @mutex.synchronize do
117
+ !!@config
118
+ end
119
+ end
120
+
115
121
  private
116
122
 
117
123
  def sentinels_to_configs(sentinels)
@@ -188,6 +194,10 @@ class RedisClient
188
194
  if success
189
195
  @sentinel_configs.unshift(@sentinel_configs.delete(sentinel_config))
190
196
  end
197
+ # Redis Sentinels may be configured to have a lower maxclients setting than
198
+ # the Redis nodes. Close the connection to the Sentinel node to avoid using
199
+ # a connection.
200
+ sentinel_client.close
191
201
  end
192
202
  end
193
203
 
@@ -4,26 +4,41 @@ require "uri"
4
4
 
5
5
  class RedisClient
6
6
  class URLConfig
7
- DEFAULT_SCHEMA = "redis"
8
- SSL_SCHEMA = "rediss"
9
-
10
7
  attr_reader :url, :uri
11
8
 
12
9
  def initialize(url)
13
10
  @url = url
14
11
  @uri = URI(url)
15
- unless uri.scheme == DEFAULT_SCHEMA || uri.scheme == SSL_SCHEMA
16
- raise ArgumentError, "Invalid URL: #{url.inspect}"
12
+ @unix = false
13
+ @ssl = false
14
+ case uri.scheme
15
+ when "redis"
16
+ # expected
17
+ when "rediss"
18
+ @ssl = true
19
+ when "unix", nil
20
+ @unix = true
21
+ else
22
+ raise ArgumentError, "Unknown URL scheme: #{url.inspect}"
17
23
  end
18
24
  end
19
25
 
20
26
  def ssl?
21
- @uri.scheme == SSL_SCHEMA
27
+ @ssl
22
28
  end
23
29
 
24
30
  def db
25
- db_path = uri.path&.delete_prefix("/")
26
- Integer(db_path) if db_path && !db_path.empty?
31
+ unless @unix
32
+ db_path = uri.path&.delete_prefix("/")
33
+ return Integer(db_path) if db_path && !db_path.empty?
34
+ end
35
+
36
+ unless uri.query.nil? || uri.query.empty?
37
+ _, db_query = URI.decode_www_form(uri.query).find do |key, _|
38
+ key == "db"
39
+ end
40
+ return Integer(db_query) if db_query && !db_query.empty?
41
+ end
27
42
  end
28
43
 
29
44
  def username
@@ -44,6 +59,12 @@ class RedisClient
44
59
  uri.host.sub(/\A\[(.*)\]\z/, '\1')
45
60
  end
46
61
 
62
+ def path
63
+ if @unix
64
+ File.join(*[uri.host, uri.path].compact)
65
+ end
66
+ end
67
+
47
68
  def port
48
69
  return unless uri.port
49
70
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class RedisClient
4
- VERSION = "0.19.1"
4
+ VERSION = "0.22.0"
5
5
  end
data/lib/redis_client.rb CHANGED
@@ -77,7 +77,29 @@ class RedisClient
77
77
  end
78
78
  end
79
79
 
80
- Error = Class.new(StandardError)
80
+ module HasConfig
81
+ attr_reader :config
82
+
83
+ def _set_config(config)
84
+ @config = config
85
+ end
86
+
87
+ def message
88
+ return super unless config&.resolved?
89
+
90
+ "#{super} (#{config.server_url})"
91
+ end
92
+ end
93
+
94
+ class Error < StandardError
95
+ include HasConfig
96
+
97
+ def self.with_config(message, config = nil)
98
+ new(message).tap do |error|
99
+ error._set_config(config)
100
+ end
101
+ end
102
+ end
81
103
 
82
104
  ProtocolError = Class.new(Error)
83
105
  UnsupportedServer = Class.new(Error)
@@ -114,7 +136,7 @@ class RedisClient
114
136
  end
115
137
  code ||= error_message.split(' ', 2).first
116
138
  klass = ERRORS.fetch(code, self)
117
- klass.new(error_message)
139
+ klass.new(error_message.strip)
118
140
  end
119
141
  end
120
142
  end
@@ -399,7 +421,7 @@ class RedisClient
399
421
  ensure_connected(retryable: false, &block)
400
422
  end
401
423
 
402
- def pipelined
424
+ def pipelined(exception: true)
403
425
  pipeline = Pipeline.new(@command_builder)
404
426
  yield pipeline
405
427
 
@@ -409,7 +431,7 @@ class RedisClient
409
431
  results = ensure_connected(retryable: pipeline._retryable?) do |connection|
410
432
  commands = pipeline._commands
411
433
  @middlewares.call_pipelined(commands, config) do
412
- connection.call_pipelined(commands, pipeline._timeouts)
434
+ connection.call_pipelined(commands, pipeline._timeouts, exception: exception)
413
435
  end
414
436
  end
415
437
 
@@ -750,10 +772,13 @@ class RedisClient
750
772
  end
751
773
  end
752
774
  end
753
- rescue FailoverError, CannotConnectError
754
- raise
775
+ rescue FailoverError, CannotConnectError => error
776
+ error._set_config(config)
777
+ raise error
755
778
  rescue ConnectionError => error
756
- raise CannotConnectError, error.message, error.backtrace
779
+ connect_error = CannotConnectError.with_config(error.message, config)
780
+ connect_error.set_backtrace(error.backtrace)
781
+ raise connect_error
757
782
  rescue CommandError => error
758
783
  if error.message.match?(/ERR unknown command ['`]HELLO['`]/)
759
784
  raise UnsupportedServer,
data/redis-client.gemspec CHANGED
@@ -11,7 +11,7 @@ Gem::Specification.new do |spec|
11
11
  spec.summary = "Simple low-level client for Redis 6+"
12
12
  spec.homepage = "https://github.com/redis-rb/redis-client"
13
13
  spec.license = "MIT"
14
- spec.required_ruby_version = ">= 2.5.0"
14
+ spec.required_ruby_version = ">= 2.6.0"
15
15
 
16
16
  spec.metadata["allowed_push_host"] = "https://rubygems.org"
17
17
 
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: redis-client
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.19.1
4
+ version: 0.22.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jean Boussier
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2023-12-21 00:00:00.000000000 Z
11
+ date: 2024-04-12 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: connection_pool
@@ -70,14 +70,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
70
70
  requirements:
71
71
  - - ">="
72
72
  - !ruby/object:Gem::Version
73
- version: 2.5.0
73
+ version: 2.6.0
74
74
  required_rubygems_version: !ruby/object:Gem::Requirement
75
75
  requirements:
76
76
  - - ">="
77
77
  - !ruby/object:Gem::Version
78
78
  version: '0'
79
79
  requirements: []
80
- rubygems_version: 3.4.10
80
+ rubygems_version: 3.5.5
81
81
  signing_key:
82
82
  specification_version: 4
83
83
  summary: Simple low-level client for Redis 6+