solid-redis 1.0.1 → 1.0.3

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: 9c1a91886e9988d667a965cce4d26a331c87e020857e11e155c50b1d2f0f9974
4
- data.tar.gz: ff0d3277cf6011e225c12953f9a5a8d0eb016070e10e399fc5b158f59730ed37
3
+ metadata.gz: e1a92b96853d5d381ae812b7440c9b50a718f9b67fc678e0cbee1d0c8014fc16
4
+ data.tar.gz: 679a2723c695c95b68f95432006fe93e111c2318106b0384e7a409eca475b480
5
5
  SHA512:
6
- metadata.gz: de7efbf6005150a5c6986de65bbb3e4d2afbbda154c69d8a6ff60b43dc917c26ee0b4a2eb078f432c563e46a95507b646220aba4c699fbf37ebfb46bf0a34c94
7
- data.tar.gz: 1b9f37eebe965da58f9ad0c097ed9020423cf8518a1690830478497fb550052a0e0ccb1bf583e2f06a02e608954c518d61df17ac6b8dbdfa907492a07a75563e
6
+ metadata.gz: 2ea6594459de7d81b8e8fbe1f0f323a397a6c99ff145489ecba44e0ff6ecb8c2409236283a0d21add112033377ceaab4ce787e7021afbfd6c79d18ffc1991f92
7
+ data.tar.gz: 4ddc0772aeb012163fced9a0bdca65c8da9d21019e82f25b36254fe2c0c3b32312052de47e707b1bcdf64fc35663363db4775155dcc4143ba9c44db93cdc6d48
data/CHANGELOG.md ADDED
@@ -0,0 +1,101 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented in this file. The format
4
+ follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the
5
+ project adheres to [Semantic Versioning](https://semver.org/).
6
+
7
+ ## [1.0.3] - 2026-09-27
8
+
9
+ ### Fixed
10
+
11
+ - Consume complete RESP arrays, maps, sets, pushes, and attributes before
12
+ raising nested Redis errors, preventing connection desynchronization.
13
+ - Handle `:wait_writable` during non-blocking reads and discard connections
14
+ after protocol errors without replaying commands.
15
+ - Retry `TRYAGAIN` and `CLUSTERDOWN` pipeline replies consistently with direct
16
+ Cluster calls, and clear `ASKING` state before refreshing the topology.
17
+ - Route `MEMORY USAGE` to the node owning its key.
18
+ - Preserve an explicitly configured `db` when a Redis URL is also provided.
19
+
20
+ ### Changed
21
+
22
+ - Clarify that Cluster commands are grouped into per-node pipelines rather
23
+ than sent concurrently.
24
+
25
+ ## [1.0.2] - 2026-09-27
26
+
27
+ ### Added
28
+
29
+ - `CHANGELOG.md` and `changelog_uri` gem metadata.
30
+
31
+ ### Changed
32
+
33
+ - README: describe the test suite's fake server and how to check against a
34
+ real Redis or local cluster.
35
+
36
+ ## [1.0.1] - 2026-09-27
37
+
38
+ ### Changed
39
+
40
+ - README: document the Cluster architecture (`ClusterConfig`/`ClusterState`).
41
+
42
+ ## [1.0.0] - 2026-09-27
43
+
44
+ ### Added
45
+
46
+ - `Client#blocking_call(timeout, *command)` and `#blocking_call_v` for BLPOP,
47
+ BRPOP, BZPOPMIN, XREAD BLOCK and similar commands. The read timeout is
48
+ extended by `timeout`; `nil`/`0` waits forever. Blocking commands are never
49
+ retried. Also available on `Pool`.
50
+ - Pub/Sub: `Config#new_subscription` and `SentinelConfig#new_subscription`
51
+ return a `SolidRedis::Subscription` bound to a dedicated connection, with
52
+ `subscribe`/`psubscribe`/`ssubscribe`, `next_message(timeout:)`,
53
+ `each_message`, `ping`, and automatic resubscription after reconnect.
54
+ - Redis Cluster: `SolidRedis.cluster(nodes:, max_redirections:, **options)`
55
+ builds a shareable `ClusterConfig`. Each Ractor keeps its own `ClusterState`
56
+ (slot table and per-node clients). `ClusterClient` handles `MOVED`, `ASK`,
57
+ `TRYAGAIN` and `CLUSTERDOWN`, extracts routing keys (hash tags, EVAL/FCALL,
58
+ XREAD STREAMS, ZUNION-style commands) and runs pipelines grouped per node
59
+ while preserving reply order.
60
+ - `RESP::Reader#with_timeout` and `#wait_readable`.
61
+
62
+ ## [0.2.1] - 2026-09-27
63
+
64
+ ### Changed
65
+
66
+ - README: refresh the downloads badge.
67
+
68
+ ## [0.2.0] - 2026-09-27
69
+
70
+ ### Added
71
+
72
+ - `Client#pipelined(exception: false)` returns `CommandError` instances in
73
+ place instead of raising the first one.
74
+
75
+ ## [0.1.1] - 2026-09-27
76
+
77
+ ### Added
78
+
79
+ - README usage examples.
80
+
81
+ ### Changed
82
+
83
+ - Require Ruby >= 3.1; CI runs Ruby 3.1 through 4.0.
84
+
85
+ ## [0.1.0] - 2026-09-27
86
+
87
+ ### Added
88
+
89
+ - Initial release: immutable, Ractor-shareable `Config` and `SentinelConfig`
90
+ with per-Ractor `SentinelState`, `Client` (RESP2/RESP3, TCP, Unix socket,
91
+ TLS, reconnection), `Pool` owned by a single Ractor, pipelines, and
92
+ lifecycle callbacks via `callback-collection`.
93
+
94
+ [1.0.3]: https://github.com/nicolasva/solid-redis/compare/v1.0.2...v1.0.3
95
+ [1.0.2]: https://github.com/nicolasva/solid-redis/compare/v1.0.1...v1.0.2
96
+ [1.0.1]: https://github.com/nicolasva/solid-redis/compare/v1.0.0...v1.0.1
97
+ [1.0.0]: https://github.com/nicolasva/solid-redis/compare/v0.2.1...v1.0.0
98
+ [0.2.1]: https://github.com/nicolasva/solid-redis/compare/v0.2.0...v0.2.1
99
+ [0.2.0]: https://github.com/nicolasva/solid-redis/compare/v0.1.1...v0.2.0
100
+ [0.1.1]: https://github.com/nicolasva/solid-redis/compare/v0.1.0...v0.1.1
101
+ [0.1.0]: https://github.com/nicolasva/solid-redis/releases/tag/v0.1.0
data/README.md CHANGED
@@ -272,9 +272,9 @@ client.call("GET", "user:1")
272
272
  client.call("MGET", "{user:1}.name", "{user:1}.email") # same slot via hash tag
273
273
 
274
274
  client.pipelined do |pipeline|
275
- pipeline.call("GET", "a") # commands are grouped per node,
276
- pipeline.call("GET", "b") # sent in parallel pipelines,
277
- pipeline.call("PING") # and results come back in order
275
+ pipeline.call("GET", "a") # commands are grouped into one pipeline
276
+ pipeline.call("GET", "b") # per node, and results come back
277
+ pipeline.call("PING") # in their original order
278
278
  end
279
279
 
280
280
  pool = CLUSTER.new_pool(size: 5) # a pool of cluster clients
@@ -308,6 +308,10 @@ Direct Redis options:
308
308
  | `ssl` | `false` | Enable TLS |
309
309
  | `ssl_params` | | Immutable `OpenSSL::SSL::SSLContext` attributes |
310
310
 
311
+ An explicit `db:` option takes precedence over a database selected by the URL
312
+ path or `?db=` query parameter. When neither specifies a database, it defaults
313
+ to `0`.
314
+
311
315
  Sentinel additionally requires `name` and `sentinels`, and accepts `role`,
312
316
  `sentinel_username`, `sentinel_password`, `sentinel_ssl`, and
313
317
  `sentinel_ssl_params`. Sentinel defaults to two reconnect attempts.
@@ -589,6 +593,22 @@ bundle exec rake
589
593
  The default task runs the Minitest suite and builds the gem in `pkg/`.
590
594
  CI runs the suite on Ruby 3.1 through 4.0.
591
595
 
596
+ The suite needs no running Redis: it uses an in-process fake server
597
+ (`test/support/fake_redis_server.rb`) that speaks RESP2/RESP3, Sentinel,
598
+ Pub/Sub and a three-node Cluster topology. To try a specific Ruby version:
599
+
600
+ ```sh
601
+ RBENV_VERSION=4.0.1 rbenv exec bundle exec rake test
602
+ ```
603
+
604
+ To exercise the client against a real server, start `redis-server` and run
605
+ snippets from the examples above with `bundle exec ruby -Ilib`. A local
606
+ cluster for manual checks can be created with three
607
+ `redis-server --port 700X --cluster-enabled yes` processes followed by
608
+ `redis-cli --cluster create 127.0.0.1:7000 127.0.0.1:7001 127.0.0.1:7002 --cluster-yes`.
609
+
610
+ The changelog lives in `CHANGELOG.md`; add an entry for every release.
611
+
592
612
  ## Publishing
593
613
 
594
614
  Releases use RubyGems Trusted Publishing. Configure a pending trusted
@@ -44,10 +44,8 @@ module SolidRedis
44
44
  begin
45
45
  write(RESP.encode(command))
46
46
  @reader.with_timeout(read_timeout) { @reader.read }
47
- rescue ConnectionError, IO::WaitReadable, IO::WaitWritable, SystemCallError => error
48
- close
49
- config.reset if config.sentinel?
50
- config.notify(:connection_error, error.class.name, error.message)
47
+ rescue ProtocolError, ConnectionError, IO::WaitReadable, IO::WaitWritable, SystemCallError => error
48
+ handle_connection_failure(error)
51
49
  raise error if error.is_a?(Error)
52
50
 
53
51
  raise ConnectionError, error.message, cause: error
@@ -96,10 +94,11 @@ module SolidRedis
96
94
  begin
97
95
  connect unless connected?
98
96
  yield
97
+ rescue ProtocolError => error
98
+ handle_connection_failure(error)
99
+ raise
99
100
  rescue ConnectionError, IO::WaitReadable, IO::WaitWritable, SystemCallError => error
100
- close
101
- config.reset if config.sentinel?
102
- config.notify(:connection_error, error.class.name, error.message)
101
+ handle_connection_failure(error)
103
102
  if attempts < config.reconnect_attempts
104
103
  attempts += 1
105
104
  retry
@@ -110,6 +109,12 @@ module SolidRedis
110
109
  end
111
110
  end
112
111
 
112
+ def handle_connection_failure(error)
113
+ close
114
+ config.reset if config.sentinel?
115
+ config.notify(:connection_error, error.class.name, error.message)
116
+ end
117
+
113
118
  def connect
114
119
  @target = config.sentinel? ? config.resolve : config
115
120
  @socket = open_socket(@target)
@@ -8,7 +8,7 @@ module SolidRedis
8
8
  KEYLESS = Ractor.make_shareable(
9
9
  %w[
10
10
  ACL ASKING AUTH BGREWRITEAOF BGSAVE CLIENT CLUSTER COMMAND CONFIG DBSIZE DEBUG DISCARD ECHO EXEC
11
- FAILOVER FLUSHALL FLUSHDB FUNCTION HELLO INFO LASTSAVE LATENCY LOLWUT MEMORY MODULE MONITOR MULTI PING
11
+ FAILOVER FLUSHALL FLUSHDB FUNCTION HELLO INFO LASTSAVE LATENCY LOLWUT MODULE MONITOR MULTI PING
12
12
  PSUBSCRIBE PUBLISH PUBSUB PUNSUBSCRIBE QUIT RANDOMKEY READONLY READWRITE REPLICAOF RESET ROLE SAVE
13
13
  SCAN SCRIPT SELECT SHUTDOWN SLAVEOF SLOWLOG SUBSCRIBE SWAPDB SYNC TIME UNSUBSCRIBE UNWATCH WAIT
14
14
  ].to_h { |name| [name, true] },
@@ -42,6 +42,7 @@ module SolidRedis
42
42
  end
43
43
 
44
44
  case name
45
+ when "MEMORY" then command[1].to_s.casecmp?("USAGE") ? command[2] : nil
45
46
  when "XGROUP", "XINFO", "OBJECT" then command[2]
46
47
  when "MIGRATE" then command[3].to_s.empty? ? nil : command[3]
47
48
  else command[1]
@@ -7,6 +7,7 @@ module SolidRedis
7
7
  class ClusterClient
8
8
  REDIRECTION = /\A(MOVED|ASK) (\d+) (\S+):(\d+)\z/
9
9
  RETRY_DELAY = 0.05
10
+ TRANSIENT_ERRORS = %w[TRYAGAIN CLUSTERDOWN].freeze
10
11
 
11
12
  attr_reader :config
12
13
 
@@ -49,7 +50,8 @@ module SolidRedis
49
50
  end
50
51
 
51
52
  results.each_with_index do |result, index|
52
- next unless result.is_a?(CommandError) && result.message.match?(REDIRECTION)
53
+ next unless result.is_a?(CommandError)
54
+ next unless result.message.match?(REDIRECTION) || transient_error?(result)
53
55
 
54
56
  results[index] = begin
55
57
  call_v(commands[index])
@@ -107,6 +109,7 @@ module SolidRedis
107
109
  redirections += 1
108
110
  raise FailoverError, "Cluster unavailable: #{error.message}" if redirections > config.max_redirections
109
111
 
112
+ asking = false
110
113
  sleep RETRY_DELAY
111
114
  config.state.refresh
112
115
  node = node_for(command)
@@ -129,6 +132,10 @@ module SolidRedis
129
132
  key ? config.state.node_for_slot(Cluster::KeySlot.for(key)) : config.state.any_node
130
133
  end
131
134
 
135
+ def transient_error?(error)
136
+ error.message.start_with?(*TRANSIENT_ERRORS)
137
+ end
138
+
132
139
  def client_for(node)
133
140
  @clients[node.server_key] ||= node.new_client(name: @name)
134
141
  end
@@ -6,6 +6,7 @@ module SolidRedis
6
6
  class Config
7
7
  DEFAULT_TIMEOUT = 1.0
8
8
  DEFAULT_PORT = 6379
9
+ UNSPECIFIED = Object.new.freeze
9
10
 
10
11
  attr_reader :host, :port, :path, :username, :password, :db,
11
12
  :connect_timeout, :read_timeout, :write_timeout, :reconnect_attempts,
@@ -18,7 +19,7 @@ module SolidRedis
18
19
  path: nil,
19
20
  username: nil,
20
21
  password: nil,
21
- db: 0,
22
+ db: UNSPECIFIED,
22
23
  timeout: DEFAULT_TIMEOUT,
23
24
  connect_timeout: timeout,
24
25
  read_timeout: timeout,
@@ -35,9 +36,10 @@ module SolidRedis
35
36
  path = values[:path]
36
37
  username ||= values[:username]
37
38
  password ||= values[:password]
38
- db = values[:db] unless values[:db].nil?
39
+ db = values[:db] if db.equal?(UNSPECIFIED) && !values[:db].nil?
39
40
  ssl ||= values[:ssl]
40
41
  end
42
+ db = 0 if db.equal?(UNSPECIFIED)
41
43
 
42
44
  @host = String(host).dup.freeze unless path
43
45
  @port = Integer(port) unless path
@@ -140,10 +142,10 @@ module SolidRedis
140
142
  end
141
143
 
142
144
  def query_db(uri)
143
- return 0 unless uri.query
145
+ return unless uri.query
144
146
 
145
147
  value = URI.decode_www_form(uri.query).to_h["db"]
146
- value ? Integer(value) : 0
148
+ Integer(value) if value
147
149
  end
148
150
 
149
151
  def decoded(value)
@@ -72,7 +72,7 @@ module SolidRedis
72
72
  when "~", ">" then read_collection(exception)
73
73
  when "=" then read_verbatim
74
74
  when "!" then error_response(read_sized_string, exception)
75
- when "|" then read_map(exception) && read(exception: exception)
75
+ when "|" then read_attribute(exception)
76
76
  else raise ProtocolError, "Unknown RESP type byte: #{type.inspect}"
77
77
  end
78
78
  rescue EOFError
@@ -94,18 +94,46 @@ module SolidRedis
94
94
  length = Integer(read_line)
95
95
  return if length == -1
96
96
 
97
- Array.new(length) { read(exception: exception) }
97
+ finish_collection(Array.new(length) { read(exception: false) }, exception)
98
98
  end
99
99
 
100
100
  def read_collection(exception)
101
101
  length = Integer(read_line)
102
- Array.new(length) { read(exception: exception) }
102
+ finish_collection(Array.new(length) { read(exception: false) }, exception)
103
103
  end
104
104
 
105
105
  def read_map(exception)
106
106
  length = Integer(read_line)
107
- {}.tap do |map|
108
- length.times { map[read(exception: exception)] = read(exception: exception) }
107
+ map = {}.tap do |result|
108
+ length.times { result[read(exception: false)] = read(exception: false) }
109
+ end
110
+ finish_collection(map, exception)
111
+ end
112
+
113
+ def read_attribute(exception)
114
+ attributes = read_map(false)
115
+ value = read(exception: false)
116
+ raise_nested_error(attributes) if exception
117
+ raise_nested_error(value) if exception
118
+ value
119
+ end
120
+
121
+ def finish_collection(value, exception)
122
+ raise_nested_error(value) if exception
123
+ value
124
+ end
125
+
126
+ def raise_nested_error(value)
127
+ case value
128
+ when CommandError
129
+ raise value
130
+ when Array
131
+ value.each { |element| raise_nested_error(element) }
132
+ when Hash
133
+ value.each do |key, element|
134
+ raise_nested_error(key)
135
+ raise_nested_error(element)
136
+ end
109
137
  end
110
138
  end
111
139
 
@@ -140,9 +168,12 @@ module SolidRedis
140
168
  end
141
169
 
142
170
  def fill_buffer
171
+ wait_for = :readable
143
172
  loop do
144
173
  if @io.respond_to?(:to_io)
145
- unless IO.select([@io], nil, nil, @read_timeout)
174
+ readers = wait_for == :readable ? [@io] : nil
175
+ writers = wait_for == :writable ? [@io] : nil
176
+ unless IO.select(readers, writers, nil, @read_timeout)
146
177
  raise TimeoutError, "Redis read timed out after #{@read_timeout}s"
147
178
  end
148
179
 
@@ -151,7 +182,10 @@ module SolidRedis
151
182
  chunk = @io.read(16_384)
152
183
  end
153
184
 
154
- next if chunk == :wait_readable
185
+ if chunk == :wait_readable || chunk == :wait_writable
186
+ wait_for = chunk == :wait_readable ? :readable : :writable
187
+ next
188
+ end
155
189
  raise EOFError if chunk.nil?
156
190
 
157
191
  @buffer << chunk
@@ -75,6 +75,9 @@ module SolidRedis
75
75
  return unless @reader.wait_readable(timeout)
76
76
 
77
77
  return decode(@reader.read)
78
+ rescue ProtocolError => error
79
+ handle_error(error)
80
+ raise
78
81
  rescue ConnectionError, IO::WaitReadable, IO::WaitWritable, SystemCallError => error
79
82
  handle_error(error)
80
83
  raise error if attempts >= config.reconnect_attempts
@@ -123,6 +126,9 @@ module SolidRedis
123
126
  loop do
124
127
  ensure_connected
125
128
  return write(RESP.encode(yield))
129
+ rescue ProtocolError => error
130
+ handle_error(error)
131
+ raise
126
132
  rescue ConnectionError, IO::WaitReadable, IO::WaitWritable, SystemCallError => error
127
133
  handle_error(error)
128
134
  raise error if attempts >= config.reconnect_attempts
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SolidRedis
4
- VERSION = "1.0.1"
4
+ VERSION = "1.0.3"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: solid-redis
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.1
4
+ version: 1.0.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Nicolas Vandenbogaerde
@@ -78,6 +78,7 @@ executables: []
78
78
  extensions: []
79
79
  extra_rdoc_files: []
80
80
  files:
81
+ - CHANGELOG.md
81
82
  - LICENSE.txt
82
83
  - README.md
83
84
  - lib/solid_redis.rb
@@ -103,8 +104,9 @@ licenses:
103
104
  - MIT
104
105
  metadata:
105
106
  rubygems_mfa_required: 'true'
106
- documentation_uri: https://www.rubydoc.info/gems/solid-redis/1.0.1
107
+ documentation_uri: https://www.rubydoc.info/gems/solid-redis/1.0.3
107
108
  source_code_uri: https://github.com/nicolasva/solid-redis
109
+ changelog_uri: https://github.com/nicolasva/solid-redis/blob/main/CHANGELOG.md
108
110
  rdoc_options: []
109
111
  require_paths:
110
112
  - lib