solid-redis 0.2.1 → 1.0.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 +4 -4
- data/README.md +126 -11
- data/lib/solid_redis/client.rb +28 -0
- data/lib/solid_redis/cluster/command_key.rb +52 -0
- data/lib/solid_redis/cluster/discover_service.rb +53 -0
- data/lib/solid_redis/cluster/key_slot.rb +42 -0
- data/lib/solid_redis/cluster_client.rb +140 -0
- data/lib/solid_redis/cluster_config.rb +106 -0
- data/lib/solid_redis/cluster_state.rb +103 -0
- data/lib/solid_redis/config.rb +4 -0
- data/lib/solid_redis/pool.rb +8 -0
- data/lib/solid_redis/resp.rb +23 -0
- data/lib/solid_redis/sentinel_config.rb +4 -0
- data/lib/solid_redis/subscription.rb +176 -0
- data/lib/solid_redis/version.rb +1 -1
- data/lib/solid_redis.rb +11 -0
- metadata +14 -5
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: aa178f3f726a1fe71f54af0aacebb6bb93890c86c4068e518acf1bbb7de16a67
|
|
4
|
+
data.tar.gz: 2b834a7e586b947cc39486bc0107614e7faa9db2aa3b3c91ab365c58db9b314e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: cc25f3b1e2ee529877e39f3da367ad966fcdeb35283732f7ad2d144ce4d78b8c3e1476203f6910525e80bceb7cf6a9f3bc7916c4dc1ac401ea9b97130f80937e
|
|
7
|
+
data.tar.gz: cd5472d06a641f06bfe1a13d7bfd50b530bfc8126ce07cfb34484f19ae3842da24dedc36e51863225603861b0b23057b923164db817087667d91f86caa1aece9
|
data/README.md
CHANGED
|
@@ -6,17 +6,20 @@
|
|
|
6
6
|
[](https://www.rubydoc.info/gems/solid-redis)
|
|
7
7
|
|
|
8
8
|
`solid-redis` is a dependency-free Redis client designed around Ractor
|
|
9
|
-
isolation. Its Redis and
|
|
10
|
-
every Ractor creates and retains its own resolution state,
|
|
11
|
-
clients, and sockets.
|
|
9
|
+
isolation. Its Redis, Sentinel, and Cluster specifications are immutable and
|
|
10
|
+
shareable; every Ractor creates and retains its own resolution state, slot
|
|
11
|
+
table, mutex, pool, clients, and sockets.
|
|
12
12
|
|
|
13
|
-
It
|
|
13
|
+
It supports standalone Redis, Sentinel failover, Redis Cluster routing,
|
|
14
|
+
pipelines, blocking commands, and Pub/Sub. It does not depend on or patch
|
|
15
|
+
`redis-client`.
|
|
14
16
|
|
|
15
17
|
The implementation intentionally composes two small gems from the same
|
|
16
18
|
author:
|
|
17
19
|
|
|
18
20
|
- [`base-service`](https://github.com/nicolasva/base-service) executes each
|
|
19
|
-
Sentinel resolution and
|
|
21
|
+
Sentinel resolution and Cluster topology discovery and returns its immutable
|
|
22
|
+
result and endpoint errors;
|
|
20
23
|
- [`callback-collection`](https://github.com/nicolasva/callback-collection)
|
|
21
24
|
provides immutable lifecycle event handlers.
|
|
22
25
|
|
|
@@ -175,6 +178,109 @@ The pool may be shared by threads in its owning Ractor. It must not be sent to
|
|
|
175
178
|
another Ractor. Create a separate pool inside every Ractor, as in the example
|
|
176
179
|
above.
|
|
177
180
|
|
|
181
|
+
## Blocking commands
|
|
182
|
+
|
|
183
|
+
`blocking_call(timeout, *command)` runs BLPOP, BRPOP, BZPOPMIN, XREAD BLOCK
|
|
184
|
+
and similar commands. The socket read timeout becomes `timeout` plus the
|
|
185
|
+
configured `read_timeout`; pass `nil` or `0` when Redis blocks indefinitely.
|
|
186
|
+
|
|
187
|
+
```ruby
|
|
188
|
+
pool.blocking_call(5, "BLPOP", "jobs", 5) # => ["jobs", "payload"] or nil
|
|
189
|
+
pool.blocking_call(nil, "BLPOP", "jobs", 0) # waits forever
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
A blocking command is never retried after a connection error, because the
|
|
193
|
+
element may already have been consumed. While it waits, its connection stays
|
|
194
|
+
checked out: size pools with room for concurrent blocking waiters plus regular
|
|
195
|
+
traffic, and keep the pool `timeout` short so other threads fail fast instead
|
|
196
|
+
of freezing.
|
|
197
|
+
|
|
198
|
+
## Pub/Sub
|
|
199
|
+
|
|
200
|
+
`new_subscription` opens a dedicated connection that belongs to the calling
|
|
201
|
+
Ractor and is never taken from a pool.
|
|
202
|
+
|
|
203
|
+
```ruby
|
|
204
|
+
subscription = config.new_subscription
|
|
205
|
+
subscription.subscribe("events").psubscribe("alerts.*")
|
|
206
|
+
|
|
207
|
+
subscription.each_message(timeout: 1.0) do |message|
|
|
208
|
+
next if message.nil? # timeout elapsed: check a stop flag here
|
|
209
|
+
next unless message.message? # skip subscribe/unsubscribe/pong events
|
|
210
|
+
|
|
211
|
+
puts "#{message.channel}: #{message.payload}"
|
|
212
|
+
end
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
`Message` is a frozen struct with `type` (`:message`, `:pmessage`,
|
|
216
|
+
`:smessage`, `:subscribe`, `:psubscribe`, `:ssubscribe`, `:unsubscribe`,
|
|
217
|
+
`:punsubscribe`, `:sunsubscribe`, `:pong`), `channel`, `pattern`, and
|
|
218
|
+
`payload`. `next_message(timeout:)` returns one message or `nil`; `ping`
|
|
219
|
+
sends a keepalive answered by a `:pong` message. Sharded channels use
|
|
220
|
+
`ssubscribe`/`sunsubscribe`.
|
|
221
|
+
|
|
222
|
+
After a connection loss the subscription reconnects according to
|
|
223
|
+
`reconnect_attempts` and re-issues its tracked channels and patterns; the
|
|
224
|
+
confirmations flow back as `:subscribe`/`:psubscribe` messages. Regular
|
|
225
|
+
commands raise `SolidRedis::Error` on a subscription.
|
|
226
|
+
|
|
227
|
+
The natural Ractor pattern is one listener Ractor fanning out plain Strings to
|
|
228
|
+
workers:
|
|
229
|
+
|
|
230
|
+
```ruby
|
|
231
|
+
port = Ractor::Port.new # Ruby >= 4.0; use Ractor.yield/take on 3.x
|
|
232
|
+
|
|
233
|
+
listener = Ractor.new(CONFIG, port) do |config, port|
|
|
234
|
+
subscription = config.new_subscription
|
|
235
|
+
subscription.subscribe("events")
|
|
236
|
+
subscription.each_message do |message|
|
|
237
|
+
port << [message.channel, message.payload].freeze if message&.message?
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
loop do
|
|
242
|
+
channel, payload = port.receive
|
|
243
|
+
# dispatch to application Ractors
|
|
244
|
+
end
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
## Redis Cluster
|
|
248
|
+
|
|
249
|
+
`SolidRedis.cluster` builds an immutable, shareable specification from seed
|
|
250
|
+
nodes. Each Ractor discovers the slot table with `CLUSTER SLOTS` on first use
|
|
251
|
+
and keeps it, together with one connection per node, in Ractor-local state.
|
|
252
|
+
|
|
253
|
+
```ruby
|
|
254
|
+
CLUSTER = SolidRedis.cluster(
|
|
255
|
+
nodes: ["redis://10.0.0.1:7000", "10.0.0.2:7000"],
|
|
256
|
+
password: ENV["REDIS_PASSWORD"],
|
|
257
|
+
timeout: 1.0,
|
|
258
|
+
max_redirections: 5
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
Ractor.shareable?(CLUSTER) # => true
|
|
262
|
+
|
|
263
|
+
client = CLUSTER.new_client
|
|
264
|
+
client.call("SET", "user:1", "Ada")
|
|
265
|
+
client.call("GET", "user:1")
|
|
266
|
+
client.call("MGET", "{user:1}.name", "{user:1}.email") # same slot via hash tag
|
|
267
|
+
|
|
268
|
+
client.pipelined do |pipeline|
|
|
269
|
+
pipeline.call("GET", "a") # commands are grouped per node,
|
|
270
|
+
pipeline.call("GET", "b") # sent in parallel pipelines,
|
|
271
|
+
pipeline.call("PING") # and results come back in order
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
pool = CLUSTER.new_pool(size: 5) # a pool of cluster clients
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
Routing uses CRC16 hash slots with `{hash tag}` support and knows the key
|
|
278
|
+
position of EVAL/FCALL, XREAD/XREADGROUP, ZUNION-style and keyless commands.
|
|
279
|
+
`MOVED` updates the local slot table; `ASK` is followed once with `ASKING`;
|
|
280
|
+
`TRYAGAIN`/`CLUSTERDOWN` trigger a topology refresh. `CROSSSLOT` errors from
|
|
281
|
+
Redis are raised as `SolidRedis::CommandError`. Cluster only supports database
|
|
282
|
+
`0`; `blocking_call` is routed like any other command.
|
|
283
|
+
|
|
178
284
|
## Configuration
|
|
179
285
|
|
|
180
286
|
Direct Redis options:
|
|
@@ -200,6 +306,10 @@ Sentinel additionally requires `name` and `sentinels`, and accepts `role`,
|
|
|
200
306
|
`sentinel_username`, `sentinel_password`, `sentinel_ssl`, and
|
|
201
307
|
`sentinel_ssl_params`. Sentinel defaults to two reconnect attempts.
|
|
202
308
|
|
|
309
|
+
Cluster requires `nodes` (host:port strings, `redis://` URLs, or hashes) and
|
|
310
|
+
accepts `max_redirections` (default `5`) plus the direct Redis options above
|
|
311
|
+
except `db`, which must be `0`.
|
|
312
|
+
|
|
203
313
|
All configuration is copied and deeply frozen. Proc credentials and mutable
|
|
204
314
|
objects such as `OpenSSL::X509::Store` are rejected because Ruby cannot make
|
|
205
315
|
them Ractor-shareable. Prefer immutable values and paths in TLS parameters.
|
|
@@ -237,7 +347,7 @@ config = SolidRedis.sentinel(
|
|
|
237
347
|
| `connected` | resolved server URL |
|
|
238
348
|
| `disconnected` | resolved server URL |
|
|
239
349
|
| `connection_error` | exception class name and message |
|
|
240
|
-
| `resolved` | Sentinel name and resolved server URL |
|
|
350
|
+
| `resolved` | Sentinel name and resolved server URL, or `"cluster"` and the node list |
|
|
241
351
|
|
|
242
352
|
Method registration is required for a Ractor-shareable callback collection.
|
|
243
353
|
Block callbacks retain mutable lexical context and are therefore rejected.
|
|
@@ -426,16 +536,21 @@ SolidRedis.config(
|
|
|
426
536
|
|
|
427
537
|
## Semantics and current scope
|
|
428
538
|
|
|
429
|
-
- Clients, pools, sockets, mutexes,
|
|
430
|
-
shared between Ractors.
|
|
431
|
-
- Multiple threads in one Ractor share one protected Sentinel resolution
|
|
539
|
+
- Clients, pools, sockets, mutexes, Sentinel runtime state, and Cluster slot
|
|
540
|
+
tables are never shared between Ractors.
|
|
541
|
+
- Multiple threads in one Ractor share one protected Sentinel resolution or
|
|
542
|
+
Cluster topology.
|
|
432
543
|
- Redis command errors are never retried.
|
|
433
544
|
- Connection errors may retry a command according to `reconnect_attempts`.
|
|
434
545
|
Applications requiring strict at-most-once semantics should set it to `0`.
|
|
546
|
+
Blocking commands and Pub/Sub never replay a command.
|
|
435
547
|
- A pool waits up to its checkout timeout and then raises
|
|
436
548
|
`SolidRedis::CheckoutTimeoutError`.
|
|
437
|
-
-
|
|
438
|
-
|
|
549
|
+
- Cluster clients follow up to `max_redirections` MOVED/ASK redirections and
|
|
550
|
+
refresh the topology on TRYAGAIN/CLUSTERDOWN, then raise
|
|
551
|
+
`SolidRedis::FailoverError`.
|
|
552
|
+
- Transactions helpers (MULTI/EXEC/WATCH), Cluster replica reads, middleware,
|
|
553
|
+
and an asynchronous actor pool are not part of version `1.0`.
|
|
439
554
|
|
|
440
555
|
### Known CRuby limitation
|
|
441
556
|
|
data/lib/solid_redis/client.rb
CHANGED
|
@@ -26,6 +26,34 @@ module SolidRedis
|
|
|
26
26
|
end
|
|
27
27
|
end
|
|
28
28
|
|
|
29
|
+
# Runs a blocking command such as BLPOP, BRPOP, BZPOPMIN or XREAD BLOCK.
|
|
30
|
+
#
|
|
31
|
+
# +timeout+ is the number of seconds Redis was asked to block for; the
|
|
32
|
+
# socket read timeout becomes +timeout+ plus the configured read timeout.
|
|
33
|
+
# Pass +nil+ or +0+ when Redis blocks indefinitely: the read then waits
|
|
34
|
+
# forever. The command is never retried after a connection error because
|
|
35
|
+
# the element may already have been consumed.
|
|
36
|
+
def blocking_call(timeout, *command)
|
|
37
|
+
blocking_call_v(timeout, command)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def blocking_call_v(timeout, command)
|
|
41
|
+
with_reconnect { connect unless connected? }
|
|
42
|
+
|
|
43
|
+
read_timeout = timeout && timeout.positive? ? timeout + @target.read_timeout : nil
|
|
44
|
+
begin
|
|
45
|
+
write(RESP.encode(command))
|
|
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)
|
|
51
|
+
raise error if error.is_a?(Error)
|
|
52
|
+
|
|
53
|
+
raise ConnectionError, error.message, cause: error
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
29
57
|
def pipelined(exception: true)
|
|
30
58
|
pipeline = Pipeline.new
|
|
31
59
|
yield pipeline
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidRedis
|
|
4
|
+
module Cluster
|
|
5
|
+
# Extracts the routing key of a command. Most Redis commands carry their
|
|
6
|
+
# first key as the second argument; the exceptions are listed here.
|
|
7
|
+
module CommandKey
|
|
8
|
+
KEYLESS = Ractor.make_shareable(
|
|
9
|
+
%w[
|
|
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
|
|
12
|
+
PSUBSCRIBE PUBLISH PUBSUB PUNSUBSCRIBE QUIT RANDOMKEY READONLY READWRITE REPLICAOF RESET ROLE SAVE
|
|
13
|
+
SCAN SCRIPT SELECT SHUTDOWN SLAVEOF SLOWLOG SUBSCRIBE SWAPDB SYNC TIME UNSUBSCRIBE UNWATCH WAIT
|
|
14
|
+
].to_h { |name| [name, true] },
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
# Commands whose keys are announced by a "numkeys" argument.
|
|
18
|
+
NUMKEYS_AT = Ractor.make_shareable({
|
|
19
|
+
"EVAL" => 2, "EVALSHA" => 2, "EVAL_RO" => 2, "EVALSHA_RO" => 2,
|
|
20
|
+
"FCALL" => 2, "FCALL_RO" => 2, "ZUNIONSTORE" => 2, "ZINTERSTORE" => 2, "ZDIFFSTORE" => 2,
|
|
21
|
+
"ZUNION" => 1, "ZINTER" => 1, "ZDIFF" => 1, "SINTERCARD" => 1, "ZINTERCARD" => 1, "LMPOP" => 1, "ZMPOP" => 1,
|
|
22
|
+
"BLMPOP" => 2, "BZMPOP" => 2,
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
STREAMS_COMMANDS = Ractor.make_shareable({ "XREAD" => true, "XREADGROUP" => true })
|
|
26
|
+
|
|
27
|
+
module_function
|
|
28
|
+
|
|
29
|
+
# Returns the first key of +command+, or nil for keyless commands.
|
|
30
|
+
def for(command)
|
|
31
|
+
name = command[0].to_s.upcase
|
|
32
|
+
return if KEYLESS[name]
|
|
33
|
+
|
|
34
|
+
if (index = NUMKEYS_AT[name])
|
|
35
|
+
count = Integer(command[index], exception: false) || 0
|
|
36
|
+
return count.positive? ? command[index + 1] : nil
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
if STREAMS_COMMANDS[name]
|
|
40
|
+
streams = command.index { |argument| argument.to_s.casecmp?("STREAMS") }
|
|
41
|
+
return streams && command[streams + 1]
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
case name
|
|
45
|
+
when "XGROUP", "XINFO", "OBJECT" then command[2]
|
|
46
|
+
when "MIGRATE" then command[3].to_s.empty? ? nil : command[3]
|
|
47
|
+
else command[1]
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidRedis
|
|
4
|
+
module Cluster
|
|
5
|
+
# Queries CLUSTER SLOTS on the first reachable node and returns the slot
|
|
6
|
+
# table as a list of ranges:
|
|
7
|
+
#
|
|
8
|
+
# { ranges: [{ from:, to:, master: { host:, port: }, replicas: [...] }], endpoint: }
|
|
9
|
+
class DiscoverService < Service::Base
|
|
10
|
+
def call
|
|
11
|
+
@endpoints.each do |endpoint|
|
|
12
|
+
client = Config.new(**@specification.redis_client_options, **endpoint).new_client
|
|
13
|
+
ranges = client.call("CLUSTER", "SLOTS").map { |entry| range(entry, endpoint) }
|
|
14
|
+
if ranges.empty?
|
|
15
|
+
append_error(:empty_topology, "#{endpoint_label(endpoint)} returned no slots")
|
|
16
|
+
next
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
return { ranges: ranges, endpoint: endpoint }
|
|
20
|
+
rescue SolidRedis::Error, KeyError, ArgumentError, TypeError => error
|
|
21
|
+
append_error(:node_unavailable, "#{endpoint_label(endpoint)}: #{error.message}")
|
|
22
|
+
ensure
|
|
23
|
+
client&.close
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
nil
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def range(entry, endpoint)
|
|
32
|
+
from, to, master, *replicas = entry
|
|
33
|
+
{
|
|
34
|
+
from: Integer(from),
|
|
35
|
+
to: Integer(to),
|
|
36
|
+
master: node(master, endpoint),
|
|
37
|
+
replicas: replicas.map { |replica| node(replica, endpoint) },
|
|
38
|
+
}
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# A node may announce an empty host, meaning "the address you used".
|
|
42
|
+
def node(entry, endpoint)
|
|
43
|
+
host, port = entry
|
|
44
|
+
host = endpoint[:host] if host.nil? || host.to_s.empty?
|
|
45
|
+
{ host: host, port: Integer(port) }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def endpoint_label(endpoint)
|
|
49
|
+
"#{endpoint[:host]}:#{endpoint[:port]}"
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidRedis
|
|
4
|
+
module Cluster
|
|
5
|
+
# Maps a Redis key to one of the 16384 cluster hash slots using CRC16
|
|
6
|
+
# (XMODEM) and the +{hash tag}+ rule from the Redis Cluster specification.
|
|
7
|
+
module KeySlot
|
|
8
|
+
SLOTS = 16_384
|
|
9
|
+
|
|
10
|
+
# CRC16 XMODEM lookup table (polynomial 0x1021).
|
|
11
|
+
TABLE = Ractor.make_shareable(
|
|
12
|
+
Array.new(256) do |byte|
|
|
13
|
+
crc = byte << 8
|
|
14
|
+
8.times { crc = (crc & 0x8000).zero? ? (crc << 1) & 0xFFFF : ((crc << 1) ^ 0x1021) & 0xFFFF }
|
|
15
|
+
crc
|
|
16
|
+
end,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
def for(key)
|
|
22
|
+
crc16(hash_tag(key.to_s)) % SLOTS
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def hash_tag(key)
|
|
26
|
+
open = key.index("{")
|
|
27
|
+
return key unless open
|
|
28
|
+
|
|
29
|
+
close = key.index("}", open + 1)
|
|
30
|
+
return key unless close && close > open + 1
|
|
31
|
+
|
|
32
|
+
key[(open + 1)...close]
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def crc16(string)
|
|
36
|
+
string.each_byte.reduce(0) do |crc, byte|
|
|
37
|
+
((crc << 8) & 0xFFFF) ^ TABLE[((crc >> 8) ^ byte) & 0xFF]
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidRedis
|
|
4
|
+
# Routes commands to the cluster node owning their key slot and follows
|
|
5
|
+
# MOVED/ASK redirections. Holds one Client per node; all of them belong to
|
|
6
|
+
# the Ractor that created this object.
|
|
7
|
+
class ClusterClient
|
|
8
|
+
REDIRECTION = /\A(MOVED|ASK) (\d+) (\S+):(\d+)\z/
|
|
9
|
+
RETRY_DELAY = 0.05
|
|
10
|
+
|
|
11
|
+
attr_reader :config
|
|
12
|
+
|
|
13
|
+
def initialize(config, name: nil)
|
|
14
|
+
@config = config
|
|
15
|
+
@name = name
|
|
16
|
+
@clients = {}
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def call(*command)
|
|
20
|
+
call_v(command)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def call_v(command)
|
|
24
|
+
route(command) { |client| client.call_v(command) }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def blocking_call(timeout, *command)
|
|
28
|
+
blocking_call_v(timeout, command)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def blocking_call_v(timeout, command)
|
|
32
|
+
route(command, retry_connection: false) { |client| client.blocking_call_v(timeout, command) }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Groups commands by node, runs one pipeline per node and restores the
|
|
36
|
+
# original order. Redirected commands are replayed individually.
|
|
37
|
+
def pipelined(exception: true)
|
|
38
|
+
pipeline = Client::Pipeline.new
|
|
39
|
+
yield pipeline
|
|
40
|
+
commands = pipeline.commands
|
|
41
|
+
return [] if commands.empty?
|
|
42
|
+
|
|
43
|
+
results = Array.new(commands.length)
|
|
44
|
+
commands.each_with_index.group_by { |command, _| node_for(command) }.each do |node, group|
|
|
45
|
+
replies = client_for(node).pipelined(exception: false) do |batch|
|
|
46
|
+
group.each { |command, _| batch.call_v(command) }
|
|
47
|
+
end
|
|
48
|
+
group.each_with_index { |(_, index), position| results[index] = replies[position] }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
results.each_with_index do |result, index|
|
|
52
|
+
next unless result.is_a?(CommandError) && result.message.match?(REDIRECTION)
|
|
53
|
+
|
|
54
|
+
results[index] = begin
|
|
55
|
+
call_v(commands[index])
|
|
56
|
+
rescue CommandError => error
|
|
57
|
+
error
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
if exception && (error = results.find { |result| result.is_a?(CommandError) })
|
|
62
|
+
raise error
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
results
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def connected?
|
|
69
|
+
@clients.values.any?(&:connected?)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def close
|
|
73
|
+
clients = @clients.values
|
|
74
|
+
@clients = {}
|
|
75
|
+
clients.each(&:close)
|
|
76
|
+
self
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def server_url
|
|
80
|
+
@clients.keys.join(",")
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
private
|
|
84
|
+
|
|
85
|
+
def route(command, retry_connection: true)
|
|
86
|
+
node = node_for(command)
|
|
87
|
+
redirections = 0
|
|
88
|
+
attempts = 0
|
|
89
|
+
asking = false
|
|
90
|
+
|
|
91
|
+
loop do
|
|
92
|
+
client = client_for(node)
|
|
93
|
+
client.call_v(["ASKING"]) if asking
|
|
94
|
+
return yield(client)
|
|
95
|
+
rescue CommandError => error
|
|
96
|
+
redirection = error.message.match(REDIRECTION)
|
|
97
|
+
if redirection
|
|
98
|
+
redirections += 1
|
|
99
|
+
raise FailoverError, "Too many cluster redirections: #{error.message}" if redirections > config.max_redirections
|
|
100
|
+
|
|
101
|
+
slot = Integer(redirection[2])
|
|
102
|
+
host = redirection[3]
|
|
103
|
+
port = Integer(redirection[4])
|
|
104
|
+
asking = redirection[1] == "ASK"
|
|
105
|
+
node = asking ? config.state.node(host, port) : config.state.move(slot, host, port)
|
|
106
|
+
elsif error.message.start_with?("TRYAGAIN", "CLUSTERDOWN")
|
|
107
|
+
redirections += 1
|
|
108
|
+
raise FailoverError, "Cluster unavailable: #{error.message}" if redirections > config.max_redirections
|
|
109
|
+
|
|
110
|
+
sleep RETRY_DELAY
|
|
111
|
+
config.state.refresh
|
|
112
|
+
node = node_for(command)
|
|
113
|
+
else
|
|
114
|
+
raise
|
|
115
|
+
end
|
|
116
|
+
rescue ConnectionError => error
|
|
117
|
+
drop(node)
|
|
118
|
+
raise unless retry_connection && attempts < config.reconnect_attempts
|
|
119
|
+
|
|
120
|
+
attempts += 1
|
|
121
|
+
config.notify(:connection_error, error.class.name, error.message)
|
|
122
|
+
config.state.refresh
|
|
123
|
+
node = node_for(command)
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def node_for(command)
|
|
128
|
+
key = Cluster::CommandKey.for(command)
|
|
129
|
+
key ? config.state.node_for_slot(Cluster::KeySlot.for(key)) : config.state.any_node
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def client_for(node)
|
|
133
|
+
@clients[node.server_key] ||= node.new_client(name: @name)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def drop(node)
|
|
137
|
+
@clients.delete(node.server_key)&.close
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidRedis
|
|
4
|
+
# Immutable, Ractor-shareable description of a Redis Cluster: seed nodes and
|
|
5
|
+
# per-node client options. Slot tables, node clients and sockets live in a
|
|
6
|
+
# per-Ractor ClusterState, exactly like SentinelConfig/SentinelState.
|
|
7
|
+
class ClusterConfig
|
|
8
|
+
LOCAL_STATE_KEY = :solid_redis_cluster_states
|
|
9
|
+
DEFAULT_PORT = 6379
|
|
10
|
+
DEFAULT_MAX_REDIRECTIONS = 5
|
|
11
|
+
|
|
12
|
+
attr_reader :node_endpoints, :redis_client_options, :reconnect_attempts, :max_redirections
|
|
13
|
+
|
|
14
|
+
def initialize(nodes:, max_redirections: DEFAULT_MAX_REDIRECTIONS, **redis_options)
|
|
15
|
+
endpoints = Array(nodes).map { |endpoint| normalize_endpoint(endpoint) }
|
|
16
|
+
@node_endpoints = Shareable.copy(endpoints, label: "nodes")
|
|
17
|
+
raise ArgumentError, "At least one cluster node is required" if @node_endpoints.empty?
|
|
18
|
+
|
|
19
|
+
@max_redirections = Integer(max_redirections)
|
|
20
|
+
raise ArgumentError, "max_redirections must not be negative" if @max_redirections.negative?
|
|
21
|
+
|
|
22
|
+
@reconnect_attempts = Integer(redis_options.fetch(:reconnect_attempts, 1))
|
|
23
|
+
raise ArgumentError, "reconnect_attempts must not be negative" if @reconnect_attempts.negative?
|
|
24
|
+
raise ArgumentError, "Redis Cluster only supports database 0" if redis_options.fetch(:db, 0) != 0
|
|
25
|
+
|
|
26
|
+
@redis_client_options = Shareable.copy(redis_options, label: "Redis options")
|
|
27
|
+
Ractor.make_shareable(self)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def sentinel?
|
|
31
|
+
false
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def cluster?
|
|
35
|
+
true
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def discovered?
|
|
39
|
+
state.discovered?
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def nodes
|
|
43
|
+
state.nodes
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def reset
|
|
47
|
+
state.reset
|
|
48
|
+
self
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def refresh
|
|
52
|
+
state.refresh
|
|
53
|
+
self
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def new_client(**options)
|
|
57
|
+
ClusterClient.new(self, **options)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def new_pool(**options)
|
|
61
|
+
Pool.new(self, **options)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def notify(event, *arguments)
|
|
65
|
+
callbacks = redis_client_options[:callbacks]
|
|
66
|
+
return unless callbacks&.respond_to?(event)
|
|
67
|
+
|
|
68
|
+
callbacks.respond_with(event, *arguments)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def inspect
|
|
72
|
+
"#<#{self.class.name} nodes=#{node_endpoints.length}>"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Per-Ractor runtime state, created on first use in each Ractor.
|
|
76
|
+
def state
|
|
77
|
+
registry = Ractor.current[LOCAL_STATE_KEY]
|
|
78
|
+
unless registry
|
|
79
|
+
registry = { mutex: Mutex.new, states: {} }
|
|
80
|
+
Ractor.current[LOCAL_STATE_KEY] = registry
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
registry[:mutex].synchronize do
|
|
84
|
+
registry[:states][self] ||= ClusterState.new(self)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
private
|
|
89
|
+
|
|
90
|
+
def normalize_endpoint(endpoint)
|
|
91
|
+
unless endpoint.is_a?(String)
|
|
92
|
+
endpoint = endpoint.transform_keys(&:to_sym)
|
|
93
|
+
return { host: endpoint.fetch(:host), port: Integer(endpoint.fetch(:port, DEFAULT_PORT)) }
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
uri = URI.parse(endpoint.include?("://") ? endpoint : "redis://#{endpoint}")
|
|
97
|
+
unless %w[redis rediss].include?(uri.scheme)
|
|
98
|
+
raise ArgumentError, "Unsupported cluster URL scheme: #{uri.scheme.inspect}"
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
{ host: uri.host || "127.0.0.1", port: uri.port || DEFAULT_PORT }
|
|
102
|
+
rescue URI::InvalidURIError => error
|
|
103
|
+
raise ArgumentError, "Invalid cluster node: #{error.message}", cause: error
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidRedis
|
|
4
|
+
# Per-Ractor Cluster runtime: the slot table, the node configurations and
|
|
5
|
+
# the mutex protecting them. Never shared between Ractors.
|
|
6
|
+
class ClusterState
|
|
7
|
+
def initialize(specification)
|
|
8
|
+
@specification = specification
|
|
9
|
+
@mutex = Mutex.new
|
|
10
|
+
@slots = Array.new(Cluster::KeySlot::SLOTS)
|
|
11
|
+
@nodes = {}
|
|
12
|
+
@discovered = false
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def discovered?
|
|
16
|
+
@mutex.synchronize { @discovered }
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Returns the master Config owning +slot+, discovering the topology when
|
|
20
|
+
# it is not known yet.
|
|
21
|
+
def node_for_slot(slot)
|
|
22
|
+
@mutex.synchronize do
|
|
23
|
+
discover unless @discovered
|
|
24
|
+
key = @slots[slot]
|
|
25
|
+
if key.nil?
|
|
26
|
+
discover
|
|
27
|
+
key = @slots[slot]
|
|
28
|
+
end
|
|
29
|
+
@nodes[key] || raise(ConnectionError, "No cluster node serves slot #{slot}")
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def nodes
|
|
34
|
+
@mutex.synchronize do
|
|
35
|
+
discover unless @discovered
|
|
36
|
+
@nodes.values
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def any_node
|
|
41
|
+
nodes.first
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Applies a MOVED redirection locally without a full topology refresh.
|
|
45
|
+
def move(slot, host, port)
|
|
46
|
+
@mutex.synchronize do
|
|
47
|
+
key = node_key(host, port)
|
|
48
|
+
@nodes[key] ||= node_config(host, port)
|
|
49
|
+
@slots[slot] = key
|
|
50
|
+
@nodes[key]
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def node(host, port)
|
|
55
|
+
@mutex.synchronize { @nodes[node_key(host, port)] ||= node_config(host, port) }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def reset
|
|
59
|
+
@mutex.synchronize do
|
|
60
|
+
@discovered = false
|
|
61
|
+
@slots.fill(nil)
|
|
62
|
+
@nodes.clear
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def refresh
|
|
67
|
+
@mutex.synchronize { discover }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
# Must be called with the mutex held.
|
|
73
|
+
def discover
|
|
74
|
+
endpoints = @nodes.values.map { |config| { host: config.host, port: config.port } }
|
|
75
|
+
endpoints |= @specification.node_endpoints
|
|
76
|
+
result = Cluster::DiscoverService.call(specification: @specification, endpoints: endpoints)
|
|
77
|
+
unless result.successful? && result.result
|
|
78
|
+
details = result.errors.map(&:message).join("; ")
|
|
79
|
+
raise ConnectionError, "No cluster node reachable: #{details}"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
@slots.fill(nil)
|
|
83
|
+
@nodes.clear
|
|
84
|
+
result.result[:ranges].each do |range|
|
|
85
|
+
key = node_key(range[:master][:host], range[:master][:port])
|
|
86
|
+
@nodes[key] ||= node_config(range[:master][:host], range[:master][:port])
|
|
87
|
+
(range[:from]..range[:to]).each { |slot| @slots[slot] = key }
|
|
88
|
+
end
|
|
89
|
+
@discovered = true
|
|
90
|
+
@specification.notify(:resolved, "cluster", @nodes.keys.join(","))
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def node_key(host, port)
|
|
94
|
+
"#{host}:#{port}"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Node clients never retry on their own: ClusterClient owns the retry so
|
|
98
|
+
# that a topology refresh happens between attempts.
|
|
99
|
+
def node_config(host, port)
|
|
100
|
+
Config.new(**@specification.redis_client_options, host: host, port: port, reconnect_attempts: 0)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
data/lib/solid_redis/config.rb
CHANGED
data/lib/solid_redis/pool.rb
CHANGED
|
@@ -37,6 +37,14 @@ module SolidRedis
|
|
|
37
37
|
with { |client| client.call_v(command) }
|
|
38
38
|
end
|
|
39
39
|
|
|
40
|
+
def blocking_call(timeout, *command)
|
|
41
|
+
with { |client| client.blocking_call_v(timeout, command) }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def blocking_call_v(timeout, command)
|
|
45
|
+
with { |client| client.blocking_call_v(timeout, command) }
|
|
46
|
+
end
|
|
47
|
+
|
|
40
48
|
def pipelined(exception: true, &block)
|
|
41
49
|
with { |client| client.pipelined(exception: exception, &block) }
|
|
42
50
|
end
|
data/lib/solid_redis/resp.rb
CHANGED
|
@@ -34,6 +34,29 @@ module SolidRedis
|
|
|
34
34
|
@buffer = +""
|
|
35
35
|
end
|
|
36
36
|
|
|
37
|
+
# Temporarily overrides the read timeout. +nil+ waits forever, which is
|
|
38
|
+
# what blocking commands such as BLPOP with a 0 timeout require.
|
|
39
|
+
def with_timeout(timeout)
|
|
40
|
+
previous = @read_timeout
|
|
41
|
+
@read_timeout = timeout
|
|
42
|
+
yield
|
|
43
|
+
ensure
|
|
44
|
+
@read_timeout = previous
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Waits until at least one byte is available without consuming it.
|
|
48
|
+
# Returns +false+ on timeout. Unlike a timed-out +read+, this never
|
|
49
|
+
# leaves a partially consumed frame behind, so it is the safe way to
|
|
50
|
+
# poll for the next Pub/Sub message.
|
|
51
|
+
def wait_readable(timeout)
|
|
52
|
+
return true unless @buffer.empty?
|
|
53
|
+
return true unless @io.respond_to?(:to_io)
|
|
54
|
+
|
|
55
|
+
!IO.select([@io], nil, nil, timeout).nil?
|
|
56
|
+
rescue IOError, SystemCallError => error
|
|
57
|
+
raise ConnectionError, error.message, cause: error
|
|
58
|
+
end
|
|
59
|
+
|
|
37
60
|
def read(exception: true)
|
|
38
61
|
case (type = read_bytes(1))
|
|
39
62
|
when "+" then read_line
|
|
@@ -90,6 +90,10 @@ module SolidRedis
|
|
|
90
90
|
Pool.new(self, **options)
|
|
91
91
|
end
|
|
92
92
|
|
|
93
|
+
def new_subscription(**options)
|
|
94
|
+
Subscription.new(self, **options)
|
|
95
|
+
end
|
|
96
|
+
|
|
93
97
|
def notify(event, *arguments)
|
|
94
98
|
callbacks = redis_client_options[:callbacks]
|
|
95
99
|
return unless callbacks&.respond_to?(event)
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidRedis
|
|
4
|
+
# A dedicated Pub/Sub connection.
|
|
5
|
+
#
|
|
6
|
+
# Once SUBSCRIBE has been sent, a Redis connection stops answering regular
|
|
7
|
+
# commands and turns into a stream of push messages. A Subscription therefore
|
|
8
|
+
# owns its own socket, is never taken from a pool, and belongs to the Ractor
|
|
9
|
+
# that created it. Channel and pattern lists are tracked locally so that the
|
|
10
|
+
# connection can be re-established and re-subscribed after a network error.
|
|
11
|
+
class Subscription < Client
|
|
12
|
+
Message = Struct.new(:type, :channel, :pattern, :payload) do
|
|
13
|
+
def message?
|
|
14
|
+
%i[message pmessage smessage].include?(type)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
SUBSCRIBE_COMMANDS = Ractor.make_shareable({
|
|
19
|
+
channels: %w[SUBSCRIBE UNSUBSCRIBE],
|
|
20
|
+
patterns: %w[PSUBSCRIBE PUNSUBSCRIBE],
|
|
21
|
+
shards: %w[SSUBSCRIBE SUNSUBSCRIBE],
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
def initialize(config, name: nil)
|
|
25
|
+
super
|
|
26
|
+
@channels = []
|
|
27
|
+
@patterns = []
|
|
28
|
+
@shards = []
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def subscribe(*channels)
|
|
32
|
+
change(:channels, 0, channels)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def psubscribe(*patterns)
|
|
36
|
+
change(:patterns, 0, patterns)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def ssubscribe(*channels)
|
|
40
|
+
change(:shards, 0, channels)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def unsubscribe(*channels)
|
|
44
|
+
change(:channels, 1, channels)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def punsubscribe(*patterns)
|
|
48
|
+
change(:patterns, 1, patterns)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def sunsubscribe(*channels)
|
|
52
|
+
change(:shards, 1, channels)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def ping(payload = nil)
|
|
56
|
+
transmit { payload ? ["PING", payload] : ["PING"] }
|
|
57
|
+
self
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def subscriptions
|
|
61
|
+
{ channels: @channels.dup, patterns: @patterns.dup, shards: @shards.dup }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def subscribed?
|
|
65
|
+
!(@channels.empty? && @patterns.empty? && @shards.empty?)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Returns the next Message, or +nil+ when +timeout+ seconds elapse without
|
|
69
|
+
# one. A +nil+ timeout waits forever. Connection errors trigger a reconnect
|
|
70
|
+
# and a re-subscription according to +reconnect_attempts+.
|
|
71
|
+
def next_message(timeout: nil)
|
|
72
|
+
attempts = 0
|
|
73
|
+
loop do
|
|
74
|
+
ensure_connected
|
|
75
|
+
return unless @reader.wait_readable(timeout)
|
|
76
|
+
|
|
77
|
+
return decode(@reader.read)
|
|
78
|
+
rescue ConnectionError, IO::WaitReadable, IO::WaitWritable, SystemCallError => error
|
|
79
|
+
handle_error(error)
|
|
80
|
+
raise error if attempts >= config.reconnect_attempts
|
|
81
|
+
|
|
82
|
+
attempts += 1
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Yields every incoming Message. When +timeout+ is given, yields +nil+
|
|
87
|
+
# each time it elapses so the caller can check a stop condition.
|
|
88
|
+
def each_message(timeout: nil)
|
|
89
|
+
return enum_for(__method__, timeout: timeout) unless block_given?
|
|
90
|
+
|
|
91
|
+
loop { yield next_message(timeout: timeout) }
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def call(*)
|
|
95
|
+
raise Error, "Regular commands are not available on a Pub/Sub connection"
|
|
96
|
+
end
|
|
97
|
+
alias_method :call_v, :call
|
|
98
|
+
alias_method :pipelined, :call
|
|
99
|
+
alias_method :blocking_call, :call
|
|
100
|
+
alias_method :blocking_call_v, :call
|
|
101
|
+
|
|
102
|
+
private
|
|
103
|
+
|
|
104
|
+
# The tracked list is updated only once a connection exists, so a fresh
|
|
105
|
+
# connection re-subscribes to the previous list and the new command is
|
|
106
|
+
# then sent exactly once.
|
|
107
|
+
def change(kind, direction, names)
|
|
108
|
+
names = names.flatten.map(&:to_s)
|
|
109
|
+
transmit do
|
|
110
|
+
list = instance_variable_get(:"@#{kind}")
|
|
111
|
+
if direction.zero?
|
|
112
|
+
names.each { |name| list << name unless list.include?(name) }
|
|
113
|
+
else
|
|
114
|
+
names.empty? ? list.clear : list.delete_if { |name| names.include?(name) }
|
|
115
|
+
end
|
|
116
|
+
[SUBSCRIBE_COMMANDS[kind][direction], *names]
|
|
117
|
+
end
|
|
118
|
+
self
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def transmit
|
|
122
|
+
attempts = 0
|
|
123
|
+
loop do
|
|
124
|
+
ensure_connected
|
|
125
|
+
return write(RESP.encode(yield))
|
|
126
|
+
rescue ConnectionError, IO::WaitReadable, IO::WaitWritable, SystemCallError => error
|
|
127
|
+
handle_error(error)
|
|
128
|
+
raise error if attempts >= config.reconnect_attempts
|
|
129
|
+
|
|
130
|
+
attempts += 1
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def ensure_connected
|
|
135
|
+
return if connected?
|
|
136
|
+
|
|
137
|
+
connect
|
|
138
|
+
resubscribe
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Re-issues the tracked subscriptions on a fresh connection. Confirmation
|
|
142
|
+
# events flow back to the caller as :subscribe/:psubscribe/:ssubscribe.
|
|
143
|
+
def resubscribe
|
|
144
|
+
write(RESP.encode(["SUBSCRIBE", *@channels])) unless @channels.empty?
|
|
145
|
+
write(RESP.encode(["PSUBSCRIBE", *@patterns])) unless @patterns.empty?
|
|
146
|
+
write(RESP.encode(["SSUBSCRIBE", *@shards])) unless @shards.empty?
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def handle_error(error)
|
|
150
|
+
close
|
|
151
|
+
config.reset if config.sentinel?
|
|
152
|
+
config.notify(:connection_error, error.class.name, error.message)
|
|
153
|
+
raise ConnectionError, error.message, cause: error unless error.is_a?(Error)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def decode(reply)
|
|
157
|
+
unless reply.is_a?(Array) && reply.first.is_a?(String)
|
|
158
|
+
raise ProtocolError, "Unexpected Pub/Sub reply: #{reply.inspect}"
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
type = reply[0].to_sym
|
|
162
|
+
case type
|
|
163
|
+
when :message, :smessage
|
|
164
|
+
Message.new(type, reply[1], nil, reply[2])
|
|
165
|
+
when :pmessage
|
|
166
|
+
Message.new(type, reply[2], reply[1], reply[3])
|
|
167
|
+
when :pong
|
|
168
|
+
Message.new(type, nil, nil, reply[1])
|
|
169
|
+
when :psubscribe, :punsubscribe
|
|
170
|
+
Message.new(type, nil, reply[1], reply[2])
|
|
171
|
+
else
|
|
172
|
+
Message.new(type, reply[1], nil, reply[2])
|
|
173
|
+
end.freeze
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
data/lib/solid_redis/version.rb
CHANGED
data/lib/solid_redis.rb
CHANGED
|
@@ -9,9 +9,16 @@ require_relative "solid_redis/shareable"
|
|
|
9
9
|
require_relative "solid_redis/config"
|
|
10
10
|
require_relative "solid_redis/resp"
|
|
11
11
|
require_relative "solid_redis/client"
|
|
12
|
+
require_relative "solid_redis/subscription"
|
|
12
13
|
require_relative "solid_redis/sentinel/resolve_service"
|
|
13
14
|
require_relative "solid_redis/sentinel_state"
|
|
14
15
|
require_relative "solid_redis/sentinel_config"
|
|
16
|
+
require_relative "solid_redis/cluster/key_slot"
|
|
17
|
+
require_relative "solid_redis/cluster/command_key"
|
|
18
|
+
require_relative "solid_redis/cluster/discover_service"
|
|
19
|
+
require_relative "solid_redis/cluster_state"
|
|
20
|
+
require_relative "solid_redis/cluster_config"
|
|
21
|
+
require_relative "solid_redis/cluster_client"
|
|
15
22
|
require_relative "solid_redis/pool"
|
|
16
23
|
|
|
17
24
|
module SolidRedis
|
|
@@ -24,4 +31,8 @@ module SolidRedis
|
|
|
24
31
|
def sentinel(**options)
|
|
25
32
|
SentinelConfig.new(**options)
|
|
26
33
|
end
|
|
34
|
+
|
|
35
|
+
def cluster(**options)
|
|
36
|
+
ClusterConfig.new(**options)
|
|
37
|
+
end
|
|
27
38
|
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: 0.
|
|
4
|
+
version: 1.0.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Nicolas Vandenbogaerde
|
|
@@ -71,8 +71,9 @@ dependencies:
|
|
|
71
71
|
- - "~>"
|
|
72
72
|
- !ruby/object:Gem::Version
|
|
73
73
|
version: '13.0'
|
|
74
|
-
description:
|
|
75
|
-
and sockets per Ractor.
|
|
74
|
+
description: Immutable, shareable Redis, Sentinel and Cluster configuration with isolated
|
|
75
|
+
runtime state, pools, and sockets per Ractor. Supports pipelines, blocking commands,
|
|
76
|
+
and Pub/Sub.
|
|
76
77
|
executables: []
|
|
77
78
|
extensions: []
|
|
78
79
|
extra_rdoc_files: []
|
|
@@ -81,6 +82,12 @@ files:
|
|
|
81
82
|
- README.md
|
|
82
83
|
- lib/solid_redis.rb
|
|
83
84
|
- lib/solid_redis/client.rb
|
|
85
|
+
- lib/solid_redis/cluster/command_key.rb
|
|
86
|
+
- lib/solid_redis/cluster/discover_service.rb
|
|
87
|
+
- lib/solid_redis/cluster/key_slot.rb
|
|
88
|
+
- lib/solid_redis/cluster_client.rb
|
|
89
|
+
- lib/solid_redis/cluster_config.rb
|
|
90
|
+
- lib/solid_redis/cluster_state.rb
|
|
84
91
|
- lib/solid_redis/config.rb
|
|
85
92
|
- lib/solid_redis/errors.rb
|
|
86
93
|
- lib/solid_redis/pool.rb
|
|
@@ -89,13 +96,14 @@ files:
|
|
|
89
96
|
- lib/solid_redis/sentinel_config.rb
|
|
90
97
|
- lib/solid_redis/sentinel_state.rb
|
|
91
98
|
- lib/solid_redis/shareable.rb
|
|
99
|
+
- lib/solid_redis/subscription.rb
|
|
92
100
|
- lib/solid_redis/version.rb
|
|
93
101
|
homepage: https://github.com/nicolasva/solid-redis
|
|
94
102
|
licenses:
|
|
95
103
|
- MIT
|
|
96
104
|
metadata:
|
|
97
105
|
rubygems_mfa_required: 'true'
|
|
98
|
-
documentation_uri: https://www.rubydoc.info/gems/solid-redis/0.
|
|
106
|
+
documentation_uri: https://www.rubydoc.info/gems/solid-redis/1.0.0
|
|
99
107
|
source_code_uri: https://github.com/nicolasva/solid-redis
|
|
100
108
|
rdoc_options: []
|
|
101
109
|
require_paths:
|
|
@@ -113,5 +121,6 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
113
121
|
requirements: []
|
|
114
122
|
rubygems_version: 4.0.20
|
|
115
123
|
specification_version: 4
|
|
116
|
-
summary: A Ractor-aware Redis client with Sentinel
|
|
124
|
+
summary: A Ractor-aware Redis client with Sentinel, Cluster, Pub/Sub and blocking
|
|
125
|
+
command support
|
|
117
126
|
test_files: []
|