async-redis 0.11.2 → 0.13.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.
@@ -0,0 +1,172 @@
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
+ Redis pub/sub enables real-time communication between different parts of your application or between different applications. It's perfect for broadcasting notifications, coordinating distributed systems, or building real-time features, provided you don't need reliable messaging.
6
+
7
+ Common use cases:
8
+ - **Real-time notifications**: Alert users about new messages, updates, or events.
9
+ - **System coordination**: Notify services about configuration changes or cache invalidation.
10
+ - **Live updates**: Push data changes to web interfaces or mobile apps.
11
+ - **Event-driven architecture**: Decouple services through asynchronous messaging.
12
+
13
+ ## Overview
14
+
15
+ Redis provides 3 pub/sub mechanisms:
16
+ - **SUBSCRIBE**: Subscribe to specific channels by name.
17
+ - **PSUBSCRIBE**: Subscribe to channels matching patterns (e.g., `user.*`).
18
+ - **SSUBSCRIBE**: Sharded subscriptions for cluster environments (better performance).
19
+
20
+ ## Subscribe
21
+
22
+ The `SUBSCRIBE` command lets you listen for messages on specific channels. This creates a persistent connection that receives messages as they're published.
23
+
24
+ Here's a simple notification system - first, create a listener:
25
+
26
+ ``` ruby
27
+ require "async"
28
+ require "async/redis"
29
+
30
+ client = Async::Redis::Client.new
31
+
32
+ Async do
33
+ client.subscribe "status.frontend" do |context|
34
+ puts "Listening for messages on 'status.frontend'..."
35
+
36
+ type, name, message = context.listen
37
+
38
+ puts "Received: #{message}"
39
+ end
40
+ end
41
+ ```
42
+
43
+ In another part of your application, publish notifications:
44
+
45
+ ``` ruby
46
+ require "async"
47
+ require "async/redis"
48
+
49
+ client = Async::Redis::Client.new
50
+
51
+ Async do
52
+ puts "Publishing message..."
53
+ client.publish "status.frontend", "good"
54
+ puts "Message sent!"
55
+ end
56
+ ```
57
+
58
+ 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:
59
+
60
+ ```bash
61
+ $ ruby listener.rb
62
+ Listening for messages on 'status.frontend'...
63
+ Received: good
64
+ ```
65
+
66
+ ### Error Handling
67
+
68
+ 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.
69
+
70
+ ```ruby
71
+ require "async"
72
+ require "async/redis"
73
+
74
+ client = Async::Redis::Client.new
75
+
76
+ Async do
77
+ client.subscribe "status.frontend" do |context|
78
+ puts "Listening for messages on 'status.frontend'..."
79
+
80
+ context.each do |type, name, message|
81
+ puts "Received: #{message}"
82
+ end
83
+ end
84
+ rescue => error
85
+ Console.warn(self, "Subscription failed", error)
86
+ sleep 1
87
+ retry
88
+ end
89
+ ```
90
+
91
+ ## Pattern Subscribe
92
+
93
+ When you need to listen to multiple related channels, patterns save you from subscribing to each channel individually. This is perfect for monitoring all user activities or all events from a specific service.
94
+
95
+ For example, to monitor all user-related events (`user.login`, `user.logout`, `user.signup`):
96
+
97
+ ``` ruby
98
+ require "async"
99
+ require "async/redis"
100
+
101
+ endpoint = Async::Redis.local_endpoint
102
+ client = Async::Redis::Client.new(endpoint)
103
+
104
+ Async do
105
+ client.psubscribe "status.*" do |context|
106
+ puts "Listening for messages on 'status.*'..."
107
+
108
+ type, pattern, name, message = context.listen
109
+
110
+ puts "Received: #{message}"
111
+ end
112
+ end
113
+ ```
114
+
115
+ 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.
116
+
117
+ ## Shard Subscribe
118
+
119
+ 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.
120
+
121
+ To use sharded subscriptions, use a cluster client which supports sharded pub/sub:
122
+
123
+ ``` ruby
124
+ require "async"
125
+ require "async/redis"
126
+
127
+ # endpoints = ...
128
+ cluster_client = Async::Redis::ClusterClient.new(endpoints)
129
+
130
+ Async do
131
+ cluster_client.subscribe "status.frontend" do |context|
132
+ puts "Listening for messages on 'status.frontend'..."
133
+
134
+ type, name, message = context.listen
135
+
136
+ puts "Received: #{message}"
137
+ end
138
+ end
139
+ ```
140
+
141
+ ``` ruby
142
+ require "async"
143
+ require "async/redis"
144
+
145
+ # endpoints = ...
146
+ cluster_client = Async::Redis::ClusterClient.new(endpoints)
147
+
148
+ Async do
149
+ puts "Publishing message..."
150
+ cluster_client.publish("status.frontend", "good")
151
+ puts "Message sent!"
152
+ end
153
+ ```
154
+
155
+ ### Clustered Subscriptions
156
+
157
+ 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.
158
+
159
+ #### Cluster Topology Changes and Subscription Invalidation
160
+
161
+ 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.
162
+
163
+ **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.
164
+
165
+ Common scenarios that trigger subscription invalidation:
166
+
167
+ - **Resharding operations**: When slots are migrated between nodes (`MOVED` errors)
168
+ - **Node failures**: When Redis nodes become unavailable
169
+ - **Network partitions**: When connections to specific shards are lost
170
+ - **Cluster reconfiguration**: When the cluster topology changes
171
+
172
+ Applications should be prepared to handle subscription failures and implement appropriate retry strategies.
@@ -0,0 +1,197 @@
1
+ # Transactions and Pipelines
2
+
3
+ This guide explains how to use Redis transactions and pipelines with `async-redis` for atomic operations and improved performance.
4
+
5
+ By default, each command (e.g. `GET key`) acquires a connection from the client, runs the command, returns the result and releases the connection back to the client's connection pool. This may be inefficient for some use cases, and so there are several ways to group together operations that run on the same connection.
6
+
7
+ ## Transactions (MULTI/EXEC)
8
+
9
+ Transactions ensure that multiple Redis commands execute atomically - either all commands succeed, or none of them do. This is crucial when you need to maintain data consistency, such as transferring money between accounts or updating related fields together.
10
+
11
+ Use transactions when you need:
12
+ - **Atomic updates**: Multiple operations that must all succeed or all fail.
13
+ - **Data consistency**: Keeping related data in sync across multiple keys.
14
+ - **Preventing partial updates**: Avoiding situations where only some of your changes are applied.
15
+
16
+ Redis transactions queue commands and execute them all at once:
17
+
18
+ ``` ruby
19
+ require "async/redis"
20
+
21
+ endpoint = Async::Redis.local_endpoint
22
+ client = Async::Redis::Client.new(endpoint)
23
+
24
+ Async do
25
+ begin
26
+ # Execute commands atomically:
27
+ results = client.transaction do |context|
28
+ context.multi
29
+
30
+ # Queue commands for atomic execution:
31
+ context.set("user:1:name", "Alice")
32
+ context.set("user:1:email", "alice@example.com")
33
+ context.incr("user:count")
34
+
35
+ # Execute all queued commands:
36
+ context.execute
37
+ end
38
+
39
+ puts "Transaction results: #{results}"
40
+
41
+ ensure
42
+ client.close
43
+ end
44
+ end
45
+ ```
46
+
47
+ ### Watch/Unwatch for Optimistic Locking
48
+
49
+ When multiple clients might modify the same data simultaneously, you need to handle race conditions. Redis WATCH provides optimistic locking - the transaction only executes if watched keys haven't changed since you started watching them.
50
+
51
+ This is essential for scenarios like:
52
+ - Updating counters or balances where the current value matters.
53
+ - Implementing atomic increment operations with business logic.
54
+ - Preventing lost updates in concurrent environments.
55
+
56
+ Here's how to implement safe concurrent updates:
57
+
58
+ ``` ruby
59
+ require "async/redis"
60
+
61
+ endpoint = Async::Redis.local_endpoint
62
+ client = Async::Redis::Client.new(endpoint)
63
+
64
+ Async do
65
+ begin
66
+ # Initialize counter.
67
+ client.set("counter", 0)
68
+
69
+ # Optimistic locking example:
70
+ 5.times do |i|
71
+ success = false
72
+ attempts = 0
73
+
74
+ while !success && attempts < 3
75
+ attempts += 1
76
+
77
+ result = client.transaction do |context|
78
+ # Watch the counter for changes (executes immediately):
79
+ context.watch("counter")
80
+
81
+ # Read current value (executes immediately):
82
+ current_value = client.get("counter").to_i
83
+ new_value = current_value + 1
84
+
85
+ # Start transaction - commands after this are queued, not executed:
86
+ context.multi
87
+
88
+ # Queue commands (these return "QUEUED", don't execute yet):
89
+ context.set("counter", new_value)
90
+ context.set("last_update", Time.now.to_f)
91
+
92
+ # Execute all queued commands atomically.
93
+ # Returns nil if watched key was modified by another client:
94
+ context.execute
95
+ end
96
+
97
+ if result
98
+ puts "Increment #{i + 1} succeeded: #{result}"
99
+ success = true
100
+ else
101
+ puts "Increment #{i + 1} failed, retrying (attempt #{attempts})"
102
+ sleep 0.01
103
+ end
104
+ end
105
+ end
106
+
107
+ final_value = client.get("counter")
108
+ puts "Final counter value: #{final_value}"
109
+
110
+ ensure
111
+ client.close
112
+ end
113
+ end
114
+ ```
115
+
116
+ ## Pipelines
117
+
118
+ When you need to execute many Redis commands quickly, sending them one-by-one creates network latency bottlenecks. Pipelines solve this by batching multiple commands together, dramatically reducing round-trip time.
119
+
120
+ Use pipelines when you need:
121
+ - **Better performance**: Reduce network round trips for bulk operations.
122
+ - **High throughput**: Process hundreds or thousands of commands efficiently.
123
+ - **Independent operations**: Commands that don't depend on each other's results.
124
+
125
+ Unlike transactions, pipeline commands are not atomic - some may succeed while others fail:
126
+
127
+ ``` ruby
128
+ require "async/redis"
129
+
130
+ endpoint = Async::Redis.local_endpoint
131
+ client = Async::Redis::Client.new(endpoint)
132
+
133
+ Async do
134
+ begin
135
+ # Execute commands in a pipeline:
136
+ results = client.pipeline do |context|
137
+ # Commands are buffered and flushed if needed:
138
+ context.set("pipeline:1", "value1")
139
+ context.set("pipeline:2", "value2")
140
+ context.set("pipeline:3", "value3")
141
+
142
+ context.get("pipeline:1")
143
+ context.get("pipeline:2")
144
+ context.get("pipeline:3")
145
+
146
+ # Flush and collect the results from all previous commands:
147
+ context.collect
148
+ end
149
+
150
+ puts "Pipeline results: #{results}"
151
+
152
+ ensure
153
+ client.close
154
+ end
155
+ end
156
+ ```
157
+
158
+ ### Synchronous Pipeline Operations
159
+
160
+ When you need immediate results from individual commands within a pipeline, use `context.sync`:
161
+
162
+ ``` ruby
163
+ require "async/redis"
164
+
165
+ endpoint = Async::Redis.local_endpoint
166
+ client = Async::Redis::Client.new(endpoint)
167
+
168
+ Async do
169
+ begin
170
+ client.pipeline do |context|
171
+ # Set values using pipeline - no immediate response:
172
+ context.set("async_key_1", "value1")
173
+ context.set("async_key_2", "value2")
174
+
175
+ # Get immediate response using sync:
176
+ immediate_result = context.sync.get("async_key_1")
177
+ puts "Immediate result: #{immediate_result}"
178
+
179
+ # Continue with pipelined operations:
180
+ context.get("async_key_2")
181
+
182
+ # Collect remaining pipelined results:
183
+ context.collect
184
+ end
185
+
186
+ ensure
187
+ client.close
188
+ end
189
+ end
190
+ ```
191
+
192
+ Use `context.sync` when you need to:
193
+ - **Check values mid-pipeline**: Verify data before continuing with more operations.
194
+ - **Conditional logic**: Make decisions based on current Redis state.
195
+ - **Debugging**: Get immediate feedback during pipeline development.
196
+
197
+ Note that `sync` operations execute immediately and flush pending responses, so use them strategically to maintain pipeline performance benefits.
@@ -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"
@@ -32,11 +32,49 @@ module Async
32
32
  # Subscribe to one or more channels for pub/sub messaging.
33
33
  # @parameter channels [Array(String)] The channels to subscribe to.
34
34
  # @yields {|context| ...} If a block is given, it will be executed within the subscription context.
35
- # @parameter context [Context::Subscribe] The subscription context.
35
+ # @parameter context [Context::Subscription] The subscription context.
36
36
  # @returns [Object] The result of the block if block given.
37
- # @returns [Context::Subscribe] The subscription context if no block given.
37
+ # @returns [Context::Subscription] The subscription context if no block given.
38
38
  def subscribe(*channels)
39
- context = Context::Subscribe.new(@pool, channels)
39
+ context = Context::Subscription.new(@pool, channels)
40
+
41
+ return context unless block_given?
42
+
43
+ begin
44
+ yield context
45
+ ensure
46
+ context.close
47
+ end
48
+ end
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)
40
78
 
41
79
  return context unless block_given?
42
80
 
@@ -5,12 +5,18 @@
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
12
16
  # A Redis cluster client that manages multiple Redis instances and handles cluster operations.
13
17
  class ClusterClient
18
+ include ::Protocol::Redis::Cluster::Methods
19
+
14
20
  # Raised when cluster configuration cannot be reloaded.
15
21
  class ReloadError < StandardError
16
22
  end
@@ -21,54 +27,6 @@ module Async
21
27
 
22
28
  Node = Struct.new(:id, :endpoint, :role, :health, :client)
23
29
 
24
- # A map that stores ranges and their associated values for efficient lookup.
25
- class RangeMap
26
- # Initialize a new RangeMap.
27
- def initialize
28
- @ranges = []
29
- end
30
-
31
- # Add a range-value pair to the map.
32
- # @parameter range [Range] The range to map.
33
- # @parameter value [Object] The value to associate with the range.
34
- # @returns [Object] The added value.
35
- def add(range, value)
36
- @ranges << [range, value]
37
-
38
- return value
39
- end
40
-
41
- # Find the value associated with a key within any range.
42
- # @parameter key [Object] The key to find.
43
- # @yields {...} Block called if no range contains the key.
44
- # @returns [Object] The value if found, result of block if given, or nil.
45
- def find(key)
46
- @ranges.each do |range, value|
47
- return value if range.include?(key)
48
- end
49
-
50
- if block_given?
51
- return yield
52
- end
53
-
54
- return nil
55
- end
56
-
57
- # Iterate over all values in the map.
58
- # @yields {|value| ...} Block called for each value.
59
- # @parameter value [Object] The value from the range-value pair.
60
- def each
61
- @ranges.each do |range, value|
62
- yield value
63
- end
64
- end
65
-
66
- # Clear all ranges from the map.
67
- def clear
68
- @ranges.clear
69
- end
70
- end
71
-
72
30
  # Create a new instance of the cluster client.
73
31
  #
74
32
  # @property endpoints [Array(Endpoint)] The list of cluster endpoints.
@@ -127,13 +85,35 @@ module Async
127
85
  end
128
86
  end
129
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
+
130
110
  protected
131
111
 
132
112
  def reload_cluster!(endpoints = @endpoints)
133
113
  @endpoints.each do |endpoint|
134
114
  client = Client.new(endpoint, **@options)
135
115
 
136
- shards = RangeMap.new
116
+ shards = Async::Redis::RangeMap.new
137
117
  endpoints = []
138
118
 
139
119
  client.call("CLUSTER", "SHARDS").each do |shard|
@@ -243,6 +223,32 @@ module Async
243
223
 
244
224
  return slots
245
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
246
252
  end
247
253
  end
248
254
  end