solid-redis 0.2.0 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b8d43f81f25072d1e69cf2eb753ce70869745c2b60e5b7130681b77cce431907
4
- data.tar.gz: 8829253e57de67c1b5ce46932849e0dd105ca49e0bf433aa88d4e3361e0c2f75
3
+ metadata.gz: aa178f3f726a1fe71f54af0aacebb6bb93890c86c4068e518acf1bbb7de16a67
4
+ data.tar.gz: 2b834a7e586b947cc39486bc0107614e7faa9db2aa3b3c91ab365c58db9b314e
5
5
  SHA512:
6
- metadata.gz: 41e44bbd248002414a12a726376e9cbf2acad0825b163bab1633db89cc5ae4c1903776ec24c51cd0c59a26335999a0f5510a4dfc8052af6686495554cb135315
7
- data.tar.gz: '01281e6eaf973aaff81ffeddeb346a4edf296461532b6f6207319dba8dcd2af3c6cd5e1581e1d73452892c128189c1695cde79348420b87ad4bf54c8bdb8598b'
6
+ metadata.gz: cc25f3b1e2ee529877e39f3da367ad966fcdeb35283732f7ad2d144ce4d78b8c3e1476203f6910525e80bceb7cf6a9f3bc7916c4dc1ac401ea9b97130f80937e
7
+ data.tar.gz: cd5472d06a641f06bfe1a13d7bfd50b530bfc8126ce07cfb34484f19ae3842da24dedc36e51863225603861b0b23057b923164db817087667d91f86caa1aece9
data/README.md CHANGED
@@ -2,21 +2,24 @@
2
2
 
3
3
  [![Build Status](https://github.com/nicolasva/solid-redis/actions/workflows/ci.yml/badge.svg)](https://github.com/nicolasva/solid-redis/actions/workflows/ci.yml)
4
4
  [![Gem Version](https://badge.fury.io/rb/solid-redis.svg)](https://rubygems.org/gems/solid-redis)
5
- [![Downloads](https://img.shields.io/gem/dt/solid-redis.svg)](https://rubygems.org/gems/solid-redis)
5
+ [![Downloads](https://img.shields.io/gem/dt/solid-redis?style=flat)](https://rubygems.org/gems/solid-redis)
6
6
  [![Documentation Status](https://img.shields.io/badge/docs-RubyDoc.info-blue.svg)](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 Sentinel specifications are immutable and shareable;
10
- every Ractor creates and retains its own resolution state, mutex, pool,
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 does not depend on or patch `redis-client`.
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 returns its immutable result and endpoint errors;
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, and Sentinel runtime state are never
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
- - Pub/Sub, transactions, blocking-call helpers, cluster routing, middleware,
438
- and an asynchronous actor pool are not part of version `0.1`.
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
 
@@ -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
@@ -89,6 +89,10 @@ module SolidRedis
89
89
  Pool.new(self, **options)
90
90
  end
91
91
 
92
+ def new_subscription(**options)
93
+ Subscription.new(self, **options)
94
+ end
95
+
92
96
  def notify(event, *arguments)
93
97
  return unless @callbacks&.respond_to?(event)
94
98
 
@@ -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
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SolidRedis
4
- VERSION = "0.2.0"
4
+ VERSION = "1.0.0"
5
5
  end
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.2.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: Use immutable Redis configuration with isolated Sentinel state, pools,
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.2.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 support
124
+ summary: A Ractor-aware Redis client with Sentinel, Cluster, Pub/Sub and blocking
125
+ command support
117
126
  test_files: []