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 +4 -4
- data/CHANGELOG.md +42 -0
- data/README.md +118 -5
- data/lib/solid_redis/client.rb +22 -12
- data/lib/solid_redis/cluster/command_key.rb +2 -1
- data/lib/solid_redis/cluster_client.rb +8 -1
- data/lib/solid_redis/config.rb +8 -5
- data/lib/solid_redis/resp.rb +98 -20
- data/lib/solid_redis/subscription.rb +6 -0
- data/lib/solid_redis/version.rb +1 -1
- metadata +30 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b6c2088305fc897e081c9f3b7d2a5913441ab4816851a9da52158d05ea84d863
|
|
4
|
+
data.tar.gz: 15d0a731a92ac785fd837ca99be1d6521a728446be45087adbf997f338f6456d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
276
|
-
pipeline.call("GET", "b") #
|
|
277
|
-
pipeline.call("PING") #
|
|
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
|
|
590
|
-
CI runs
|
|
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,
|
data/lib/solid_redis/client.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
202
|
-
|
|
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
|
|
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)
|
|
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
|
data/lib/solid_redis/config.rb
CHANGED
|
@@ -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:
|
|
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]
|
|
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
|
-
|
|
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
|
|
146
|
+
return unless uri.query
|
|
144
147
|
|
|
145
148
|
value = URI.decode_www_form(uri.query).to_h["db"]
|
|
146
|
-
|
|
149
|
+
Integer(value) if value
|
|
147
150
|
end
|
|
148
151
|
|
|
149
152
|
def decoded(value)
|
data/lib/solid_redis/resp.rb
CHANGED
|
@@ -7,15 +7,23 @@ module SolidRedis
|
|
|
7
7
|
module_function
|
|
8
8
|
|
|
9
9
|
def encode(command)
|
|
10
|
-
|
|
11
|
-
raise ArgumentError, "Redis command cannot be empty" if
|
|
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(+"*#{
|
|
14
|
-
|
|
15
|
-
|
|
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
|
|
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
|
|
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:
|
|
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:
|
|
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 |
|
|
108
|
-
length.times {
|
|
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
|
-
|
|
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
|
|
139
|
-
@buffer.
|
|
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
|
-
|
|
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
|
data/lib/solid_redis/version.rb
CHANGED
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.
|
|
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.
|
|
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: []
|