@@ -3623,6 +3623,20 @@ def inner(*args, **kwargs):
36233623 return inner
36243624
36253625
3626+ def is_zero_key_eval_command (* args ) -> bool :
3627+ """
3628+ True for EVAL/EVALSHA with numkeys=0 (may run on any primary).
3629+ """
3630+ if len (args ) < 3 :
3631+ return False
3632+ if str (args [0 ]).upper () not in ("EVAL" , "EVALSHA" ):
3633+ return False
3634+ try :
3635+ return int (args [2 ]) == 0
3636+ except (TypeError , ValueError ):
3637+ return False
3638+
3639+
36263640# Blocked pipeline commands
36273641PIPELINE_BLOCKED_COMMANDS = (
36283642 "BGREWRITEAOF" ,
@@ -4112,20 +4126,26 @@ def _send_cluster_commands(
41124126 command_flag = self .command_flags .get (command )
41134127 if not command_flag :
41144128 # Fallback to default policy.
4115- # Use determine_slot (not _get_command_keys) so EVAL /
4116- # EVALSHA with numkeys=0 work on Redis <7, matching
4117- # the async ClusterPipeline path.
4118- if not self ._pipe .get_default_node ():
4119- slot = None
4120- else :
4121- slot = self ._pipe .determine_slot (* c .args )
4122- if slot is None :
4123- command_policies = CommandPolicies ()
4124- else :
4129+ # EVAL/EVALSHA must not use _get_command_keys(): Redis
4130+ # <7 breaks on COMMAND GETKEYS when numkeys is 0.
4131+ # Other unflagged commands keep the keyless fallback.
4132+ if command in ("EVAL" , "EVALSHA" ):
41254133 command_policies = CommandPolicies (
41264134 request_policy = RequestPolicy .DEFAULT_KEYED ,
41274135 response_policy = ResponsePolicy .DEFAULT_KEYED ,
41284136 )
4137+ else :
4138+ if not self ._pipe .get_default_node ():
4139+ keys = None
4140+ else :
4141+ keys = self ._pipe ._get_command_keys (* c .args )
4142+ if not keys or len (keys ) == 0 :
4143+ command_policies = CommandPolicies ()
4144+ else :
4145+ command_policies = CommandPolicies (
4146+ request_policy = RequestPolicy .DEFAULT_KEYED ,
4147+ response_policy = ResponsePolicy .DEFAULT_KEYED ,
4148+ )
41294149 else :
41304150 if command_flag in self ._pipe ._command_flags_mapping :
41314151 command_policies = CommandPolicies (
@@ -4419,13 +4439,43 @@ def __init__(self, pipe: ClusterPipeline):
44194439 self ._explicit_transaction = False
44204440 self ._watching = False
44214441 self ._pipeline_slots : Set [int ] = set ()
4442+ # True once a keyed (non-slot-agnostic) command has fixed the slot
4443+ self ._transaction_has_keyed_slot = False
44224444 self ._transaction_connection : Optional [Connection ] = None
44234445 self ._executing = False
44244446 self ._retry = copy (self ._pipe .retry )
44254447 self ._retry .update_supported_errors (
44264448 RedisCluster .ERRORS_ALLOW_RETRY + self .SLOT_REDIRECT_ERRORS
44274449 )
44284450
4451+ def _resolve_transaction_slot (self , * args ) -> Optional [int ]:
4452+ """
4453+ Pick a slot for a transactional pipeline command.
4454+
4455+ Zero-key EVAL/EVALSHA can run on any primary. Reuse an existing
4456+ transaction slot when present so multiple zero-key scripts (or a
4457+ mix with keyed commands) stay single-slot.
4458+ """
4459+ if args [0 ] in ClusterPipeline .NO_SLOTS_COMMANDS :
4460+ return None
4461+
4462+ if is_zero_key_eval_command (* args ):
4463+ if self ._pipeline_slots :
4464+ return next (iter (self ._pipeline_slots ))
4465+ return self ._pipe .determine_slot (* args )
4466+
4467+ slot_number = self ._pipe .determine_slot (* args )
4468+ if (
4469+ slot_number is not None
4470+ and self ._pipeline_slots
4471+ and slot_number not in self ._pipeline_slots
4472+ and not self ._transaction_has_keyed_slot
4473+ ):
4474+ # Prior slots came only from zero-key EVAL/EVALSHA; retarget.
4475+ self ._pipeline_slots .clear ()
4476+ self ._transaction_has_keyed_slot = True
4477+ return slot_number
4478+
44294479 def _get_client_and_connection_for_transaction (self ) -> Tuple [Redis , Connection ]:
44304480 """
44314481 Find a connection for a pipeline transaction.
@@ -4462,7 +4512,7 @@ def _get_client_and_connection_for_transaction(self) -> Tuple[Redis, Connection]
44624512 def execute_command (self , * args , ** kwargs ):
44634513 slot_number : Optional [int ] = None
44644514 if args [0 ] not in ClusterPipeline .NO_SLOTS_COMMANDS :
4465- slot_number = self ._pipe . determine_slot (* args )
4515+ slot_number = self ._resolve_transaction_slot (* args )
44664516
44674517 if (
44684518 self ._watching or args [0 ] in self .IMMEDIATE_EXECUTE_COMMANDS
@@ -4787,6 +4837,7 @@ def reset(self):
47874837 self ._watching = False
47884838 self ._explicit_transaction = False
47894839 self ._pipeline_slots = set ()
4840+ self ._transaction_has_keyed_slot = False
47904841 self ._executing = False
47914842
47924843 def send_cluster_commands (
0 commit comments