async-redis 0.11.1 → 0.12.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: 1a6afd9e0917905a3ac88663aab6185431af4f90230a160439131b137611a072
4
- data.tar.gz: 503e41aea7adc5f2f103d442db5be4c37f9299c9fb9bbba33b040f8200237511
3
+ metadata.gz: 631249ff627c06ac8931e39bbebd3a357695b390452bc67e7fc69e98a8abbcf0
4
+ data.tar.gz: 9f85726c02e706838bbf90d78d36233a4749901593e8c766ab008f797d18f381
5
5
  SHA512:
6
- metadata.gz: 459502e1c7c5cfbb6250a4460f1dfa4b5e0cd2386e056f77696883521274d3ad0b6c25cd5a1e6ee3db393d18703782c643c1fe6b39e4cf22292cd26417b6af9b
7
- data.tar.gz: 3119cf4d3b60ed724b68ed9fa2fc9dee2729a27657ebf1460453d236426d8e1c19f3f1717c5d0266fb85a9c7f821ca1e74f3e1e2321750b71805ecbefe084387
6
+ metadata.gz: 4471fecf34ff2cc9a3ead17ac8a92978aafe7e4ef8abcc5b25a36c7a48223ac9f9734ce8972a337ee2a83fd684e790a8a2bbaf263fd09d8ac646f39520f79443
7
+ data.tar.gz: 3d7388f8ab00d99aa5fd97d06ddec1f07c9027c7512fb8c194a08afa1becabf7c93407e73e85be25fc75c6af54129984fee5e638b550e22846e30c0c2b56f5fc
checksums.yaml.gz.sig CHANGED
Binary file
@@ -0,0 +1,94 @@
1
+ # Getting Started
2
+
3
+ This guide explains how to use the `async-redis` gem to connect to a Redis server and perform basic operations.
4
+
5
+ ## Installation
6
+
7
+ Add the gem to your project:
8
+
9
+ ``` shell
10
+ $ bundle add async-redis
11
+ ```
12
+
13
+ ## Usage
14
+
15
+ ### Basic Local Connection
16
+
17
+ ``` ruby
18
+ require 'async/redis'
19
+
20
+ Async do
21
+ endpoint = Async::Redis.local_endpoint(
22
+ # Optional database index:
23
+ database: 1,
24
+ # Optional credentials:
25
+ credentials: ["username", "password"]
26
+ )
27
+
28
+ client = Async::Redis::Client.new(endpoint)
29
+ puts client.info
30
+
31
+ client.set("mykey", "myvalue")
32
+ puts client.get("mykey")
33
+ end
34
+ ```
35
+
36
+ You can also encode this information in a URL:
37
+
38
+
39
+
40
+ ### Connecting to Redis SSL Endpoint
41
+
42
+ This example demonstrates parsing an environment variable with a `redis://` or SSL `rediss://` scheme, and demonstrates how you can specify SSL parameters on the SSLContext object.
43
+
44
+ ``` ruby
45
+ require 'async/redis'
46
+
47
+ ssl_context = OpenSSL::SSL::SSLContext.new.tap do |context|
48
+ # Load the certificate store:
49
+ context.cert_store = OpenSSL::X509::Store.new.tap do |store|
50
+ store.add_file(Rails.root.join("config/redis.pem").to_s)
51
+ end
52
+
53
+ # Load the certificate:
54
+ context.cert = OpenSSL::X509::Certificate.new(File.read(
55
+ Rails.root.join("config/redis.crt")
56
+ ))
57
+
58
+ # Load the private key:
59
+ context.key = OpenSSL::PKey::RSA.new(
60
+ Rails.application.credentials.services.redis.private_key
61
+ )
62
+
63
+ # Ensure the connection is verified according to the above certificates:
64
+ context.verify_mode = OpenSSL::SSL::VERIFY_PEER
65
+ end
66
+
67
+ # e.g. REDIS_URL=rediss://:PASSWORD@redis.example.com:12345
68
+ endpoint = Async::Redis::Endpoint.parse(ENV["REDIS_URL"], ssl_context: ssl_context)
69
+ client = Async::Redis::Client.new(endpoint)
70
+ Sync do
71
+ puts client.call("PING")
72
+ end
73
+ ```
74
+
75
+ ### Variables
76
+
77
+ ``` ruby
78
+ require 'async'
79
+ require 'async/redis'
80
+
81
+ endpoint = Async::Redis.local_endpoint
82
+ client = Async::Redis::Client.new(endpoint)
83
+
84
+ Async do
85
+ client.set('X', 10)
86
+ puts client.get('X')
87
+ ensure
88
+ client.close
89
+ end
90
+ ```
91
+
92
+ ## Next Steps
93
+
94
+ - [Subscriptions](../subscriptions/) - Learn how to use Redis pub/sub functionality for real-time messaging.
@@ -0,0 +1,16 @@
1
+ # Automatically generated context index for Utopia::Project guides.
2
+ # Do not edit then files in this directory directly, instead edit the guides and then run `bake utopia:project:agent:context:update`.
3
+ ---
4
+ description: A Redis client library.
5
+ metadata:
6
+ documentation_uri: https://socketry.github.io/async-redis/
7
+ source_code_uri: https://github.com/socketry/async-redis.git
8
+ files:
9
+ - path: getting-started.md
10
+ title: Getting Started
11
+ description: This guide explains how to use the `async-redis` gem to connect to
12
+ a Redis server and perform basic operations.
13
+ - path: subscriptions.md
14
+ title: Subscriptions
15
+ description: This guide explains how to use Redis pub/sub functionality with `async-redis`
16
+ to publish and subscribe to messages.
@@ -0,0 +1,161 @@
1
+ # Subscriptions
2
+
3
+ This guide explains how to use Redis pub/sub functionality with `async-redis` to publish and subscribe to messages.
4
+
5
+ ## Overview
6
+
7
+ Redis actually has 3 mechanisms to support pub/sub - a general `SUBSCRIBE` command, a pattern-based `PSUBSCRIBE` command, and a sharded `SSUBSCRIBE` command for cluster environments. They mostly work the same way, but have different use cases.
8
+
9
+ ## Subscribe
10
+
11
+ The `SUBSCRIBE` command is used to subscribe to one or more channels. When a message is published to a subscribed channel, the client receives the message in real-time.
12
+
13
+ First, let's create a simple listener that subscribes to messages on a channel:
14
+
15
+ ``` ruby
16
+ require 'async'
17
+ require 'async/redis'
18
+
19
+ client = Async::Redis::Client.new
20
+
21
+ Async do
22
+ client.subscribe 'status.frontend' do |context|
23
+ puts "Listening for messages on 'status.frontend'..."
24
+
25
+ type, name, message = context.listen
26
+
27
+ puts "Received: #{message}"
28
+ end
29
+ end
30
+ ```
31
+
32
+ Now, let's create a publisher that sends messages to the same channel:
33
+
34
+ ``` ruby
35
+ require 'async'
36
+ require 'async/redis'
37
+
38
+ client = Async::Redis::Client.new
39
+
40
+ Async do
41
+ puts "Publishing message..."
42
+ client.publish 'status.frontend', 'good'
43
+ puts "Message sent!"
44
+ end
45
+ ```
46
+
47
+ To see pub/sub in action, you can run the listener in one terminal and the publisher in another. The listener will receive any messages sent by the publisher to the `status.frontend` channel:
48
+
49
+ ```bash
50
+ $ ruby listener.rb
51
+ Listening for messages on 'status.frontend'...
52
+ Received: good
53
+ ```
54
+
55
+ ### Error Handling
56
+
57
+ Subscriptions are at-most-once delivery. In addition, subscriptions are stateful, meaning that they maintain their own internal state and can be affected by network issues or server restarts. In order to improve resilience, it's important to implement error handling and reconnection logic.
58
+
59
+ ```ruby
60
+ require 'async'
61
+ require 'async/redis'
62
+
63
+ client = Async::Redis::Client.new
64
+
65
+ Async do
66
+ client.subscribe 'status.frontend' do |context|
67
+ puts "Listening for messages on 'status.frontend'..."
68
+
69
+ context.each do |type, name, message|
70
+ puts "Received: #{message}"
71
+ end
72
+ end
73
+ rescue => error
74
+ Console.warn(self, "Subscription failed", error)
75
+ sleep 1
76
+ retry
77
+ end
78
+ ```
79
+
80
+ ## Pattern Subscribe
81
+
82
+ The `PSUBSCRIBE` command is used to subscribe to channels that match a given pattern. This allows clients to receive messages from multiple channels without subscribing to each one individually.
83
+
84
+ Let's replace the receiver in the above example:
85
+
86
+ ``` ruby
87
+ require 'async'
88
+ require 'async/redis'
89
+
90
+ endpoint = Async::Redis.local_endpoint
91
+ client = Async::Redis::Client.new(endpoint)
92
+
93
+ Async do
94
+ client.psubscribe 'status.*' do |context|
95
+ puts "Listening for messages on 'status.*'..."
96
+
97
+ type, pattern, name, message = context.listen
98
+
99
+ puts "Received: #{message}"
100
+ end
101
+ end
102
+ ```
103
+
104
+ Note that an extra field, `pattern` is returned when using `PSUBSCRIBE`. This field indicates the pattern that was matched for the incoming message. This can be useful for logging or debugging purposes, as it allows you to see which pattern triggered the message delivery.
105
+
106
+ ## Shard Subscribe
107
+
108
+ If you are working with a clustered environment, you can improve performance by limiting the scope of your subscriptions to specific shards. This can help reduce the amount of data that needs to be sent between shards and improve overall throughput.
109
+
110
+ To use sharded subscriptions, use a cluster client which supports sharded pub/sub:
111
+
112
+ ``` ruby
113
+ require 'async'
114
+ require 'async/redis'
115
+
116
+ # endpoints = ...
117
+ cluster_client = Async::Redis::ClusterClient.new(endpoints)
118
+
119
+ Async do
120
+ cluster_client.subscribe 'status.frontend' do |context|
121
+ puts "Listening for messages on 'status.frontend'..."
122
+
123
+ type, name, message = context.listen
124
+
125
+ puts "Received: #{message}"
126
+ end
127
+ end
128
+ ```
129
+
130
+ ``` ruby
131
+ require 'async'
132
+ require 'async/redis'
133
+
134
+ # endpoints = ...
135
+ cluster_client = Async::Redis::ClusterClient.new(endpoints)
136
+
137
+ Async do
138
+ puts "Publishing message..."
139
+ cluster_client.publish('status.frontend', 'good')
140
+ puts "Message sent!"
141
+ end
142
+ ```
143
+
144
+ ### Clustered Subscriptions
145
+
146
+ While general `PUBLISH` and `SUBSCRIBE` will work on a cluster, they are less efficient as they require inter-shard communication. By default, the {ruby Async::Redis::ClusterClient} subscription mechanism defaults to `SSUBSCRIBE` and `SPUBLISH`, which are optimized for sharded environments. However, if using multiple subscriptions, internally, several connections will be made to the relevant shards, which increases the complexity.
147
+
148
+ #### Cluster Topology Changes and Subscription Invalidation
149
+
150
+ If the cluster is re-configured (e.g. adding or removing nodes, resharding), the subscription state may need to be re-established to account for the new topology. During this process, messages may be lost. This is expected as subscriptions are stateless.
151
+
152
+ **Important**: When any individual shard subscription fails (due to resharding, node failures, or network issues), the entire cluster subscription is invalidated and will stop delivering messages. This design ensures consistency and prevents partial subscription states that could lead to missed messages on some shards.
153
+
154
+ Common scenarios that trigger subscription invalidation:
155
+
156
+ - **Resharding operations**: When slots are migrated between nodes (`MOVED` errors)
157
+ - **Node failures**: When Redis nodes become unavailable
158
+ - **Network partitions**: When connections to specific shards are lost
159
+ - **Cluster reconfiguration**: When the cluster topology changes
160
+
161
+ Applications should be prepared to handle subscription failures and implement appropriate retry strategies.
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2018-2024, by Samuel Williams.
4
+ # Copyright, 2018-2025, by Samuel Williams.
5
5
  # Copyright, 2018, by Huba Nagy.
6
6
  # Copyright, 2019, by Mikael Henriksson.
7
7
  # Copyright, 2019, by David Ortiz.
@@ -9,7 +9,7 @@
9
9
 
10
10
  require_relative "context/pipeline"
11
11
  require_relative "context/transaction"
12
- require_relative "context/subscribe"
12
+ require_relative "context/subscription"
13
13
  require_relative "endpoint"
14
14
 
15
15
  require "io/endpoint/host_endpoint"
@@ -23,12 +23,20 @@ module Async
23
23
  # Legacy.
24
24
  ServerError = ::Protocol::Redis::ServerError
25
25
 
26
+ # A Redis client that provides connection pooling and context management.
26
27
  class Client
27
28
  include ::Protocol::Redis::Methods
28
29
 
30
+ # Methods module providing Redis-specific functionality.
29
31
  module Methods
32
+ # Subscribe to one or more channels for pub/sub messaging.
33
+ # @parameter channels [Array(String)] The channels to subscribe to.
34
+ # @yields {|context| ...} If a block is given, it will be executed within the subscription context.
35
+ # @parameter context [Context::Subscription] The subscription context.
36
+ # @returns [Object] The result of the block if block given.
37
+ # @returns [Context::Subscription] The subscription context if no block given.
30
38
  def subscribe(*channels)
31
- context = Context::Subscribe.new(@pool, channels)
39
+ context = Context::Subscription.new(@pool, channels)
32
40
 
33
41
  return context unless block_given?
34
42
 
@@ -39,6 +47,49 @@ module Async
39
47
  end
40
48
  end
41
49
 
50
+ # Subscribe to one or more channel patterns for pub/sub messaging.
51
+ # @parameter patterns [Array(String)] The channel patterns to subscribe to.
52
+ # @yields {|context| ...} If a block is given, it will be executed within the subscription context.
53
+ # @parameter context [Context::Subscription] The subscription context.
54
+ # @returns [Object] The result of the block if block given.
55
+ # @returns [Context::Subscription] The subscription context if no block given.
56
+ def psubscribe(*patterns)
57
+ context = Context::Subscription.new(@pool, [])
58
+ context.psubscribe(patterns)
59
+
60
+ return context unless block_given?
61
+
62
+ begin
63
+ yield context
64
+ ensure
65
+ context.close
66
+ end
67
+ end
68
+
69
+ # Subscribe to one or more sharded channels for pub/sub messaging (Redis 7.0+).
70
+ # @parameter channels [Array(String)] The sharded channels to subscribe to.
71
+ # @yields {|context| ...} If a block is given, it will be executed within the subscription context.
72
+ # @parameter context [Context::Subscription] The subscription context.
73
+ # @returns [Object] The result of the block if block given.
74
+ # @returns [Context::Subscription] The subscription context if no block given.
75
+ def ssubscribe(*channels)
76
+ context = Context::Subscription.new(@pool, [])
77
+ context.ssubscribe(channels)
78
+
79
+ return context unless block_given?
80
+
81
+ begin
82
+ yield context
83
+ ensure
84
+ context.close
85
+ end
86
+ end
87
+
88
+ # Execute commands within a Redis transaction.
89
+ # @yields {|context| ...} If a block is given, it will be executed within the transaction context.
90
+ # @parameter context [Context::Transaction] The transaction context.
91
+ # @returns [Object] The result of the block if block given.
92
+ # @returns [Context::Transaction] Else if no block is given, returns the transaction context.
42
93
  def transaction(&block)
43
94
  context = Context::Transaction.new(@pool)
44
95
 
@@ -53,6 +104,11 @@ module Async
53
104
 
54
105
  alias multi transaction
55
106
 
107
+ # Execute commands in a pipeline for improved performance.
108
+ # @yields {|context| ...} If a block is given, it will be executed within the pipeline context.
109
+ # @parameter context [Context::Pipeline] The pipeline context.
110
+ # @returns [Object] The result of the block if block given.
111
+ # @returns [Context::Pipeline] The pipeline context if no block given.
56
112
  def pipeline(&block)
57
113
  context = Context::Pipeline.new(@pool)
58
114
 
@@ -68,6 +124,9 @@ module Async
68
124
  # Deprecated.
69
125
  alias nested pipeline
70
126
 
127
+ # Execute a Redis command directly.
128
+ # @parameter arguments [Array] The command and its arguments.
129
+ # @returns [Object] The response from the Redis server.
71
130
  def call(*arguments)
72
131
  @pool.acquire do |connection|
73
132
  connection.write_request(arguments)
@@ -78,6 +137,7 @@ module Async
78
137
  end
79
138
  end
80
139
 
140
+ # Close the client and all its connections.
81
141
  def close
82
142
  @pool.close
83
143
  end
@@ -85,6 +145,10 @@ module Async
85
145
 
86
146
  include Methods
87
147
 
148
+ # Create a new Redis client.
149
+ # @parameter endpoint [Endpoint] The Redis endpoint to connect to.
150
+ # @parameter protocol [Protocol] The protocol to use for communication.
151
+ # @parameter options [Hash] Additional options for the connection pool.
88
152
  def initialize(endpoint = Endpoint.local, protocol: endpoint.protocol, **options)
89
153
  @endpoint = endpoint
90
154
  @protocol = protocol
@@ -92,11 +156,18 @@ module Async
92
156
  @pool = make_pool(**options)
93
157
  end
94
158
 
159
+ # @attribute [Endpoint] The Redis endpoint.
95
160
  attr :endpoint
161
+
162
+ # @attribute [Protocol] The communication protocol.
96
163
  attr :protocol
97
164
 
98
- # @return [client] if no block provided.
99
- # @yield [client, task] yield the client in an async task.
165
+ # Open a Redis client and optionally yield it in an async task.
166
+ # @yields {|client, task| ...} If a block is given, yield the client in an async task.
167
+ # @parameter client [Client] The Redis client instance.
168
+ # @parameter task [Async::Task] The async task.
169
+ # @returns [Client] The client if no block provided.
170
+ # @returns [Object] The result of the block if block given.
100
171
  def self.open(*arguments, **options, &block)
101
172
  client = self.new(*arguments, **options)
102
173
 
@@ -5,53 +5,28 @@
5
5
  # Copyright, 2025, by Travis Bell.
6
6
 
7
7
  require_relative "client"
8
+ require_relative "cluster_subscription"
9
+ require_relative "range_map"
8
10
  require "io/stream"
9
11
 
12
+ require "protocol/redis/cluster/methods"
13
+
10
14
  module Async
11
15
  module Redis
16
+ # A Redis cluster client that manages multiple Redis instances and handles cluster operations.
12
17
  class ClusterClient
18
+ include ::Protocol::Redis::Cluster::Methods
19
+
20
+ # Raised when cluster configuration cannot be reloaded.
13
21
  class ReloadError < StandardError
14
22
  end
15
23
 
24
+ # Raised when no nodes are found for a specific slot.
16
25
  class SlotError < StandardError
17
26
  end
18
27
 
19
28
  Node = Struct.new(:id, :endpoint, :role, :health, :client)
20
29
 
21
- class RangeMap
22
- def initialize
23
- @ranges = []
24
- end
25
-
26
- def add(range, value)
27
- @ranges << [range, value]
28
-
29
- return value
30
- end
31
-
32
- def find(key)
33
- @ranges.each do |range, value|
34
- return value if range.include?(key)
35
- end
36
-
37
- if block_given?
38
- return yield
39
- end
40
-
41
- return nil
42
- end
43
-
44
- def each
45
- @ranges.each do |range, value|
46
- yield value
47
- end
48
- end
49
-
50
- def clear
51
- @ranges.clear
52
- end
53
- end
54
-
55
30
  # Create a new instance of the cluster client.
56
31
  #
57
32
  # @property endpoints [Array(Endpoint)] The list of cluster endpoints.
@@ -61,6 +36,13 @@ module Async
61
36
  @shards = nil
62
37
  end
63
38
 
39
+ # Execute a block with clients for the given keys, grouped by cluster slot.
40
+ # @parameter keys [Array] The keys to find clients for.
41
+ # @parameter role [Symbol] The role of nodes to use (:master or :slave).
42
+ # @parameter attempts [Integer] Number of retry attempts for cluster errors.
43
+ # @yields {|client, keys| ...} Block called for each client-keys pair.
44
+ # @parameter client [Client] The Redis client for the slot.
45
+ # @parameter keys [Array] The keys handled by this client.
64
46
  def clients_for(*keys, role: :master, attempts: 3)
65
47
  slots = slots_for(keys)
66
48
 
@@ -83,6 +65,10 @@ module Async
83
65
  end
84
66
  end
85
67
 
68
+ # Get a client for a specific slot.
69
+ # @parameter slot [Integer] The cluster slot number.
70
+ # @parameter role [Symbol] The role of node to get (:master or :slave).
71
+ # @returns [Client] The Redis client for the slot.
86
72
  def client_for(slot, role = :master)
87
73
  unless @shards
88
74
  reload_cluster!
@@ -99,13 +85,35 @@ module Async
99
85
  end
100
86
  end
101
87
 
88
+ # Get any available client from the cluster.
89
+ # This is useful for operations that don't require slot-specific routing, such as global pub/sub operations, INFO commands, or other cluster-wide operations.
90
+ # @parameter role [Symbol] The role of node to get (:master or :slave).
91
+ # @returns [Client] A Redis client for any available node.
92
+ def any_client(role = :master)
93
+ unless @shards
94
+ reload_cluster!
95
+ end
96
+
97
+ # Sample a random shard to get better load distribution
98
+ if nodes = @shards.sample
99
+ nodes = nodes.select{|node| node.role == role}
100
+
101
+ if node = nodes.sample
102
+ return (node.client ||= Client.new(node.endpoint, **@options))
103
+ end
104
+ end
105
+
106
+ # Fallback to slot 0 if sampling fails
107
+ client_for(0, role)
108
+ end
109
+
102
110
  protected
103
111
 
104
112
  def reload_cluster!(endpoints = @endpoints)
105
113
  @endpoints.each do |endpoint|
106
114
  client = Client.new(endpoint, **@options)
107
115
 
108
- shards = RangeMap.new
116
+ shards = Async::Redis::RangeMap.new
109
117
  endpoints = []
110
118
 
111
119
  client.call("CLUSTER", "SHARDS").each do |shard|
@@ -116,7 +124,7 @@ module Async
116
124
 
117
125
  nodes = shard["nodes"].map do |node|
118
126
  node = node.each_slice(2).to_h
119
- endpoint = Endpoint.remote(node["ip"], node["port"])
127
+ endpoint = Endpoint.for(endpoint.scheme, node["endpoint"], port: node["port"])
120
128
 
121
129
  # Collect all endpoints:
122
130
  endpoints << endpoint
@@ -203,6 +211,9 @@ module Async
203
211
  return crc16(key) % HASH_SLOTS
204
212
  end
205
213
 
214
+ # Calculate the hash slots for multiple keys.
215
+ # @parameter keys [Array] The keys to calculate slots for.
216
+ # @returns [Hash] A hash mapping slot numbers to arrays of keys.
206
217
  def slots_for(keys)
207
218
  slots = Hash.new{|hash, key| hash[key] = []}
208
219
 
@@ -212,6 +223,32 @@ module Async
212
223
 
213
224
  return slots
214
225
  end
226
+
227
+ # Subscribe to one or more sharded channels for pub/sub messaging in cluster environment.
228
+ # The subscription will be created on the appropriate nodes responsible for each channel's hash slot.
229
+ #
230
+ # @parameter channels [Array(String)] The sharded channels to subscribe to.
231
+ # @yields {|context| ...} If a block is given, it will be executed within the subscription context.
232
+ # @parameter context [ClusterSubscription] The cluster subscription context.
233
+ # @returns [Object] The result of the block if block given.
234
+ # @returns [ClusterSubscription] The cluster subscription context if no block given.
235
+ def subscribe(*channels)
236
+ context = ClusterSubscription.new(self)
237
+
238
+ if channels.any?
239
+ context.subscribe(channels)
240
+ end
241
+
242
+ if block_given?
243
+ begin
244
+ yield context
245
+ ensure
246
+ context.close
247
+ end
248
+ else
249
+ return context
250
+ end
251
+ end
215
252
  end
216
253
  end
217
254
  end