-
Notifications
You must be signed in to change notification settings - Fork 10
Validate key slots used with RedisCluster::Client#with #314
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
Closed
KJTsanaktsidis
wants to merge
5
commits into
redis-rb:master
from
zendesk:ktsanaktsidis/validate_keys
Closed
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
7e6c5e7
Use a custom subclass of RedisClient for the node clients
5c01be5
Push command construction down into the Node object
7a1b83d
Make Commands a long-lived object
13b3153
Finish drilling @command object into client connection
b0a0d8a
Implement validation of slot numbers in #with
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -8,6 +8,7 @@ | |
require 'redis_client/cluster/node/random_replica' | ||
require 'redis_client/cluster/node/random_replica_or_primary' | ||
require 'redis_client/cluster/node/latency_replica' | ||
require 'redis_client/cluster/pinning' | ||
|
||
class RedisClient | ||
class Cluster | ||
|
@@ -78,14 +79,25 @@ def []=(index, element) | |
end | ||
end | ||
|
||
class SingleNodeRedisClient < ::RedisClient | ||
include Pinning::ClientMixin | ||
end | ||
|
||
class Config < ::RedisClient::Config | ||
def initialize(scale_read: false, middlewares: nil, **kwargs) | ||
def initialize(cluster_commands:, scale_read: false, middlewares: nil, **kwargs) | ||
@scale_read = scale_read | ||
@cluster_commands = cluster_commands | ||
middlewares ||= [] | ||
middlewares.unshift Pinning::ClientMiddleware | ||
middlewares.unshift ErrorIdentification::Middleware | ||
super(middlewares: middlewares, **kwargs) | ||
super( | ||
middlewares: middlewares, | ||
client_implementation: SingleNodeRedisClient, | ||
**kwargs) | ||
end | ||
|
||
attr_reader :cluster_commands | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What's the difference between There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Sorry, I misread it. Forget about that. |
||
|
||
private | ||
|
||
def build_connection_prelude | ||
|
@@ -106,11 +118,15 @@ def initialize( | |
@slots = build_slot_node_mappings(EMPTY_ARRAY) | ||
@replications = build_replication_mappings(EMPTY_ARRAY) | ||
klass = make_topology_class(config.use_replica?, config.replica_affinity) | ||
@topology = klass.new(pool, @concurrent_worker, **kwargs) | ||
@command = ::RedisClient::Cluster::Command.new | ||
@base_connection_configuration = { **kwargs, cluster_commands: @command } | ||
@topology = klass.new(pool, @concurrent_worker, **@base_connection_configuration) | ||
@config = config | ||
@mutex = Mutex.new | ||
end | ||
|
||
attr_reader :command | ||
|
||
def inspect | ||
"#<#{self.class.name} #{node_keys.join(', ')}>" | ||
end | ||
|
@@ -212,6 +228,13 @@ def reload! | |
end | ||
@slots = build_slot_node_mappings(@node_info) | ||
@replications = build_replication_mappings(@node_info) | ||
|
||
# Call COMMAND to find out the commands available on this cluster. We only call this once | ||
# the first time the client is constructed; if you perform a rolling update to a new version | ||
# of Redis, for example, applications won't know about the new commands available until they | ||
# construct new client objects (or, more likely, are restarted). | ||
@command.load!(startup_clients, slow_command_timeout: @config.slow_command_timeout) unless @command.loaded? | ||
|
||
@topology.process_topology_update!(@replications, @node_configs) | ||
end | ||
end | ||
|
@@ -404,7 +427,7 @@ def with_startup_clients(count) # rubocop:disable Metrics/AbcSize | |
# Memoize the startup clients, so we maintain RedisClient's internal circuit breaker configuration | ||
# if it's set. | ||
@startup_clients ||= @config.startup_nodes.values.sample(count).map do |node_config| | ||
::RedisClient::Cluster::Node::Config.new(**node_config).new_client | ||
::RedisClient::Cluster::Node::Config.new(**@base_connection_configuration.merge(node_config)).new_client | ||
end | ||
yield @startup_clients | ||
ensure | ||
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,59 @@ | ||
# frozen_string_literal: true | ||
|
||
class RedisClient | ||
class Cluster | ||
module Pinning | ||
module ClientMixin | ||
attr_reader :locked_key_slot | ||
|
||
# This gets called when handing out connections in Cluster#with to lock the returned | ||
# connections to a given slot. | ||
def locked_to_key_slot(key_slot) | ||
raise ArgumentError, 'recursive slot locking is not allowed' if @locked_key_slot | ||
|
||
begin | ||
@locked_key_slot = key_slot | ||
yield | ||
ensure | ||
@locked_key_slot = nil | ||
end | ||
end | ||
end | ||
|
||
# This middleware is what actually enforces the slot locking above. | ||
module ClientMiddleware | ||
def initialize(client) | ||
@client = client | ||
super | ||
end | ||
|
||
def assert_slot_valid!(command, config) # rubocop:disable Metrics/AbcSize | ||
return unless @client.locked_key_slot | ||
return unless config.cluster_commands.loaded? | ||
|
||
keys = config.cluster_commands.extract_all_keys(command) | ||
key_slots = keys.map { |k| ::RedisClient::Cluster::KeySlotConverter.convert(k) } | ||
locked_slot = ::RedisClient::Cluster::KeySlotConverter.convert(@client.locked_key_slot) | ||
return if key_slots.all? { |slot| slot == locked_slot } | ||
|
||
key_slot_pairs = keys.zip(key_slots).map { |key, slot| "#{key} => #{slot}" }.join(', ') | ||
raise ::RedisClient::Cluster::Transaction::ConsistencyError, <<~MESSAGE | ||
Connection is pinned to slot #{locked_slot} (via key #{@client.locked_key_slot}). \ | ||
However, command #{command.inspect} has keys hashing to slots #{key_slot_pairs}. \ | ||
Transactions in redis cluster must only refer to keys hashing to the same slot. | ||
MESSAGE | ||
end | ||
|
||
def call(command, config) | ||
assert_slot_valid!(command, config) | ||
super | ||
end | ||
|
||
def call_pipelined(command, config) | ||
assert_slot_valid!(command, config) | ||
super | ||
end | ||
end | ||
end | ||
end | ||
end |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think the following complexity as you said is derived from this mixin and middleware pattern.
I think it would be better to use a simple wrapper object for the slot validation.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We can do this, but it's a little awkward because of the need to re-wrap the client if
pipelined
ormulti
is called. It ends up looking like this: https://github.com/redis-rb/redis-cluster-client/pull/298/files#diff-426e610bbc89f748ae5f9d43790b07d10fed426fa431d6bea29b67f649a7eb72R1 which i remember we were not very happy with.Most of the complexity here is coming from the fact that we have to change the lifecycle of the
RedisClient::Cluster::Command
object so that it's available for the pinning middleware. If we made our customRedisClient
subclass support some kind of temporary "validation procs" itself, then theRedisCluster::Client
instance can construct the validation proc only when#with
is called and@commands
is already known.I think that will be a lot simpler - let me try that first.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think we can omit
#pipelined
and#multi
in#with
method. There is no user to use in the odd way. We can't never care about all use cases completely. Our users should have responsible to use it method.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We definitely need
#multi
at least - the whole point is to enable single shard transactions!I managed to write a much better implementation of the delegator in #298 today though - expect to see another PR tomorrow!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I thought the
#multi
methods is holded by the cluster client.Anyway, I think we shouldn't do overengineering.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
No, my plan was to delete the
#multi
implementation inRedisClient::Cluster
(or, at least, re-implement it in terms ofRedisClient::Cluster#with
.My understanding was that the original idea of adding this kind of “pinning” was to avoid needing to implement transaction support in
RedisClient::Cluster
at all - instead users can issue transactions directly against the correct node selected by#with
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm wondering your decision. In Redis cluster, I think users should use the transaction feature as simple because CAP theory. I've thought the
#with
method is just an optional way to be able to use the transaction feature with some complecated ways in cluster mode. I've thought it's not the main interface for the transaction feature. It's a bit different from the redis-client. Please give me time to think it.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Remember, the original problem I was solving was that
RedisClient::Cluster#multi
currently calls the transaction block twice - once itself to figure out what node to send the transaction to, and thenRedisClient#multi
calls it again to actually build up the transaction to execute. This interface is also different from what's inRedisClient
!From my pretty extensive survey of the options here over the last few months, there's a pretty fundamental tradeoff to make here:
RedisClient#multi
, you must know the node ahead of time. You can't lazily callRedisClient#multi
once you see the first command in the transaction, because you've already yielded control back to the caller, and can't give the caller the realRedisClient::Multi
object without yielding again (which was the original bug I was trying to solve!).RedisClient::Cluster#multi
to work without knowing the node ahead of time (and thus, make it fully compatible), the only choice is to implement a kind of 'lazy transaction' system like I did in Support watch & unwatch properly #295. That is, you wait for the first command in the transaction, then issue MULTI on the appropriate node (and, thus, the redis-cluster-client must also take responsibility for calling EXEC/DISCARD/UNWATCH when appropriate too). This will work when issuingclient.call('MULTI') ... client.call('EXEC')
, which is valid too withRedisClient
.My understanding is that we settled on the
#with
interface as the best way to implement "knowing the node ahead of time", because you can also make the same code still work with RedisClient (sinceRedisClient#with
does nothing - like I wrote in the docs here:redis-cluster-client/README.md
Lines 253 to 255 in 86c0e01
If we want to make
RedisClient::Cluster#multi
keep working how it is now, for backwards compatibility, we can keep its current implementation. However going forward I think we should be recommending people to useRedisClient::Cluter#with
since it doesn't have the calls-block-twice behaviour.For compatability with
RedisClient
, an approach like #295 would be better - it would let us behave exactly like a drop in replacement forRedisClient
.