-
Notifications
You must be signed in to change notification settings - Fork 9
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Fix MULTI transactions calling the block twice #294
Conversation
You might want to do things in the multi block such as read a key that you passed to `watch:`. Currently, if you do so, you will actually read the key _twice_. The second copy of your transaction is the one that actually gets committed. For example, this transaction adds _two_ to "key", not one: ``` $redis.multi(watch: ["key"]) do |m| old_value = $redis.call_v(["GET", "key"]).to_i m.call_v(["SET", old_value + 1]) end ``` This patch fixes the issue by batching up the transaction's calls in our _own_ buffer, and only invoking @node.multi after the user block is done.
This is almost certainly what you want, because otherwise the watching is totally inneffective (you might have already read a stale value from the replica that was already updated before you WATCH'd the primary)
Thank you for your pull request. Please give me some time to look into it. |
Thank you @supercaracal - of course, this stuff is incredibly complicated so it's worth going over carefully! Just as an update - I had another go at fixing the "What this PR doesn't do" problem with using redis-cluster-client through redis-rb's The approach in that PR is to add the idea of an "implicit transaction". Essentially, the client starts a The That PR makes my not-working example above actually work; I wrote a series of tests in redis-rb which will pass with that other PR: redis/redis-rb@master...zendesk:redis-rb:ktsanaktsidis/cluster_txn_tests The "implicit transaction" approach certainly adds some complexity to the implementation of redis-cluster-client. The interface between redis-rb and redis-client for single-node A better interface for doing watches with cluster client is something like this, I think:
That makes the implicit state of the transaction created by tl;dr: should we consider the approach in #295 instead? |
I'd say that the redis-cluster-client should be simple. So I'd like to allow only an interface for transactions feature by single-method calling with a block. Also I want to deny an independent calling by a WATCH command with throwing an error. Of course the redis-cluster-client should be aware of the redis-clustering usability, but I'd say that it would be better to prevent to be affected by the design too much. I think your idea to use redis = RedisClient.cluster.new_client
redis.with(key: '{key}1', primary: true) do |r|
# r is an instance of RedisClient
r.multi(watch: %w[{key}1 {key}2]) do |tx|
# tx is implemented by RedisClient
v1 = r.call('GET', '{key}1')
v2 = r.call('GET', '{key}2')
tx.call('SET', '{key}1', v2)
tx.call('SET', '{key}2', v1)
end
end
redis.multi(watch: %w[{key}1 {key}2]) do |tx|
# tx is implemented by RedisClient::Cluster
tx.call('INCR', '{key}1')
tx.call('INCR', '{key}2')
end |
Actually I realise it was a mistake in my original message. When I wrote
The idea is that calling I guess the implementation would be that The really good thing about this is that
i.e. it accepts and ignores options; so, you can easily write code which is compatible with both Regarding redis-clustering, I think if we have this interface in redis-cluster-client, we can implement a I'll play around with implementing this + the redis-cluster-client side this week and open another PR so we can compare the approach - does that work for you? Thanks so much for giving this some deep thought by the way! |
We are on the same page.
Yes, it does. Thank you so much! |
What this PR does
Currently using a transaction with
RedisClient::Cluster#multi
actually calls the provided block twice: Once inRedisClient::Cluster::Transaction#execute
to work out what node to send the transaction to, and then once again inRedisClient#multi
to actually execute the thing. Also, the first time the block is called, the keys are not watched yet, so any get calls you make in there are stale.This PR fixes the issue by building up a buffer of transaction commands to execute when yielding the block passed to
#multi
, and then yielding that buffer intoRedisClient#multi
instead of the user-provided block again. That way, the block only gets called once.This PR also adds in some machinery to make sure that any commands run on the client during a
#multi
block happen on the primary; this is almost always what you want to be doing inside a transaction. If you watch a key on a primary but then read it from the replica, it's possible for you to read a stale value that was already known to the primary when you calledWATCH
; you will then build up a transaction from stale data and yourEXEC
will succeed where you would expect it to fail.With this PR, it's possible to do e.g. an atomic swap of two variables with something like this:
Note, though, that it's not possible to actually pass
watch:
keys tomulti
if you're usingredis-cluster-client
through theredis-rb
andredis-clustering
gems. That should be a very simple fix however: redis/redis-rb#1236What this PR doesn't do
Even with this change, using
Redis::Cluster#multi
fromredis-clustering
/redis-rb
is pretty wonky. Something like this does work and swap the keys:But, support for unwatching and not committing the transaction is broken. This throws
RedisClient::Cluster::AmbiguousNodeError
because the router doesn't know what node to sendUNWATCH
to.This happens because there's nothing storing the idea that a transaction is happening around the entire
watch
block; theRedis::Cluster::Transaction
is only created around a call to#multi
. On a similar note, nothing stops you from watching or getting keys from multiple different hash slots in your transaction. For example, this will work, but shouldn't.I played around for a couple of days trying to work out the right way to fix this, but didn't really land on anything I like. I'm open to ideas if you can think of any good way to do this though. The closest I came up with was this idea about maintaining some transaction state in
RedisCluster::Client
, but the thing that makes it tricky is thatRedis::Cluster#watch
(which is actuallyRedis::Commands::Transactions#watch
) just doescall_v([:watch] + keys])
and thensend_command([:unwatch])
in a begin/rescue block; it doesn't pass the watch block toRedisClient::Cluster
at all to invoke which means it's difficult to maintain our own state reliably in redis-clustering.Anyway, as I said, if you can think of a way to make this work nicely I'm all ears.
Thanks for your review!