solid-redis 1.0.2 → 1.0.4

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: 5a8d7e4c46fd3443d3eda55582111998c1c2f1cfa1d97745f53a8d39494d1dbf
4
- data.tar.gz: bbd7f6c8da3509f605d20daf2ace8439e4d0f3ffc16bd4d4405a8e6d3f880723
3
+ metadata.gz: b6c2088305fc897e081c9f3b7d2a5913441ab4816851a9da52158d05ea84d863
4
+ data.tar.gz: 15d0a731a92ac785fd837ca99be1d6521a728446be45087adbf997f338f6456d
5
5
  SHA512:
6
- metadata.gz: 3744b1d0cecccd3e286445f5224852d27220afb7cbcfe763d4cf500ddea03976a4e7a73586276219446cfcb590ca09f174510f9f426063f868d5e0d8bf797d41
7
- data.tar.gz: d3ae71c56576195f4067db3d3384c7aa19a6c0fad306dbd588cb1562d09289bb0fc39957f80149184eec63ce3e415b169956e1bde816209209e454e85725330f
6
+ metadata.gz: a20d9fcccf65d69d08045d196ca54478e65c689941c6a07b302acea78e3f2218b6b96b3039904e5f710fff54fc013ef951ef98b267c24d3c9cbf961066ad8442
7
+ data.tar.gz: 373aac9ed547db8482549be91af26821fba8ed48b4098fae5049ffd9496de97960c7f3176e6ebdbf46e6230196c90058842efce10803d5e5251d321afe623ca8
data/CHANGELOG.md CHANGED
@@ -4,6 +4,46 @@ All notable changes to this project are documented in this file. The format
4
4
  follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the
5
5
  project adheres to [Semantic Versioning](https://semver.org/).
6
6
 
7
+ ## [1.0.4] - 2026-09-27
8
+
9
+ ### Changed
10
+
11
+ - Cache the immutable server key instead of allocating it on every Cluster
12
+ lookup.
13
+ - Encode one-level nested command arguments without creating an intermediate
14
+ flattened array.
15
+ - Parse RESP data with a buffer cursor and compact only when another socket
16
+ read is required, reducing response-parser allocations.
17
+ - Attempt non-blocking socket reads and writes before waiting with `IO.select`,
18
+ while preserving read/write deadlines when the socket would block.
19
+
20
+ ## [1.0.3] - 2026-09-27
21
+
22
+ ### Added
23
+
24
+ - Deterministic fault-injection and bounded thread/Ractor stress tests in the
25
+ standard CI suite.
26
+ - A dependency-free local throughput benchmark run by the default Rake task.
27
+ - A reproducible real-Redis comparison with `redis-client` across 1/2/4/8
28
+ Ractors, pipelines, isolated pools, Cluster, and forced Sentinel failover,
29
+ including latency percentiles, CPU, RSS, allocations, and errors.
30
+
31
+ ### Fixed
32
+
33
+ - Consume complete RESP arrays, maps, sets, pushes, and attributes before
34
+ raising nested Redis errors, preventing connection desynchronization.
35
+ - Handle `:wait_writable` during non-blocking reads and discard connections
36
+ after protocol errors without replaying commands.
37
+ - Retry `TRYAGAIN` and `CLUSTERDOWN` pipeline replies consistently with direct
38
+ Cluster calls, and clear `ASKING` state before refreshing the topology.
39
+ - Route `MEMORY USAGE` to the node owning its key.
40
+ - Preserve an explicitly configured `db` when a Redis URL is also provided.
41
+
42
+ ### Changed
43
+
44
+ - Clarify that Cluster commands are grouped into per-node pipelines rather
45
+ than sent concurrently.
46
+
7
47
  ## [1.0.2] - 2026-09-27
8
48
 
9
49
  ### Added
@@ -73,6 +113,8 @@ project adheres to [Semantic Versioning](https://semver.org/).
73
113
  TLS, reconnection), `Pool` owned by a single Ractor, pipelines, and
74
114
  lifecycle callbacks via `callback-collection`.
75
115
 
116
+ [1.0.4]: https://github.com/nicolasva/solid-redis/compare/v1.0.3...v1.0.4
117
+ [1.0.3]: https://github.com/nicolasva/solid-redis/compare/v1.0.2...v1.0.3
76
118
  [1.0.2]: https://github.com/nicolasva/solid-redis/compare/v1.0.1...v1.0.2
77
119
  [1.0.1]: https://github.com/nicolasva/solid-redis/compare/v1.0.0...v1.0.1
78
120
  [1.0.0]: https://github.com/nicolasva/solid-redis/compare/v0.2.1...v1.0.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.
@@ -579,6 +583,108 @@ futures. It cannot transparently replace a block-based synchronous pool
579
583
  without defining backpressure, cancellation, transaction, Pub/Sub, and
580
584
  exception protocols.
581
585
 
586
+ ## Benchmark
587
+
588
+ <!-- benchmark-results:start -->
589
+ This is a local-loopback microbenchmark, not a prediction of application
590
+ performance. It excludes Redis server CPU/RAM and network latency.
591
+ Cluster rows compare `solid-redis` with `redis-cluster-client`, the
592
+ Cluster implementation built on `redis-client`. Unsupported combinations
593
+ are reported rather than replaced with a different concurrency model.
594
+
595
+ **Environment:** Ruby 3.4.4 (arm64-darwin24); solid-redis 1.0.3; Redis server v=8.10.0 sha=00000000:1 malloc=libc bits=64 build=80fd3081013fc32b; redis-client 0.30.1; redis-cluster-client 0.17.1; Apple M2 Max, 12 logical CPUs, 96 GiB RAM, macOS 26.6.2.
596
+
597
+ Each row is measured in a fresh Ruby process. Both clients use RESP2,
598
+ identical commands and the same local Redis topology. Client order is
599
+ alternated between runs; values are medians of 5 runs.
600
+ Regular workloads warm for 2 seconds and measure
601
+ for 10 seconds. Pipeline latency is amortized
602
+ per command (batch size 50). The isolated-pool scenario creates a
603
+ five-connection pool inside each Ractor, warms it with
604
+ 1,000 PINGs, then measures 100,000
605
+ PINGs per Ractor.
606
+ CPU is client-process CPU divided by wall time and may exceed 100% with
607
+ multiple Ractors. Peak RSS excludes Redis server processes. Sentinel
608
+ rows kill the master process 0.5 seconds after measurement starts and
609
+ verify that every Ractor resumes on the promoted replica. Recovery is
610
+ measured from the kill to each Ractor's first successful response.
611
+ Throughput counts successful commands; errors are failed commands
612
+ observed during the measured interval. Allocation and RSS measurements
613
+ include the identical latency-recording harness used for both clients, so
614
+ they are comparative rather than intrinsic library-only costs.
615
+
616
+ Reproduce the full run (requires `redis-server` and `redis-cli`):
617
+
618
+ ```sh
619
+ BENCHMARK_README=README.md bundle exec rake benchmark:comparison
620
+ ```
621
+
622
+ ### GET / SET
623
+
624
+ | Ractors | Client | Throughput (ops/s) | p50 (ms) | p95 (ms) | p99 (ms) | CPU | Peak RSS | Allocations/op | Errors/run |
625
+ |---:|---|---:|---:|---:|---:|---:|---:|---:|---:|
626
+ | 1 | `redis-client` | 35,636 | 0.027 | 0.033 | 0.045 | 58.3% | 50.6 MiB | 17.4 | 0 |
627
+ | 1 | `solid-redis` | 33,891 | 0.028 | 0.034 | 0.051 | 52.4% | 49.0 MiB | 28.9 | 0 |
628
+ | 2 | `redis-client` | 53,941 | 0.035 | 0.049 | 0.069 | 114.6% | 58.6 MiB | 17.3 | 0 |
629
+ | 2 | `solid-redis` | 56,594 | 0.033 | 0.045 | 0.090 | 104.7% | 57.9 MiB | 28.7 | 0 |
630
+ | 4 | `redis-client` | 67,608 | 0.055 | 0.092 | 0.131 | 238.3% | 63.7 MiB | 17.3 | 0 |
631
+ | 4 | `solid-redis` | 75,539 | 0.049 | 0.082 | 0.141 | 204.1% | 63.8 MiB | 28.5 | 0 |
632
+ | 8 | `redis-client` | 48,828 | 0.078 | 0.171 | 0.236 | 268.5% | 75.4 MiB | 17.3 | 0 |
633
+ | 8 | `solid-redis` | 80,691 | 0.087 | 0.172 | 0.267 | 431.4% | 69.5 MiB | 28.2 | 0 |
634
+
635
+ ### Pipelines
636
+
637
+ | Ractors | Client | Throughput (ops/s) | p50 (ms) | p95 (ms) | p99 (ms) | CPU | Peak RSS | Allocations/op | Errors/run |
638
+ |---:|---|---:|---:|---:|---:|---:|---:|---:|---:|
639
+ | 1 | `redis-client` | 262,482 | 0.004 | 0.005 | 0.007 | 81.5% | 39.5 MiB | 12.6 | 0 |
640
+ | 1 | `solid-redis` | 317,150 | 0.003 | 0.004 | 0.006 | 77.9% | 41.0 MiB | 16.2 | 0 |
641
+ | 2 | `redis-client` | 270,197 | 0.007 | 0.010 | 0.013 | 149.9% | 39.9 MiB | 12.5 | 0 |
642
+ | 2 | `solid-redis` | 459,445 | 0.004 | 0.007 | 0.009 | 147.5% | 42.5 MiB | 15.9 | 0 |
643
+ | 4 | `redis-client` | 222,813 | 0.017 | 0.025 | 0.031 | 282.8% | 40.0 MiB | 12.5 | 0 |
644
+ | 4 | `solid-redis` | 464,640 | 0.008 | 0.014 | 0.017 | 251.9% | 43.2 MiB | 15.6 | 0 |
645
+ | 8 | `redis-client` | 204,898 | 0.004 | 0.055 | 0.063 | 314.2% | 41.9 MiB | 12.5 | 0 |
646
+ | 8 | `solid-redis` | 341,323 | 0.021 | 0.042 | 0.047 | 550.7% | 43.4 MiB | 15.5 | 0 |
647
+
648
+ ### Isolated pool per Ractor
649
+
650
+ | Ractors | Client | Throughput (ops/s) | p50 (ms) | p95 (ms) | p99 (ms) | CPU | Peak RSS | Allocations/op | Errors/run |
651
+ |---:|---|---:|---:|---:|---:|---:|---:|---:|---:|
652
+ | 1 | `redis-client` | unsupported: Ractor::IsolationError (`connection_pool` uses non-shareable global state) | — | — | — | — | — | — | — |
653
+ | 1 | `solid-redis` | 34,528 | 0.028 | 0.033 | 0.043 | 53.3% | 39.0 MiB | 25.0 | 0 |
654
+ | 2 | `redis-client` | unsupported: Ractor::IsolationError (`connection_pool` uses non-shareable global state) | — | — | — | — | — | — | — |
655
+ | 2 | `solid-redis` | 57,408 | 0.032 | 0.046 | 0.086 | 106.3% | 44.4 MiB | 24.8 | 0 |
656
+ | 4 | `redis-client` | unsupported: Ractor::IsolationError (`connection_pool` uses non-shareable global state) | — | — | — | — | — | — | — |
657
+ | 4 | `solid-redis` | 78,461 | 0.047 | 0.078 | 0.129 | 205.4% | 54.1 MiB | 24.6 | 0 |
658
+ | 8 | `redis-client` | unsupported: Ractor::IsolationError (`connection_pool` uses non-shareable global state) | — | — | — | — | — | — | — |
659
+ | 8 | `solid-redis` | 72,809 | 0.071 | 0.129 | 0.202 | 297.3% | 68.9 MiB | 24.4 | 0 |
660
+
661
+ ### Cluster
662
+
663
+ | Ractors | Client | Throughput (ops/s) | p50 (ms) | p95 (ms) | p99 (ms) | CPU | Peak RSS | Allocations/op | Errors/run |
664
+ |---:|---|---:|---:|---:|---:|---:|---:|---:|---:|
665
+ | 1 | `redis-client` | 29,071 | 0.033 | 0.040 | 0.051 | 64.8% | 48.8 MiB | 19.4 | 0 |
666
+ | 1 | `solid-redis` | 29,213 | 0.033 | 0.038 | 0.056 | 57.1% | 47.6 MiB | 34.9 | 0 |
667
+ | 2 | `redis-client` | 50,333 | 0.037 | 0.051 | 0.078 | 124.7% | 57.8 MiB | 19.3 | 0 |
668
+ | 2 | `solid-redis` | 51,805 | 0.036 | 0.050 | 0.097 | 117.2% | 57.0 MiB | 34.7 | 0 |
669
+ | 4 | `redis-client` | 60,752 | 0.062 | 0.099 | 0.139 | 252.6% | 62.5 MiB | 19.3 | 0 |
670
+ | 4 | `solid-redis` | 66,626 | 0.055 | 0.089 | 0.152 | 225.9% | 61.2 MiB | 34.4 | 0 |
671
+ | 8 | `redis-client` | 43,258 | 0.089 | 0.186 | 0.253 | 278.1% | 75.4 MiB | 19.3 | 0 |
672
+ | 8 | `solid-redis` | 48,644 | 0.079 | 0.154 | 0.248 | 241.4% | 74.7 MiB | 34.3 | 0 |
673
+
674
+ ### Sentinel master crash
675
+
676
+ | Ractors | Client | Throughput (ops/s) | p50 (ms) | p95 (ms) | p99 (ms) | Recovery p50 (ms) | Recovery max (ms) | CPU | Peak RSS | Allocations/op | Errors/run |
677
+ |---:|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|
678
+ | 1 | `redis-client` | 29,378 | 0.028 | 0.035 | 0.046 | 1333.8 | 1333.8 | 61.9% | 52.3 MiB | 20.1 | 2050 |
679
+ | 1 | `solid-redis` | 28,794 | 0.029 | 0.035 | 0.050 | 1319.1 | 1319.1 | 55.5% | 48.3 MiB | 33.3 | 2090 |
680
+ | 2 | `redis-client` | 30,829 | 0.036 | 0.049 | 0.070 | 4242.9 | 4243.0 | 87.8% | 53.4 MiB | 20.4 | 3080 |
681
+ | 2 | `solid-redis` | 31,074 | 0.033 | 0.045 | 0.093 | 4440.4 | 4451.4 | 77.3% | 50.3 MiB | 33.1 | 2202 |
682
+ | 4 | `redis-client` | 36,404 | 0.054 | 0.093 | 0.131 | 4302.8 | 4870.8 | 157.2% | 58.0 MiB | 20.5 | 3113 |
683
+ | 4 | `solid-redis` | 41,080 | 0.048 | 0.081 | 0.136 | 4802.3 | 4962.6 | 132.6% | 56.0 MiB | 32.0 | 2251 |
684
+ | 8 | `redis-client` | 48,133 | 0.117 | 0.219 | 0.297 | 2278.4 | 2741.5 | 434.1% | 61.5 MiB | 19.7 | 3114 |
685
+ | 8 | `solid-redis` | 63,627 | 0.087 | 0.171 | 0.277 | 2073.7 | 2085.0 | 374.0% | 64.4 MiB | 30.6 | 2268 |
686
+ <!-- benchmark-results:end -->
687
+
582
688
  ## Development
583
689
 
584
690
  ```sh
@@ -586,8 +692,15 @@ bundle install
586
692
  bundle exec rake
587
693
  ```
588
694
 
589
- The default task runs the Minitest suite and builds the gem in `pkg/`.
590
- CI runs the suite on Ruby 3.1 through 4.0.
695
+ The default task runs the Minitest suite, a deterministic local throughput
696
+ benchmark, and builds the gem in `pkg/`. CI runs all three on Ruby 3.1 through
697
+ 4.0. The test suite includes bounded thread/Ractor stress and fault injection
698
+ for truncated responses, timeouts, reconnects, Sentinel, and Cluster errors.
699
+ Run only the benchmark with:
700
+
701
+ ```sh
702
+ bundle exec rake benchmark
703
+ ```
591
704
 
592
705
  The suite needs no running Redis: it uses an in-process fake server
593
706
  (`test/support/fake_redis_server.rb`) that speaks RESP2/RESP3, Sentinel,
@@ -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)
@@ -197,14 +202,19 @@ module SolidRedis
197
202
 
198
203
  def write(payload)
199
204
  offset = 0
205
+ deadline = nil
200
206
  while offset < payload.bytesize
201
- unless IO.select(nil, [@socket], nil, @target.write_timeout)
202
- raise TimeoutError, "Redis write timed out after #{@target.write_timeout}s"
207
+ chunk = offset.zero? ? payload : payload.byteslice(offset..)
208
+ written = @socket.write_nonblock(chunk, exception: false)
209
+ if written == :wait_writable
210
+ deadline ||= monotonic_time + @target.write_timeout
211
+ remaining = deadline - monotonic_time
212
+ if remaining <= 0 || !IO.select(nil, [@socket], nil, remaining)
213
+ raise TimeoutError, "Redis write timed out after #{@target.write_timeout}s"
214
+ end
215
+ next
203
216
  end
204
217
 
205
- written = @socket.write_nonblock(payload.byteslice(offset..), exception: false)
206
- next if written == :wait_writable
207
-
208
218
  offset += written
209
219
  end
210
220
  rescue IOError, SystemCallError => error
@@ -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
@@ -54,6 +56,7 @@ module SolidRedis
54
56
  @ssl = !!ssl
55
57
  @ssl_params = Shareable.copy(ssl_params, label: "ssl_params") if ssl_params
56
58
  @callbacks = Shareable.copy(callbacks, label: "callbacks") if callbacks
59
+ @server_key = [@path, @host, @port].freeze
57
60
  Ractor.make_shareable(self)
58
61
  end
59
62
 
@@ -70,7 +73,7 @@ module SolidRedis
70
73
  end
71
74
 
72
75
  def server_key
73
- [path, host, port].freeze
76
+ @server_key
74
77
  end
75
78
 
76
79
  def server_url
@@ -140,10 +143,10 @@ module SolidRedis
140
143
  end
141
144
 
142
145
  def query_db(uri)
143
- return 0 unless uri.query
146
+ return unless uri.query
144
147
 
145
148
  value = URI.decode_www_form(uri.query).to_h["db"]
146
- value ? Integer(value) : 0
149
+ Integer(value) if value
147
150
  end
148
151
 
149
152
  def decoded(value)
@@ -7,15 +7,23 @@ module SolidRedis
7
7
  module_function
8
8
 
9
9
  def encode(command)
10
- command = command.flatten(1)
11
- raise ArgumentError, "Redis command cannot be empty" if command.empty?
10
+ length = command.sum { |argument| argument.is_a?(Array) ? argument.length : 1 }
11
+ raise ArgumentError, "Redis command cannot be empty" if length.zero?
12
12
 
13
- command.each_with_object(+"*#{command.length}#{CRLF}") do |argument, buffer|
14
- value = encode_argument(argument)
15
- buffer << "$#{value.bytesize}#{CRLF}#{value}#{CRLF}"
13
+ command.each_with_object(+"*#{length}#{CRLF}") do |argument, buffer|
14
+ if argument.is_a?(Array)
15
+ argument.each { |value| append_argument(buffer, value) }
16
+ else
17
+ append_argument(buffer, argument)
18
+ end
16
19
  end
17
20
  end
18
21
 
22
+ def append_argument(buffer, argument)
23
+ value = encode_argument(argument)
24
+ buffer << "$#{value.bytesize}#{CRLF}#{value}#{CRLF}"
25
+ end
26
+
19
27
  def encode_argument(value)
20
28
  case value
21
29
  when String then value
@@ -32,6 +40,7 @@ module SolidRedis
32
40
  @io = io
33
41
  @read_timeout = read_timeout
34
42
  @buffer = +""
43
+ @offset = 0
35
44
  end
36
45
 
37
46
  # Temporarily overrides the read timeout. +nil+ waits forever, which is
@@ -49,7 +58,7 @@ module SolidRedis
49
58
  # leaves a partially consumed frame behind, so it is the safe way to
50
59
  # poll for the next Pub/Sub message.
51
60
  def wait_readable(timeout)
52
- return true unless @buffer.empty?
61
+ return true if available_bytes.positive?
53
62
  return true unless @io.respond_to?(:to_io)
54
63
 
55
64
  !IO.select([@io], nil, nil, timeout).nil?
@@ -72,7 +81,7 @@ module SolidRedis
72
81
  when "~", ">" then read_collection(exception)
73
82
  when "=" then read_verbatim
74
83
  when "!" then error_response(read_sized_string, exception)
75
- when "|" then read_map(exception) && read(exception: exception)
84
+ when "|" then read_attribute(exception)
76
85
  else raise ProtocolError, "Unknown RESP type byte: #{type.inspect}"
77
86
  end
78
87
  rescue EOFError
@@ -94,18 +103,46 @@ module SolidRedis
94
103
  length = Integer(read_line)
95
104
  return if length == -1
96
105
 
97
- Array.new(length) { read(exception: exception) }
106
+ finish_collection(Array.new(length) { read(exception: false) }, exception)
98
107
  end
99
108
 
100
109
  def read_collection(exception)
101
110
  length = Integer(read_line)
102
- Array.new(length) { read(exception: exception) }
111
+ finish_collection(Array.new(length) { read(exception: false) }, exception)
103
112
  end
104
113
 
105
114
  def read_map(exception)
106
115
  length = Integer(read_line)
107
- {}.tap do |map|
108
- length.times { map[read(exception: exception)] = read(exception: exception) }
116
+ map = {}.tap do |result|
117
+ length.times { result[read(exception: false)] = read(exception: false) }
118
+ end
119
+ finish_collection(map, exception)
120
+ end
121
+
122
+ def read_attribute(exception)
123
+ attributes = read_map(false)
124
+ value = read(exception: false)
125
+ raise_nested_error(attributes) if exception
126
+ raise_nested_error(value) if exception
127
+ value
128
+ end
129
+
130
+ def finish_collection(value, exception)
131
+ raise_nested_error(value) if exception
132
+ value
133
+ end
134
+
135
+ def raise_nested_error(value)
136
+ case value
137
+ when CommandError
138
+ raise value
139
+ when Array
140
+ value.each { |element| raise_nested_error(element) }
141
+ when Hash
142
+ value.each do |key, element|
143
+ raise_nested_error(key)
144
+ raise_nested_error(element)
145
+ end
109
146
  end
110
147
  end
111
148
 
@@ -127,31 +164,50 @@ module SolidRedis
127
164
 
128
165
  def read_line
129
166
  loop do
130
- if (index = @buffer.index(CRLF))
131
- return @buffer.slice!(0, index + 2).byteslice(0, index)
167
+ if (index = @buffer.index(CRLF, @offset))
168
+ value = @buffer.byteslice(@offset, index - @offset)
169
+ @offset = index + 2
170
+ clear_consumed_buffer
171
+ return value
132
172
  end
133
173
  fill_buffer
134
174
  end
135
175
  end
136
176
 
137
177
  def read_bytes(length)
138
- fill_buffer while @buffer.bytesize < length
139
- @buffer.slice!(0, length)
178
+ fill_buffer while available_bytes < length
179
+ value = @buffer.byteslice(@offset, length)
180
+ @offset += length
181
+ clear_consumed_buffer
182
+ value
140
183
  end
141
184
 
142
185
  def fill_buffer
186
+ compact_buffer
187
+ wait_for = :readable
188
+ deadline = nil
143
189
  loop do
144
190
  if @io.respond_to?(:to_io)
145
- unless IO.select([@io], nil, nil, @read_timeout)
146
- raise TimeoutError, "Redis read timed out after #{@read_timeout}s"
147
- end
148
-
149
191
  chunk = @io.read_nonblock(16_384, exception: false)
150
192
  else
151
193
  chunk = @io.read(16_384)
152
194
  end
153
195
 
154
- next if chunk == :wait_readable
196
+ if chunk == :wait_readable || chunk == :wait_writable
197
+ wait_for = chunk == :wait_readable ? :readable : :writable
198
+ deadline ||= monotonic_time + @read_timeout if @read_timeout
199
+ remaining = deadline && deadline - monotonic_time
200
+ if remaining && remaining <= 0
201
+ raise TimeoutError, "Redis read timed out after #{@read_timeout}s"
202
+ end
203
+
204
+ readers = wait_for == :readable ? [@io] : nil
205
+ writers = wait_for == :writable ? [@io] : nil
206
+ unless IO.select(readers, writers, nil, remaining)
207
+ raise TimeoutError, "Redis read timed out after #{@read_timeout}s"
208
+ end
209
+ next
210
+ end
155
211
  raise EOFError if chunk.nil?
156
212
 
157
213
  @buffer << chunk
@@ -161,6 +217,28 @@ module SolidRedis
161
217
  raise ConnectionError, error.message, cause: error
162
218
  end
163
219
 
220
+ def available_bytes
221
+ @buffer.bytesize - @offset
222
+ end
223
+
224
+ def clear_consumed_buffer
225
+ return unless @offset == @buffer.bytesize
226
+
227
+ @buffer.clear
228
+ @offset = 0
229
+ end
230
+
231
+ def compact_buffer
232
+ return if @offset.zero?
233
+
234
+ @buffer = @buffer.byteslice(@offset..) || +""
235
+ @offset = 0
236
+ end
237
+
238
+ def monotonic_time
239
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
240
+ end
241
+
164
242
  def error_response(message, exception)
165
243
  error = if message.start_with?("NOAUTH", "WRONGPASS")
166
244
  AuthenticationError.new(message)
@@ -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.2"
4
+ VERSION = "1.0.4"
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.2
4
+ version: 1.0.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Nicolas Vandenbogaerde
@@ -71,6 +71,34 @@ dependencies:
71
71
  - - "~>"
72
72
  - !ruby/object:Gem::Version
73
73
  version: '13.0'
74
+ - !ruby/object:Gem::Dependency
75
+ name: redis-client
76
+ requirement: !ruby/object:Gem::Requirement
77
+ requirements:
78
+ - - "~>"
79
+ - !ruby/object:Gem::Version
80
+ version: 0.30.1
81
+ type: :development
82
+ prerelease: false
83
+ version_requirements: !ruby/object:Gem::Requirement
84
+ requirements:
85
+ - - "~>"
86
+ - !ruby/object:Gem::Version
87
+ version: 0.30.1
88
+ - !ruby/object:Gem::Dependency
89
+ name: redis-cluster-client
90
+ requirement: !ruby/object:Gem::Requirement
91
+ requirements:
92
+ - - "~>"
93
+ - !ruby/object:Gem::Version
94
+ version: '0.17'
95
+ type: :development
96
+ prerelease: false
97
+ version_requirements: !ruby/object:Gem::Requirement
98
+ requirements:
99
+ - - "~>"
100
+ - !ruby/object:Gem::Version
101
+ version: '0.17'
74
102
  description: Immutable, shareable Redis, Sentinel and Cluster configuration with isolated
75
103
  runtime state, pools, and sockets per Ractor. Supports pipelines, blocking commands,
76
104
  and Pub/Sub.
@@ -104,7 +132,7 @@ licenses:
104
132
  - MIT
105
133
  metadata:
106
134
  rubygems_mfa_required: 'true'
107
- documentation_uri: https://www.rubydoc.info/gems/solid-redis/1.0.2
135
+ documentation_uri: https://www.rubydoc.info/gems/solid-redis/1.0.4
108
136
  source_code_uri: https://github.com/nicolasva/solid-redis
109
137
  changelog_uri: https://github.com/nicolasva/solid-redis/blob/main/CHANGELOG.md
110
138
  rdoc_options: []