solid-redis 0.1.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 +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +293 -0
- data/lib/solid_redis/client.rb +206 -0
- data/lib/solid_redis/config.rb +156 -0
- data/lib/solid_redis/errors.rb +13 -0
- data/lib/solid_redis/pool.rb +101 -0
- data/lib/solid_redis/resp.rb +153 -0
- data/lib/solid_redis/sentinel/resolve_service.rb +78 -0
- data/lib/solid_redis/sentinel_config.rb +160 -0
- data/lib/solid_redis/sentinel_state.rb +69 -0
- data/lib/solid_redis/shareable.rb +13 -0
- data/lib/solid_redis/version.rb +5 -0
- data/lib/solid_redis.rb +27 -0
- metadata +117 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: a4bfba5a15f75c77fa0c5a9d2a85a0d5d8b50ebada6fbd5e1895e56e47b5813a
|
|
4
|
+
data.tar.gz: 842b205f8ba0b9298451f24bec08c0d76542e6b46026b27539853d35c06fce54
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 8a6afa66bb51a4723939441be103372efd6260c670dd8e68ca81d788cc2430bc732221c2b7b930a35365d207b1e095a654ecbd88bac715c08bc8b6f86028872d
|
|
7
|
+
data.tar.gz: c010108671dfc8c343b213fde778afc1e9c3e2750051629ae585d2857037d8da1dd455ff2dba974b78098f014a7c25c495f30d4b1fc88b2b9ab2bf236bd0bca6
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Nicolas Vandenbogaerde
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
# Solid Redis
|
|
2
|
+
|
|
3
|
+
[](https://github.com/nicolasva/solid-redis/actions/workflows/ci.yml)
|
|
4
|
+
[](https://rubygems.org/gems/solid-redis)
|
|
5
|
+
[](https://www.rubydoc.info/gems/solid-redis)
|
|
6
|
+
|
|
7
|
+
`solid-redis` is a dependency-free Redis client designed around Ractor
|
|
8
|
+
isolation. Its Redis and Sentinel specifications are immutable and shareable;
|
|
9
|
+
every Ractor creates and retains its own resolution state, mutex, pool,
|
|
10
|
+
clients, and sockets.
|
|
11
|
+
|
|
12
|
+
It does not depend on or patch `redis-client`.
|
|
13
|
+
|
|
14
|
+
The implementation intentionally composes two small gems from the same
|
|
15
|
+
author:
|
|
16
|
+
|
|
17
|
+
- [`base-service`](https://github.com/nicolasva/base-service) executes each
|
|
18
|
+
Sentinel resolution and returns its immutable result and endpoint errors;
|
|
19
|
+
- [`callback-collection`](https://github.com/nicolasva/callback-collection)
|
|
20
|
+
provides immutable lifecycle event handlers.
|
|
21
|
+
|
|
22
|
+
## Architecture
|
|
23
|
+
|
|
24
|
+
```text
|
|
25
|
+
RUBY
|
|
26
|
+
|
|
|
27
|
+
immutable/shareable config
|
|
28
|
+
|
|
|
29
|
+
+----------------+----------------+
|
|
30
|
+
| | |
|
|
31
|
+
Ractor A Ractor B Ractor C
|
|
32
|
+
| | |
|
|
33
|
+
SentinelState A SentinelState B SentinelState C
|
|
34
|
+
| | |
|
|
35
|
+
Mutex A Mutex B Mutex C
|
|
36
|
+
| | |
|
|
37
|
+
Pool A Pool B Pool C
|
|
38
|
+
| | |
|
|
39
|
+
Socket A Socket B Socket C
|
|
40
|
+
| | |
|
|
41
|
+
+----------------+----------------+
|
|
42
|
+
|
|
|
43
|
+
Redis/Sentinel
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
`SolidRedis::SentinelConfig` contains only declarative values. It can therefore
|
|
47
|
+
cross a Ractor boundary. Its first use in each Ractor creates a
|
|
48
|
+
`SolidRedis::SentinelState` in `Ractor.current` storage. The state owns:
|
|
49
|
+
|
|
50
|
+
- the cached Redis target;
|
|
51
|
+
- the current Sentinel ordering;
|
|
52
|
+
- dynamically discovered Sentinels;
|
|
53
|
+
- temporary Sentinel clients;
|
|
54
|
+
- a mutex protecting threads in that Ractor.
|
|
55
|
+
|
|
56
|
+
Sentinel connections are opened only while resolving a target and are then
|
|
57
|
+
closed. Resolution is cached until `reset` or a connection/failover error.
|
|
58
|
+
|
|
59
|
+
## Requirements
|
|
60
|
+
|
|
61
|
+
Ruby **3.1 or newer** is required (tested on 3.1, 3.2, 3.3, 3.4, and 4.0).
|
|
62
|
+
Ruby 2.7 and 3.0 are not supported: the `Ractor` API this gem is built on
|
|
63
|
+
was introduced in 3.0, and the per-Ractor storage semantics are verified
|
|
64
|
+
from 3.1 onwards. Ruby >= 4.0 is recommended for workloads that run several
|
|
65
|
+
threads inside several Ractors (see the CRuby limitation below).
|
|
66
|
+
|
|
67
|
+
## Installation
|
|
68
|
+
|
|
69
|
+
```ruby
|
|
70
|
+
gem "solid-redis"
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
```sh
|
|
74
|
+
bundle install
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Direct Redis usage
|
|
78
|
+
|
|
79
|
+
```ruby
|
|
80
|
+
require "solid_redis"
|
|
81
|
+
|
|
82
|
+
config = SolidRedis.config(
|
|
83
|
+
url: "redis://localhost:6379/0",
|
|
84
|
+
timeout: 1.0,
|
|
85
|
+
reconnect_attempts: 1
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
client = config.new_client
|
|
89
|
+
client.call("SET", "answer", 42)
|
|
90
|
+
client.call("GET", "answer") # => "42"
|
|
91
|
+
client.close
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
The client supports TCP, Unix sockets, TLS, RESP2 and RESP3 response types,
|
|
95
|
+
AUTH, SELECT, CLIENT SETNAME, calls, vector calls, and pipelines:
|
|
96
|
+
|
|
97
|
+
```ruby
|
|
98
|
+
client.pipelined do |pipeline|
|
|
99
|
+
pipeline.call("SET", "first", 1)
|
|
100
|
+
pipeline.call("GET", "first")
|
|
101
|
+
end
|
|
102
|
+
# => ["OK", "1"]
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## Sentinel with Ractors
|
|
106
|
+
|
|
107
|
+
Create one immutable specification and pass it to every worker:
|
|
108
|
+
|
|
109
|
+
```ruby
|
|
110
|
+
SENTINEL = SolidRedis.sentinel(
|
|
111
|
+
name: "mymaster",
|
|
112
|
+
sentinels: [
|
|
113
|
+
{ host: "127.0.0.1", port: 26_380 },
|
|
114
|
+
{ host: "127.0.0.1", port: 26_381 }
|
|
115
|
+
],
|
|
116
|
+
role: :master,
|
|
117
|
+
timeout: 1.0
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
Ractor.shareable?(SENTINEL) # => true
|
|
121
|
+
|
|
122
|
+
workers = 4.times.map do
|
|
123
|
+
Ractor.new(SENTINEL) do |sentinel|
|
|
124
|
+
pool = sentinel.new_pool(size: 5)
|
|
125
|
+
pool.call("PING")
|
|
126
|
+
ensure
|
|
127
|
+
pool&.close
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
workers.map(&:value) # => ["PONG", "PONG", "PONG", "PONG"]
|
|
132
|
+
# On Ruby 3.x, use workers.map(&:take) instead.
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Each Ractor resolves `mymaster` independently on first use. A connection error
|
|
136
|
+
invalidates only that Ractor's cached target and causes its next connection
|
|
137
|
+
attempt to query Sentinel again.
|
|
138
|
+
|
|
139
|
+
For replicas, pass `role: :replica` (the `:slave` alias is accepted).
|
|
140
|
+
Unavailable replicas marked `s_down`, `o_down`, or `disconnected` are ignored.
|
|
141
|
+
|
|
142
|
+
Sentinel credentials and TLS are independent from Redis credentials and TLS:
|
|
143
|
+
|
|
144
|
+
```ruby
|
|
145
|
+
SolidRedis.sentinel(
|
|
146
|
+
name: "mymaster",
|
|
147
|
+
sentinels: ["rediss://sentinel-user:secret@sentinel.example:26379"],
|
|
148
|
+
username: "redis-user",
|
|
149
|
+
password: "redis-secret",
|
|
150
|
+
ssl: true,
|
|
151
|
+
ssl_params: { verify_mode: OpenSSL::SSL::VERIFY_PEER },
|
|
152
|
+
sentinel_ssl: true,
|
|
153
|
+
sentinel_ssl_params: { verify_mode: OpenSSL::SSL::VERIFY_PEER }
|
|
154
|
+
)
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
## Per-Ractor pool
|
|
158
|
+
|
|
159
|
+
`new_pool` creates a conventional thread-safe pool owned by the calling
|
|
160
|
+
Ractor:
|
|
161
|
+
|
|
162
|
+
```ruby
|
|
163
|
+
pool = SENTINEL.new_pool(size: 5, timeout: 1.0)
|
|
164
|
+
|
|
165
|
+
pool.with do |client|
|
|
166
|
+
client.call("INCR", "jobs")
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
pool.call("GET", "jobs")
|
|
170
|
+
pool.close
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
The pool may be shared by threads in its owning Ractor. It must not be sent to
|
|
174
|
+
another Ractor. Create a separate pool inside every Ractor, as in the example
|
|
175
|
+
above.
|
|
176
|
+
|
|
177
|
+
## Configuration
|
|
178
|
+
|
|
179
|
+
Direct Redis options:
|
|
180
|
+
|
|
181
|
+
| Option | Default | Description |
|
|
182
|
+
|---|---:|---|
|
|
183
|
+
| `url` | | `redis://`, `rediss://`, or `unix://` URL |
|
|
184
|
+
| `host` | `127.0.0.1` | Redis host |
|
|
185
|
+
| `port` | `6379` | Redis port |
|
|
186
|
+
| `path` | | Unix socket path |
|
|
187
|
+
| `username` | | ACL username |
|
|
188
|
+
| `password` | | Password |
|
|
189
|
+
| `db` | `0` | Database number |
|
|
190
|
+
| `timeout` | `1.0` | Default connect/read/write timeout |
|
|
191
|
+
| `connect_timeout` | `timeout` | Connection timeout |
|
|
192
|
+
| `read_timeout` | `timeout` | Read timeout |
|
|
193
|
+
| `write_timeout` | `timeout` | Write timeout |
|
|
194
|
+
| `reconnect_attempts` | `1` | Retries after a connection error |
|
|
195
|
+
| `ssl` | `false` | Enable TLS |
|
|
196
|
+
| `ssl_params` | | Immutable `OpenSSL::SSL::SSLContext` attributes |
|
|
197
|
+
|
|
198
|
+
Sentinel additionally requires `name` and `sentinels`, and accepts `role`,
|
|
199
|
+
`sentinel_username`, `sentinel_password`, `sentinel_ssl`, and
|
|
200
|
+
`sentinel_ssl_params`. Sentinel defaults to two reconnect attempts.
|
|
201
|
+
|
|
202
|
+
All configuration is copied and deeply frozen. Proc credentials and mutable
|
|
203
|
+
objects such as `OpenSSL::X509::Store` are rejected because Ruby cannot make
|
|
204
|
+
them Ractor-shareable. Prefer immutable values and paths in TLS parameters.
|
|
205
|
+
|
|
206
|
+
## Lifecycle callbacks
|
|
207
|
+
|
|
208
|
+
Pass a shareable `CallbackCollection` through `callbacks:` to observe
|
|
209
|
+
`connected`, `disconnected`, `connection_error`, and `resolved` events:
|
|
210
|
+
|
|
211
|
+
```ruby
|
|
212
|
+
module RedisEvents
|
|
213
|
+
def self.connected(url)
|
|
214
|
+
Logger.info("Connected to #{url}")
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def self.connection_error(type, message)
|
|
218
|
+
Logger.warn("#{type}: #{message}")
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
callbacks = CallbackCollection.new do |collection|
|
|
223
|
+
collection.register(:connected, RedisEvents)
|
|
224
|
+
collection.register(:connection_error, RedisEvents)
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
config = SolidRedis.sentinel(
|
|
228
|
+
name: "mymaster",
|
|
229
|
+
sentinels: [{ host: "127.0.0.1", port: 26_379 }],
|
|
230
|
+
callbacks: callbacks
|
|
231
|
+
)
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
| Callback | Arguments |
|
|
235
|
+
|---|---|
|
|
236
|
+
| `connected` | resolved server URL |
|
|
237
|
+
| `disconnected` | resolved server URL |
|
|
238
|
+
| `connection_error` | exception class name and message |
|
|
239
|
+
| `resolved` | Sentinel name and resolved server URL |
|
|
240
|
+
|
|
241
|
+
Method registration is required for a Ractor-shareable callback collection.
|
|
242
|
+
Block callbacks retain mutable lexical context and are therefore rejected.
|
|
243
|
+
Callback exceptions propagate to the caller.
|
|
244
|
+
|
|
245
|
+
## Semantics and current scope
|
|
246
|
+
|
|
247
|
+
- Clients, pools, sockets, mutexes, and Sentinel runtime state are never
|
|
248
|
+
shared between Ractors.
|
|
249
|
+
- Multiple threads in one Ractor share one protected Sentinel resolution.
|
|
250
|
+
- Redis command errors are never retried.
|
|
251
|
+
- Connection errors may retry a command according to `reconnect_attempts`.
|
|
252
|
+
Applications requiring strict at-most-once semantics should set it to `0`.
|
|
253
|
+
- A pool waits up to its checkout timeout and then raises
|
|
254
|
+
`SolidRedis::CheckoutTimeoutError`.
|
|
255
|
+
- Pub/Sub, transactions, blocking-call helpers, cluster routing, middleware,
|
|
256
|
+
and an asynchronous actor pool are not part of version `0.1`.
|
|
257
|
+
|
|
258
|
+
### Known CRuby limitation
|
|
259
|
+
|
|
260
|
+
Ractor support in CRuby is still experimental. On CRuby 3.4, running
|
|
261
|
+
**multiple threads inside multiple Ractors simultaneously** can hang
|
|
262
|
+
intermittently in the VM scheduler. This is reproducible with plain
|
|
263
|
+
`Thread` + CPU work in bare Ractors, without solid-redis, and is fixed
|
|
264
|
+
by the Ractor rewrite in CRuby >= 4.0 (verified by
|
|
265
|
+
`test/ractor_stress_test.rb`, which is skipped on older Rubies).
|
|
266
|
+
Safe patterns on CRuby 3.x:
|
|
267
|
+
|
|
268
|
+
- one thread per Ractor (the natural Ractor model), or
|
|
269
|
+
- multiple threads and a pool inside a single Ractor.
|
|
270
|
+
|
|
271
|
+
Both are fully supported by this gem.
|
|
272
|
+
|
|
273
|
+
An asynchronous `new_ractor_pool` would be a separate API: worker Ractors
|
|
274
|
+
would own connections and exchange commands/results through messages or
|
|
275
|
+
futures. It cannot transparently replace a block-based synchronous pool
|
|
276
|
+
without defining backpressure, cancellation, transaction, Pub/Sub, and
|
|
277
|
+
exception protocols.
|
|
278
|
+
|
|
279
|
+
## Development
|
|
280
|
+
|
|
281
|
+
```sh
|
|
282
|
+
bundle install
|
|
283
|
+
bundle exec rake
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
The default task runs the Minitest suite and builds the gem in `pkg/`.
|
|
287
|
+
CI runs the suite on Ruby 3.1 through 4.0.
|
|
288
|
+
|
|
289
|
+
## Publishing
|
|
290
|
+
|
|
291
|
+
Releases use RubyGems Trusted Publishing. Configure a pending trusted
|
|
292
|
+
publisher for `nicolasva/solid-redis`, workflow `release.yml`, environment
|
|
293
|
+
`release`, then push a tag matching `SolidRedis::VERSION`.
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "openssl"
|
|
4
|
+
require "socket"
|
|
5
|
+
|
|
6
|
+
module SolidRedis
|
|
7
|
+
class Client
|
|
8
|
+
attr_reader :config
|
|
9
|
+
|
|
10
|
+
def initialize(config, name: nil)
|
|
11
|
+
@config = config
|
|
12
|
+
@name = name&.to_s
|
|
13
|
+
@socket = nil
|
|
14
|
+
@reader = nil
|
|
15
|
+
@target = nil
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def call(*command)
|
|
19
|
+
call_v(command)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def call_v(command)
|
|
23
|
+
with_reconnect do
|
|
24
|
+
write(RESP.encode(command))
|
|
25
|
+
@reader.read
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def pipelined
|
|
30
|
+
pipeline = Pipeline.new
|
|
31
|
+
yield pipeline
|
|
32
|
+
return [] if pipeline.commands.empty?
|
|
33
|
+
|
|
34
|
+
with_reconnect do
|
|
35
|
+
write(pipeline.commands.map { |command| RESP.encode(command) }.join)
|
|
36
|
+
results = pipeline.commands.map { @reader.read(exception: false) }
|
|
37
|
+
raise results.find { |result| result.is_a?(CommandError) } if results.any?(CommandError)
|
|
38
|
+
|
|
39
|
+
results
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def connected?
|
|
44
|
+
@socket && !@socket.closed?
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def close
|
|
48
|
+
socket = @socket
|
|
49
|
+
target = @target
|
|
50
|
+
@socket = @reader = @target = nil
|
|
51
|
+
socket&.close
|
|
52
|
+
config.notify(:disconnected, target.server_url) if socket && target
|
|
53
|
+
self
|
|
54
|
+
rescue IOError
|
|
55
|
+
self
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def server_url
|
|
59
|
+
config.server_url
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def with_reconnect
|
|
65
|
+
attempts = 0
|
|
66
|
+
begin
|
|
67
|
+
connect unless connected?
|
|
68
|
+
yield
|
|
69
|
+
rescue ConnectionError, IO::WaitReadable, IO::WaitWritable, SystemCallError => error
|
|
70
|
+
close
|
|
71
|
+
config.reset if config.sentinel?
|
|
72
|
+
config.notify(:connection_error, error.class.name, error.message)
|
|
73
|
+
if attempts < config.reconnect_attempts
|
|
74
|
+
attempts += 1
|
|
75
|
+
retry
|
|
76
|
+
end
|
|
77
|
+
raise error if error.is_a?(Error)
|
|
78
|
+
|
|
79
|
+
raise ConnectionError, error.message, cause: error
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def connect
|
|
84
|
+
@target = config.sentinel? ? config.resolve : config
|
|
85
|
+
@socket = open_socket(@target)
|
|
86
|
+
@reader = RESP::Reader.new(@socket, read_timeout: @target.read_timeout)
|
|
87
|
+
authenticate
|
|
88
|
+
raw_call(["SELECT", @target.db]) unless @target.db.zero?
|
|
89
|
+
raw_call(["CLIENT", "SETNAME", @name]) if @name
|
|
90
|
+
verify_role if config.sentinel?
|
|
91
|
+
config.notify(:connected, @target.server_url)
|
|
92
|
+
self
|
|
93
|
+
rescue StandardError
|
|
94
|
+
close
|
|
95
|
+
raise
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def open_socket(target)
|
|
99
|
+
socket = if target.path
|
|
100
|
+
UNIXSocket.new(target.path)
|
|
101
|
+
else
|
|
102
|
+
Socket.tcp(target.host, target.port, connect_timeout: target.connect_timeout)
|
|
103
|
+
end
|
|
104
|
+
return socket unless target.ssl?
|
|
105
|
+
|
|
106
|
+
context = OpenSSL::SSL::SSLContext.new
|
|
107
|
+
apply_ssl_params(context, target.ssl_params || {})
|
|
108
|
+
ssl_socket = OpenSSL::SSL::SSLSocket.new(socket, context)
|
|
109
|
+
ssl_socket.hostname = target.host if ssl_socket.respond_to?(:hostname=)
|
|
110
|
+
ssl_socket.sync_close = true
|
|
111
|
+
ssl_connect(ssl_socket, target.connect_timeout)
|
|
112
|
+
ssl_socket
|
|
113
|
+
rescue TimeoutError
|
|
114
|
+
socket&.close
|
|
115
|
+
raise
|
|
116
|
+
rescue IOError, SystemCallError, SocketError, OpenSSL::SSL::SSLError => error
|
|
117
|
+
socket&.close
|
|
118
|
+
raise ConnectionError, error.message, cause: error
|
|
119
|
+
rescue ArgumentError
|
|
120
|
+
socket&.close
|
|
121
|
+
raise
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def ssl_connect(socket, timeout)
|
|
125
|
+
deadline = monotonic_time + timeout
|
|
126
|
+
loop do
|
|
127
|
+
result = socket.connect_nonblock(exception: false)
|
|
128
|
+
return if result == socket
|
|
129
|
+
|
|
130
|
+
remaining = deadline - monotonic_time
|
|
131
|
+
raise TimeoutError, "TLS handshake timed out after #{timeout}s" unless remaining.positive?
|
|
132
|
+
|
|
133
|
+
readers = result == :wait_readable ? [socket] : nil
|
|
134
|
+
writers = result == :wait_writable ? [socket] : nil
|
|
135
|
+
IO.select(readers, writers, nil, remaining)
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def apply_ssl_params(context, params)
|
|
140
|
+
params.each do |name, value|
|
|
141
|
+
writer = :"#{name}="
|
|
142
|
+
raise ArgumentError, "Unknown SSL context option: #{name.inspect}" unless context.respond_to?(writer)
|
|
143
|
+
|
|
144
|
+
context.public_send(writer, value)
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def authenticate
|
|
149
|
+
return unless @target.password
|
|
150
|
+
|
|
151
|
+
command = @target.username ? ["AUTH", @target.username, @target.password] : ["AUTH", @target.password]
|
|
152
|
+
raw_call(command)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def verify_role
|
|
156
|
+
actual = raw_call(["ROLE"]).first
|
|
157
|
+
expected = config.role == :master ? "master" : "slave"
|
|
158
|
+
return if actual == expected
|
|
159
|
+
|
|
160
|
+
raise FailoverError, "Expected Redis role #{expected.inspect}, got #{actual.inspect}"
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def raw_call(command)
|
|
164
|
+
write(RESP.encode(command))
|
|
165
|
+
@reader.read
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def write(payload)
|
|
169
|
+
offset = 0
|
|
170
|
+
while offset < payload.bytesize
|
|
171
|
+
unless IO.select(nil, [@socket], nil, @target.write_timeout)
|
|
172
|
+
raise TimeoutError, "Redis write timed out after #{@target.write_timeout}s"
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
written = @socket.write_nonblock(payload.byteslice(offset..), exception: false)
|
|
176
|
+
next if written == :wait_writable
|
|
177
|
+
|
|
178
|
+
offset += written
|
|
179
|
+
end
|
|
180
|
+
rescue IOError, SystemCallError => error
|
|
181
|
+
raise ConnectionError, error.message, cause: error
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def monotonic_time
|
|
185
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
class Pipeline
|
|
189
|
+
attr_reader :commands
|
|
190
|
+
|
|
191
|
+
def initialize
|
|
192
|
+
@commands = []
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def call(*command)
|
|
196
|
+
@commands << command
|
|
197
|
+
nil
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def call_v(command)
|
|
201
|
+
@commands << command
|
|
202
|
+
nil
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
end
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module SolidRedis
|
|
6
|
+
class Config
|
|
7
|
+
DEFAULT_TIMEOUT = 1.0
|
|
8
|
+
DEFAULT_PORT = 6379
|
|
9
|
+
|
|
10
|
+
attr_reader :host, :port, :path, :username, :password, :db,
|
|
11
|
+
:connect_timeout, :read_timeout, :write_timeout, :reconnect_attempts,
|
|
12
|
+
:ssl_params
|
|
13
|
+
|
|
14
|
+
def initialize(
|
|
15
|
+
url: nil,
|
|
16
|
+
host: "127.0.0.1",
|
|
17
|
+
port: DEFAULT_PORT,
|
|
18
|
+
path: nil,
|
|
19
|
+
username: nil,
|
|
20
|
+
password: nil,
|
|
21
|
+
db: 0,
|
|
22
|
+
timeout: DEFAULT_TIMEOUT,
|
|
23
|
+
connect_timeout: timeout,
|
|
24
|
+
read_timeout: timeout,
|
|
25
|
+
write_timeout: timeout,
|
|
26
|
+
reconnect_attempts: 1,
|
|
27
|
+
ssl: false,
|
|
28
|
+
ssl_params: nil,
|
|
29
|
+
callbacks: nil
|
|
30
|
+
)
|
|
31
|
+
if url
|
|
32
|
+
values = parse_url(url)
|
|
33
|
+
host = values[:host]
|
|
34
|
+
port = values[:port]
|
|
35
|
+
path = values[:path]
|
|
36
|
+
username ||= values[:username]
|
|
37
|
+
password ||= values[:password]
|
|
38
|
+
db = values[:db] unless values[:db].nil?
|
|
39
|
+
ssl ||= values[:ssl]
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
@host = String(host).dup.freeze unless path
|
|
43
|
+
@port = Integer(port) unless path
|
|
44
|
+
@path = String(path).dup.freeze if path
|
|
45
|
+
@username = String(username).dup.freeze if username
|
|
46
|
+
@password = String(password).dup.freeze if password
|
|
47
|
+
@db = Integer(db)
|
|
48
|
+
@connect_timeout = positive_float(connect_timeout, :connect_timeout)
|
|
49
|
+
@read_timeout = positive_float(read_timeout, :read_timeout)
|
|
50
|
+
@write_timeout = positive_float(write_timeout, :write_timeout)
|
|
51
|
+
@reconnect_attempts = Integer(reconnect_attempts)
|
|
52
|
+
raise ArgumentError, "reconnect_attempts must not be negative" if @reconnect_attempts.negative?
|
|
53
|
+
|
|
54
|
+
@ssl = !!ssl
|
|
55
|
+
@ssl_params = Shareable.copy(ssl_params, label: "ssl_params") if ssl_params
|
|
56
|
+
@callbacks = Shareable.copy(callbacks, label: "callbacks") if callbacks
|
|
57
|
+
Ractor.make_shareable(self)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def ssl?
|
|
61
|
+
@ssl
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def sentinel?
|
|
65
|
+
false
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def resolved?
|
|
69
|
+
true
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def server_key
|
|
73
|
+
[path, host, port].freeze
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def server_url
|
|
77
|
+
return "unix://#{path}?db=#{db}" if path
|
|
78
|
+
|
|
79
|
+
scheme = ssl? ? "rediss" : "redis"
|
|
80
|
+
address = host.include?(":") ? "[#{host}]" : host
|
|
81
|
+
"#{scheme}://#{address}:#{port}/#{db}"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def new_client(**options)
|
|
85
|
+
Client.new(self, **options)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def new_pool(**options)
|
|
89
|
+
Pool.new(self, **options)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def notify(event, *arguments)
|
|
93
|
+
return unless @callbacks&.respond_to?(event)
|
|
94
|
+
|
|
95
|
+
@callbacks.respond_with(event, *arguments)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def inspect
|
|
99
|
+
"#<#{self.class.name} #{server_url}>"
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
private
|
|
103
|
+
|
|
104
|
+
def parse_url(url)
|
|
105
|
+
uri = URI.parse(url)
|
|
106
|
+
unless %w[redis rediss unix].include?(uri.scheme)
|
|
107
|
+
raise ArgumentError, "Unsupported Redis URL scheme: #{uri.scheme.inspect}"
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
if uri.scheme == "unix"
|
|
111
|
+
{
|
|
112
|
+
path: uri.path,
|
|
113
|
+
username: decoded(uri.user),
|
|
114
|
+
password: decoded(uri.password),
|
|
115
|
+
db: query_db(uri),
|
|
116
|
+
ssl: false,
|
|
117
|
+
}
|
|
118
|
+
else
|
|
119
|
+
{
|
|
120
|
+
host: uri.host,
|
|
121
|
+
port: uri.port || DEFAULT_PORT,
|
|
122
|
+
username: decoded(uri.user),
|
|
123
|
+
password: decoded(uri.password),
|
|
124
|
+
db: path_db(uri) || query_db(uri),
|
|
125
|
+
ssl: uri.scheme == "rediss",
|
|
126
|
+
}
|
|
127
|
+
end
|
|
128
|
+
rescue URI::InvalidURIError => error
|
|
129
|
+
raise ArgumentError, "Invalid Redis URL: #{error.message}", cause: error
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def path_db(uri)
|
|
133
|
+
return if uri.path.nil? || uri.path.empty? || uri.path == "/"
|
|
134
|
+
|
|
135
|
+
Integer(uri.path.delete_prefix("/"))
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def query_db(uri)
|
|
139
|
+
return 0 unless uri.query
|
|
140
|
+
|
|
141
|
+
value = URI.decode_www_form(uri.query).to_h["db"]
|
|
142
|
+
value ? Integer(value) : 0
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def decoded(value)
|
|
146
|
+
URI.decode_www_form_component(value) if value
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def positive_float(value, name)
|
|
150
|
+
value = Float(value)
|
|
151
|
+
raise ArgumentError, "#{name} must be positive" unless value.positive?
|
|
152
|
+
|
|
153
|
+
value
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidRedis
|
|
4
|
+
class Error < StandardError; end
|
|
5
|
+
class ConnectionError < Error; end
|
|
6
|
+
class TimeoutError < ConnectionError; end
|
|
7
|
+
class ProtocolError < Error; end
|
|
8
|
+
class CommandError < Error; end
|
|
9
|
+
class AuthenticationError < CommandError; end
|
|
10
|
+
class FailoverError < ConnectionError; end
|
|
11
|
+
class CheckoutTimeoutError < TimeoutError; end
|
|
12
|
+
class ClosedError < Error; end
|
|
13
|
+
end
|