Skip to content

Commit c5cf8e3

Browse files
author
KJ Tsanaktsidis
authored
Implement RedisClient::Cluster#with (again!) (#311)
1 parent 9b640c5 commit c5cf8e3

File tree

4 files changed

+342
-10
lines changed

4 files changed

+342
-10
lines changed

.rubocop.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ Metrics/ClassLength:
1919

2020
Metrics/ModuleLength:
2121
Max: 500
22+
Exclude:
23+
- 'test/**/*'
2224

2325
Metrics/MethodLength:
2426
Max: 50

README.md

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,91 @@ cli.call('MGET', '{key}1', '{key}2', '{key}3')
168168
#=> [nil, nil, nil]
169169
```
170170

171+
## Transactions
172+
This gem supports [Redis transactions](https://redis.io/topics/transactions), including atomicity with `MULTI`/`EXEC`,
173+
and conditional execution with `WATCH`. Redis does not support cross-node transactions, so all keys used within a
174+
transaction must live in the same key slot. To use transactions, you must thus "pin" your client to a single connection using
175+
`#with`. You can pass a single key, in order to perform multiple operations atomically on the same key, like so:
176+
177+
```ruby
178+
cli.with(key: 'my_cool_key') do |conn|
179+
conn.multi do |m|
180+
m.call('INC', 'my_cool_key')
181+
m.call('INC', 'my_cool_key')
182+
end
183+
# my_cool_key will be incremented by 2, with no intermediate state visible to other clients
184+
end
185+
```
186+
187+
More commonly, however, you will want to perform transactions across multiple keys. To do this, you need to ensure that all keys used in the transaction hash to the same slot; Redis a mechanism called [hashtags](https://redis.io/docs/reference/cluster-spec/#hash-tags) to achieve this. If a key contains a hashag (e.g. in the key `{foo}bar`, the hashtag is `foo`), then it is guaranted to hash to the same slot (and thus always live on the same node) as other keys which contain the same hashtag.
188+
189+
So, whilst it's not possible in Redis cluster to perform a transction on the keys `foo` and `bar`, it _is_ possible to perform a transaction on the keys `{tag}foo` and `{tag}bar`. To perform such transactions on this gem, pass `hashtag:` to `#with` instead of `key`:
190+
191+
```ruby
192+
cli.with(hashtag: 'user123') do |conn|
193+
# You can use any key which contains "{user123}" in this block
194+
conn.multi do |m|
195+
m.call('INC', '{user123}coins_spent')
196+
m.call('DEC', '{user123}coins_available')
197+
end
198+
end
199+
```
200+
201+
Once you have pinned a client to a particular slot, you can use the same transaction APIs as the
202+
[redis-client](https://github.com/redis-rb/redis-client#usage) gem allows.
203+
```ruby
204+
# No concurrent client will ever see the value 1 in 'mykey'; it will see either zero or two.
205+
cli.call('SET', 'key', 0)
206+
cli.with(key: 'key') do |conn|
207+
conn.multi do |txn|
208+
txn.call('INCR', 'key')
209+
txn.call('INCR', 'key')
210+
end
211+
#=> ['OK', 'OK']
212+
end
213+
# Conditional execution with WATCH can be used to e.g. atomically swap two keys
214+
cli.call('MSET', '{myslot}1', 'v1', '{myslot}2', 'v2')
215+
cli.with(hashtag: 'myslot') do |conn|
216+
conn.call('WATCH', '{myslot}1', '{myslot}2')
217+
conn.multi do |txn|
218+
old_key1 = conn.call('GET', '{myslot}1')
219+
old_key2 = conn.call('GET', '{myslot}2')
220+
txn.call('SET', '{myslot}1', old_key2)
221+
txn.call('SET', '{myslot}2', old_key1)
222+
end
223+
# This transaction will swap the values of {myslot}1 and {myslot}2 only if no concurrent connection modified
224+
# either of the values
225+
end
226+
# You can also pass watch: to #multi as a shortcut
227+
cli.call('MSET', '{myslot}1', 'v1', '{myslot}2', 'v2')
228+
cli.with(hashtag: 'myslot') do |conn|
229+
conn.multi(watch: ['{myslot}1', '{myslot}2']) do |txn|
230+
old_key1, old_key2 = conn.call('MGET', '{myslot}1', '{myslot}2')
231+
txn.call('MSET', '{myslot}1', old_key2, '{myslot}2', old_key1)
232+
end
233+
end
234+
```
235+
236+
Pinned connections are aware of redirections and node failures like ordinary calls to `RedisClient::Cluster`, but because
237+
you may have written non-idempotent code inside your block, the block is not automatically retried if e.g. the slot
238+
it is operating on moves to a different node. If you want this, you can opt-in to retries by passing nonzero
239+
`retry_count` to `#with`.
240+
```ruby
241+
cli.with(hashtag: 'myslot', retry_count: 1) do |conn|
242+
conn.call('GET', '{myslot}1')
243+
#=> "value1"
244+
# Now, some changes in cluster topology mean that {key} is moved to a different node!
245+
conn.call('GET', '{myslot}2')
246+
#=> MOVED 9039 127.0.0.1:16381 (RedisClient::CommandError)
247+
# Luckily, the block will get retried (once) and so both GETs will be re-executed on the newly-discovered
248+
# correct node.
249+
end
250+
```
251+
252+
Because `RedisClient` from the redis-client gem implements `#with` as simply `yield self` and ignores all of its
253+
arguments, it's possible to write code which is compatible with both redis-client and redis-cluster-client; the `#with`
254+
call will pin the connection to a slot when using clustering, or be a no-op when not.
255+
171256
## ACL
172257
The cluster client internally calls [COMMAND](https://redis.io/commands/command/) and [CLUSTER NODES](https://redis.io/commands/cluster-nodes/) commands to operate correctly.
173258
So please permit it like the followings.

lib/redis_client/cluster.rb

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,17 @@ def multi(watch: nil, &block)
9393
::RedisClient::Cluster::Transaction.new(@router, @command_builder).execute(watch: watch, &block)
9494
end
9595

96+
def with(key: nil, hashtag: nil, write: true, retry_count: 0, &block)
97+
key = process_with_arguments(key, hashtag)
98+
99+
node_key = @router.find_node_key_by_key(key, primary: write)
100+
node = @router.find_node(node_key)
101+
# Calling #with checks out the underlying connection if this is a pooled connection
102+
# Calling it through #try_delegate ensures we handle any redirections and retry the entire
103+
# transaction if so.
104+
@router.try_delegate(node, :with, retry_count: retry_count, &block)
105+
end
106+
96107
def pubsub
97108
::RedisClient::Cluster::PubSub.new(@router, @command_builder)
98109
end
@@ -105,6 +116,19 @@ def close
105116

106117
private
107118

119+
def process_with_arguments(key, hashtag) # rubocop:disable Metrics/CyclomaticComplexity
120+
raise ArgumentError, 'Only one of key or hashtag may be provided' if key && hashtag
121+
122+
if hashtag
123+
# The documentation says not to wrap your hashtag in {}, but people will probably
124+
# do it anyway and it's easy for us to fix here.
125+
key = hashtag&.match?(/^{.*}$/) ? hashtag : "{#{hashtag}}"
126+
end
127+
raise ArgumentError, 'One of key or hashtag must be provided' if key.nil? || key.empty?
128+
129+
key
130+
end
131+
108132
def method_missing(name, *args, **kwargs, &block)
109133
if @router.command_exists?(name)
110134
args.unshift(name)

0 commit comments

Comments
 (0)