async-redis 0.11.2 → 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: 4307fa3fa26652c590ff6c371541d216944b0e724fb67f8ffa3799e1f120c37c
4
- data.tar.gz: 3d1320e98619fcaf9b5e3f80ec7cef3533a25f62ec19047478c87aacb3c15bec
3
+ metadata.gz: 631249ff627c06ac8931e39bbebd3a357695b390452bc67e7fc69e98a8abbcf0
4
+ data.tar.gz: 9f85726c02e706838bbf90d78d36233a4749901593e8c766ab008f797d18f381
5
5
  SHA512:
6
- metadata.gz: a79be2158187c9dbb6b59eec5a709f899d5e41d94be7e83977b500680888a739d50e4cb06bf53e7676540a49cdd1be50a3458f9002f0951866ea61f91ceb6299
7
- data.tar.gz: 81829d10c42316190844935547406678a1593c291bbc51fe69da274d495dc5eec9b590e6ade56af997ac453a520dbffc5d916cee4c962408979261e1010d300b
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"
@@ -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
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2025, by Samuel Williams.
5
+
6
+ require "async/limited_queue"
7
+ require "async/barrier"
8
+
9
+ module Async
10
+ module Redis
11
+ # Context for managing sharded subscriptions across multiple Redis cluster nodes.
12
+ # This class handles the complexity of subscribing to channels that may be distributed
13
+ # across different shards in a Redis cluster.
14
+ class ClusterSubscription
15
+ # Represents a failure in the subscription process, e.g. network issues, shard failures.
16
+ class SubscriptionError < StandardError
17
+ end
18
+
19
+ # Initialize a new shard subscription context.
20
+ # @parameter cluster_client [ClusterClient] The cluster client to use.
21
+ def initialize(cluster_client, queue: Async::LimitedQueue.new)
22
+ @cluster_client = cluster_client
23
+ @subscriptions = {}
24
+ @channels = []
25
+
26
+ @barrier = Async::Barrier.new
27
+ @queue = queue
28
+ end
29
+
30
+ # Close all shard subscriptions.
31
+ def close
32
+ if barrier = @barrier
33
+ @barrier = nil
34
+ barrier.stop
35
+ end
36
+
37
+ @subscriptions.each_value(&:close)
38
+ @subscriptions.clear
39
+ end
40
+
41
+ # Listen for the next message from any subscribed shard.
42
+ # @returns [Array] The next message response.
43
+ # @raises [SubscriptionError] If the subscription has failed for any reason.
44
+ def listen
45
+ @queue.pop
46
+ rescue => error
47
+ raise SubscriptionError, "Failed to read message!"
48
+ end
49
+
50
+ # Iterate over all messages from all subscribed shards.
51
+ # @yields {|response| ...} Block called for each message.
52
+ # @parameter response [Array] The message response.
53
+ def each
54
+ return to_enum unless block_given?
55
+
56
+ while response = self.listen
57
+ yield response
58
+ end
59
+ end
60
+
61
+ # Subscribe to additional sharded channels.
62
+ # @parameter channels [Array(String)] The channels to subscribe to.
63
+ def subscribe(channels)
64
+ slots = @cluster_client.slots_for(channels)
65
+
66
+ slots.each do |slot, channels_for_slot|
67
+ if subscription = @subscriptions[slot]
68
+ # Add to existing subscription for this shard
69
+ subscription.ssubscribe(channels_for_slot)
70
+ else
71
+ # Create new subscription for this shard
72
+ client = @cluster_client.client_for(slot)
73
+ subscription = @subscriptions[slot] = client.ssubscribe(*channels_for_slot)
74
+
75
+ @barrier.async do
76
+ # This is optimistic, in other words, subscription.listen will also fail on close.
77
+ until subscription.closed?
78
+ @queue << subscription.listen
79
+ end
80
+ ensure
81
+ # If we are exiting here for any reason OTHER than the subscription was closed, we need to re-create the subscription state:
82
+ unless subscription.closed?
83
+ @queue.close
84
+ end
85
+ end
86
+ end
87
+ end
88
+
89
+ @channels.concat(channels)
90
+ end
91
+
92
+ # Unsubscribe from sharded channels.
93
+ # @parameter channels [Array(String)] The channels to unsubscribe from.
94
+ def unsubscribe(channels)
95
+ slots = @cluster_client.slots_for(channels)
96
+
97
+ slots.each do |slot, channels_for_slot|
98
+ if subscription = @subscriptions[slot]
99
+ subscription.sunsubscribe(channels_for_slot)
100
+
101
+ # Remove channels from our tracking
102
+ @channels -= channels_for_slot
103
+
104
+ # Check if this shard still has channels
105
+ remaining_channels_for_slot = @channels.select {|ch| @cluster_client.slot_for(ch) == slot}
106
+
107
+ # If no channels left for this shard, close and remove it
108
+ if remaining_channels_for_slot.empty?
109
+ @subscriptions.delete(slot)
110
+ subscription.close
111
+ end
112
+ end
113
+ end
114
+ end
115
+
116
+ # Get the list of currently subscribed channels.
117
+ # @returns [Array(String)] The list of subscribed channels.
118
+ def channels
119
+ @channels.dup
120
+ end
121
+
122
+ # Get the number of active shard subscriptions.
123
+ # @returns [Integer] The number of shard connections.
124
+ def shard_count
125
+ @subscriptions.size
126
+ end
127
+ end
128
+ end
129
+ end
@@ -22,12 +22,17 @@ module Async
22
22
 
23
23
  # Close the context and release the connection back to the pool.
24
24
  def close
25
- if @connection
26
- @pool.release(@connection)
25
+ if connection = @connection
27
26
  @connection = nil
27
+ @pool.release(connection)
28
28
  end
29
29
  end
30
30
 
31
+ # @returns [Boolean] Whether the context is closed.
32
+ def closed?
33
+ @connection.nil?
34
+ end
35
+
31
36
  # Write a Redis command request to the connection.
32
37
  # @parameter command [String] The Redis command.
33
38
  # @parameter arguments [Array] The command arguments.
@@ -2,7 +2,7 @@
2
2
 
3
3
  # Released under the MIT License.
4
4
  # Copyright, 2019, by David Ortiz.
5
- # Copyright, 2019-2024, by Samuel Williams.
5
+ # Copyright, 2019-2025, by Samuel Williams.
6
6
  # Copyright, 2022, by Tim Willard.
7
7
 
8
8
  require_relative "generic"
@@ -2,7 +2,7 @@
2
2
 
3
3
  # Released under the MIT License.
4
4
  # Copyright, 2018, by Huba Nagy.
5
- # Copyright, 2018-2024, by Samuel Williams.
5
+ # Copyright, 2018-2025, by Samuel Williams.
6
6
 
7
7
  require_relative "generic"
8
8
 
@@ -10,8 +10,10 @@ module Async
10
10
  module Redis
11
11
  module Context
12
12
  # Context for Redis pub/sub subscription operations.
13
- class Subscribe < Generic
13
+ class Subscription < Generic
14
14
  MESSAGE = "message"
15
+ PMESSAGE = "pmessage"
16
+ SMESSAGE = "smessage"
15
17
 
16
18
  # Initialize a new subscription context.
17
19
  # @parameter pool [Pool] The connection pool to use.
@@ -19,12 +21,12 @@ module Async
19
21
  def initialize(pool, channels)
20
22
  super(pool)
21
23
 
22
- subscribe(channels)
24
+ subscribe(channels) if channels.any?
23
25
  end
24
26
 
25
27
  # Close the subscription context.
26
28
  def close
27
- # There is no way to reset subscription state. On Redis v6+ you can use RESET, but this is not supported in <= v6.
29
+ # This causes anyone calling `#listen` to exit, as `read_response` will fail. If we decided to use `RESET` instead, we'd need to take that into account.
28
30
  @connection&.close
29
31
 
30
32
  super
@@ -34,7 +36,11 @@ module Async
34
36
  # @returns [Array] The next message response, or nil if connection closed.
35
37
  def listen
36
38
  while response = @connection.read_response
37
- return response if response.first == MESSAGE
39
+ type = response.first
40
+
41
+ if type == MESSAGE || type == PMESSAGE || type == SMESSAGE
42
+ return response
43
+ end
38
44
  end
39
45
  end
40
46
 
@@ -62,6 +68,34 @@ module Async
62
68
  @connection.write_request ["UNSUBSCRIBE", *channels]
63
69
  @connection.flush
64
70
  end
71
+
72
+ # Subscribe to channel patterns.
73
+ # @parameter patterns [Array(String)] The channel patterns to subscribe to.
74
+ def psubscribe(patterns)
75
+ @connection.write_request ["PSUBSCRIBE", *patterns]
76
+ @connection.flush
77
+ end
78
+
79
+ # Unsubscribe from channel patterns.
80
+ # @parameter patterns [Array(String)] The channel patterns to unsubscribe from.
81
+ def punsubscribe(patterns)
82
+ @connection.write_request ["PUNSUBSCRIBE", *patterns]
83
+ @connection.flush
84
+ end
85
+
86
+ # Subscribe to sharded channels (Redis 7.0+).
87
+ # @parameter channels [Array(String)] The sharded channels to subscribe to.
88
+ def ssubscribe(channels)
89
+ @connection.write_request ["SSUBSCRIBE", *channels]
90
+ @connection.flush
91
+ end
92
+
93
+ # Unsubscribe from sharded channels (Redis 7.0+).
94
+ # @parameter channels [Array(String)] The sharded channels to unsubscribe from.
95
+ def sunsubscribe(channels)
96
+ @connection.write_request ["SUNSUBSCRIBE", *channels]
97
+ @connection.flush
98
+ end
65
99
  end
66
100
  end
67
101
  end
@@ -58,11 +58,19 @@ module Async
58
58
  end
59
59
 
60
60
  # Construct an endpoint with a specified scheme, hostname, optional path, and options.
61
+ # If no scheme is provided, it will be auto-detected based on SSL context.
61
62
  #
62
- # @parameter scheme [String] The scheme to use, e.g. "redis" or "rediss".
63
+ # @parameter scheme [String, nil] The scheme to use, e.g. "redis" or "rediss". If nil, will auto-detect.
63
64
  # @parameter hostname [String] The hostname to connect to (or bind to).
64
65
  # @parameter options [Hash] Additional options, passed to {#initialize}.
65
66
  def self.for(scheme, host, credentials: nil, port: nil, database: nil, **options)
67
+ # Auto-detect scheme if not provided:
68
+ if default_scheme = options.delete(:scheme)
69
+ scheme ||= default_scheme
70
+ end
71
+
72
+ scheme ||= options.key?(:ssl_context) ? "rediss" : "redis"
73
+
66
74
  uri_klass = SCHEMES.fetch(scheme.downcase) do
67
75
  raise ArgumentError, "Unsupported scheme: #{scheme.inspect}"
68
76
  end
@@ -210,6 +218,8 @@ module Async
210
218
  end
211
219
  end
212
220
 
221
+ # Get the credentials for authentication.
222
+ # @returns [Array(String) | Nil] The username and password credentials or nil if not specified.
213
223
  def credentials
214
224
  @options[:credentials] || extract_userinfo(@url.userinfo)
215
225
  end
@@ -224,6 +234,8 @@ module Async
224
234
  end
225
235
  end
226
236
 
237
+ # Check if the endpoint is connecting to localhost.
238
+ # @returns [Boolean] True if connecting to localhost.
227
239
  def localhost?
228
240
  @url.hostname =~ /^(.*?\.)?localhost\.?$/
229
241
  end
@@ -237,6 +249,8 @@ module Async
237
249
  end
238
250
  end
239
251
 
252
+ # Get the SSL context for secure connections.
253
+ # @returns [OpenSSL::SSL::SSLContext] The SSL context configured for this endpoint.
240
254
  def ssl_context
241
255
  @options[:ssl_context] || OpenSSL::SSL::SSLContext.new.tap do |context|
242
256
  context.set_params(
@@ -245,6 +259,9 @@ module Async
245
259
  end
246
260
  end
247
261
 
262
+ # Build the underlying endpoint with optional SSL wrapping.
263
+ # @parameter endpoint [IO::Endpoint] Optional base endpoint to wrap.
264
+ # @returns [IO::Endpoint] The built endpoint, potentially wrapped with SSL.
248
265
  def build_endpoint(endpoint = nil)
249
266
  endpoint ||= tcp_endpoint
250
267
 
@@ -260,22 +277,33 @@ module Async
260
277
  return endpoint
261
278
  end
262
279
 
280
+ # Get the underlying endpoint, building it if necessary.
281
+ # @returns [IO::Endpoint] The underlying endpoint for connections.
263
282
  def endpoint
264
283
  @endpoint ||= build_endpoint
265
284
  end
266
285
 
286
+ # Set the underlying endpoint.
287
+ # @parameter endpoint [IO::Endpoint] The endpoint to wrap and use.
267
288
  def endpoint=(endpoint)
268
289
  @endpoint = build_endpoint(endpoint)
269
290
  end
270
291
 
292
+ # Bind to the endpoint and yield the server socket.
293
+ # @parameter arguments [Array] Arguments to pass to the underlying endpoint bind method.
294
+ # @yields [IO] The bound server socket.
271
295
  def bind(*arguments, &block)
272
296
  endpoint.bind(*arguments, &block)
273
297
  end
274
298
 
299
+ # Connect to the endpoint and yield the client socket.
300
+ # @yields [IO] The connected client socket.
275
301
  def connect(&block)
276
302
  endpoint.connect(&block)
277
303
  end
278
304
 
305
+ # Iterate over each possible endpoint variation.
306
+ # @yields [Endpoint] Each endpoint variant.
279
307
  def each
280
308
  return to_enum unless block_given?
281
309
 
@@ -284,14 +312,21 @@ module Async
284
312
  end
285
313
  end
286
314
 
315
+ # Get the key for hashing and equality comparison.
316
+ # @returns [Array] The key components for this endpoint.
287
317
  def key
288
318
  [@url, @options]
289
319
  end
290
320
 
321
+ # Check if this endpoint is equal to another.
322
+ # @parameter other [Endpoint] The other endpoint to compare with.
323
+ # @returns [Boolean] True if the endpoints are equal.
291
324
  def eql? other
292
325
  self.key.eql? other.key
293
326
  end
294
327
 
328
+ # Get the hash code for this endpoint.
329
+ # @returns [Integer] The hash code based on the endpoint's key.
295
330
  def hash
296
331
  self.key.hash
297
332
  end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2019-2023, by Samuel Williams.
4
+ # Copyright, 2019-2025, by Samuel Williams.
5
5
 
6
6
  module Async
7
7
  module Redis
@@ -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
 
6
6
  require "protocol/redis"
7
7
 
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Async
4
+ module Redis
5
+ # A map that stores ranges and their associated values for efficient lookup.
6
+ class RangeMap
7
+ # Initialize a new RangeMap.
8
+ def initialize
9
+ @ranges = []
10
+ end
11
+
12
+ # Add a range-value pair to the map.
13
+ # @parameter range [Range] The range to map.
14
+ # @parameter value [Object] The value to associate with the range.
15
+ # @returns [Object] The added value.
16
+ def add(range, value)
17
+ @ranges << [range, value]
18
+ return value
19
+ end
20
+
21
+ # Find the value associated with a key within any range.
22
+ # @parameter key [Object] The key to find.
23
+ # @yields {...} Block called if no range contains the key.
24
+ # @returns [Object] The value if found, result of block if given, or nil.
25
+ def find(key)
26
+ @ranges.each do |range, value|
27
+ return value if range.include?(key)
28
+ end
29
+ if block_given?
30
+ return yield
31
+ end
32
+ return nil
33
+ end
34
+
35
+ # Iterate over all values in the map.
36
+ # @yields {|value| ...} Block called for each value.
37
+ # @parameter value [Object] The value from the range-value pair.
38
+ def each
39
+ @ranges.each do |range, value|
40
+ yield value
41
+ end
42
+ end
43
+
44
+ # Get a random value from the map.
45
+ # @returns [Object] A randomly selected value, or nil if map is empty.
46
+ def sample
47
+ return nil if @ranges.empty?
48
+ range, value = @ranges.sample
49
+ return value
50
+ end
51
+
52
+ # Clear all ranges from the map.
53
+ def clear
54
+ @ranges.clear
55
+ end
56
+ end
57
+ end
58
+ end
@@ -2,7 +2,7 @@
2
2
 
3
3
  # Released under the MIT License.
4
4
  # Copyright, 2020, by David Ortiz.
5
- # Copyright, 2023-2024, by Samuel Williams.
5
+ # Copyright, 2023-2025, by Samuel Williams.
6
6
  # Copyright, 2024, by Joan Lledó.
7
7
 
8
8
  require_relative "client"
@@ -21,13 +21,15 @@ module Async
21
21
  #
22
22
  # @property endpoints [Array(Endpoint)] The list of sentinel endpoints.
23
23
  # @property master_name [String] The name of the master instance, defaults to 'mymaster'.
24
+ # @property master_options [Hash] Connection options for master instances.
25
+ # @property slave_options [Hash] Connection options for slave instances (defaults to master_options if not specified).
24
26
  # @property role [Symbol] The role of the instance that you want to connect to, either `:master` or `:slave`.
25
- # @property protocol [Protocol] The protocol to use when connecting to the actual Redis server, defaults to {Protocol::RESP2}.
26
- def initialize(endpoints, master_name: DEFAULT_MASTER_NAME, role: :master, protocol: Protocol::RESP2, **options)
27
+ def initialize(endpoints, master_name: DEFAULT_MASTER_NAME, master_options: nil, slave_options: nil, role: :master, **options)
27
28
  @endpoints = endpoints
28
29
  @master_name = master_name
30
+ @master_options = master_options || {}
31
+ @slave_options = slave_options || @master_options
29
32
  @role = role
30
- @protocol = protocol
31
33
 
32
34
  # A cache of sentinel connections.
33
35
  @sentinels = {}
@@ -94,9 +96,11 @@ module Async
94
96
  end
95
97
  end
96
98
 
99
+ private
100
+
97
101
  # Resolve the master endpoint address.
98
102
  # @returns [Endpoint | Nil] The master endpoint or nil if not found.
99
- def resolve_master
103
+ def resolve_master(options = @master_options)
100
104
  sentinels do |client|
101
105
  begin
102
106
  address = client.call("SENTINEL", "GET-MASTER-ADDR-BY-NAME", @master_name)
@@ -104,7 +108,7 @@ module Async
104
108
  next
105
109
  end
106
110
 
107
- return Endpoint.remote(address[0], address[1]) if address
111
+ return Endpoint.for(nil, address[0], port: address[1], **options) if address
108
112
  end
109
113
 
110
114
  return nil
@@ -112,7 +116,7 @@ module Async
112
116
 
113
117
  # Resolve a slave endpoint address.
114
118
  # @returns [Endpoint | Nil] A slave endpoint or nil if not found.
115
- def resolve_slave
119
+ def resolve_slave(options = @slave_options)
116
120
  sentinels do |client|
117
121
  begin
118
122
  reply = client.call("SENTINEL", "SLAVES", @master_name)
@@ -124,16 +128,14 @@ module Async
124
128
  next if slaves.empty?
125
129
 
126
130
  slave = select_slave(slaves)
127
- return Endpoint.remote(slave["ip"], slave["port"])
131
+ return Endpoint.for(nil, slave["ip"], port: slave["port"], **options)
128
132
  end
129
133
 
130
134
  return nil
131
135
  end
132
136
 
133
- protected
134
-
135
137
  def assign_default_tags(tags)
136
- tags[:protocol] = @protocol.to_s
138
+ tags[:role] ||= @role
137
139
  end
138
140
 
139
141
  # Override the parent method. The only difference is that this one needs to resolve the master/slave address.
@@ -145,7 +147,7 @@ module Async
145
147
  peer = endpoint.connect
146
148
  stream = ::IO::Stream(peer)
147
149
 
148
- @protocol.client(stream)
150
+ endpoint.protocol.client(stream)
149
151
  end
150
152
  end
151
153
 
@@ -5,6 +5,6 @@
5
5
 
6
6
  module Async
7
7
  module Redis
8
- VERSION = "0.11.2"
8
+ VERSION = "0.12.0"
9
9
  end
10
10
  end
data/lib/async/redis.rb CHANGED
@@ -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, 2020, by David Ortiz.
6
6
 
7
7
  require_relative "redis/version"
data/readme.md CHANGED
@@ -14,10 +14,19 @@ Please see the [project documentation](https://socketry.github.io/async-redis/)
14
14
 
15
15
  - [Getting Started](https://socketry.github.io/async-redis/guides/getting-started/index) - This guide explains how to use the `async-redis` gem to connect to a Redis server and perform basic operations.
16
16
 
17
+ - [Subscriptions](https://socketry.github.io/async-redis/guides/subscriptions/index) - This guide explains how to use Redis pub/sub functionality with `async-redis` to publish and subscribe to messages.
18
+
17
19
  ## Releases
18
20
 
19
21
  Please see the [project releases](https://socketry.github.io/async-redis/releases/index) for all releases.
20
22
 
23
+ ### v0.12.0
24
+
25
+ - Add agent context.
26
+ - Add support for pattern pub/sub.
27
+ - Add support for sharded pub/sub.
28
+ - Add support for `master_options` and `slave_options` (and removed `protocol`) from `SentinelClient`.
29
+
21
30
  ### v0.11.2
22
31
 
23
32
  - Fix handling of IPv6 address literals, including those returned by Redis Cluster / Sentinel.
data/releases.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Releases
2
2
 
3
+ ## v0.12.0
4
+
5
+ - Add agent context.
6
+ - Add support for pattern pub/sub.
7
+ - Add support for sharded pub/sub.
8
+ - Add support for `master_options` and `slave_options` (and removed `protocol`) from `SentinelClient`.
9
+
3
10
  ## v0.11.2
4
11
 
5
12
  - Fix handling of IPv6 address literals, including those returned by Redis Cluster / Sentinel.
data.tar.gz.sig CHANGED
Binary file
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: async-redis
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.11.2
4
+ version: 0.12.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Samuel Williams
@@ -113,30 +113,35 @@ dependencies:
113
113
  requirements:
114
114
  - - "~>"
115
115
  - !ruby/object:Gem::Version
116
- version: '0.9'
116
+ version: '0.11'
117
117
  type: :runtime
118
118
  prerelease: false
119
119
  version_requirements: !ruby/object:Gem::Requirement
120
120
  requirements:
121
121
  - - "~>"
122
122
  - !ruby/object:Gem::Version
123
- version: '0.9'
123
+ version: '0.11'
124
124
  executables: []
125
125
  extensions: []
126
126
  extra_rdoc_files: []
127
127
  files:
128
+ - context/getting-started.md
129
+ - context/index.yaml
130
+ - context/subscriptions.md
128
131
  - lib/async/redis.rb
129
132
  - lib/async/redis/client.rb
130
133
  - lib/async/redis/cluster_client.rb
134
+ - lib/async/redis/cluster_subscription.rb
131
135
  - lib/async/redis/context/generic.rb
132
136
  - lib/async/redis/context/pipeline.rb
133
- - lib/async/redis/context/subscribe.rb
137
+ - lib/async/redis/context/subscription.rb
134
138
  - lib/async/redis/context/transaction.rb
135
139
  - lib/async/redis/endpoint.rb
136
140
  - lib/async/redis/key.rb
137
141
  - lib/async/redis/protocol/authenticated.rb
138
142
  - lib/async/redis/protocol/resp2.rb
139
143
  - lib/async/redis/protocol/selected.rb
144
+ - lib/async/redis/range_map.rb
140
145
  - lib/async/redis/sentinel_client.rb
141
146
  - lib/async/redis/version.rb
142
147
  - license.md
@@ -162,7 +167,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
162
167
  - !ruby/object:Gem::Version
163
168
  version: '0'
164
169
  requirements: []
165
- rubygems_version: 3.6.7
170
+ rubygems_version: 3.6.9
166
171
  specification_version: 4
167
172
  summary: A Redis client library.
168
173
  test_files: []
metadata.gz.sig CHANGED
Binary file