Skip to content

Commit 649a36b

Browse files
committed
fix: address EVALSHA ClusterPipeline review feedback
1 parent 3bbd5d8 commit 649a36b

5 files changed

Lines changed: 221 additions & 17 deletions

File tree

docs/lua_scripting.rst

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -114,8 +114,11 @@ The following commands are not supported:
114114

115115
``EVALSHA`` can be used inside a ``ClusterPipeline``. Keys must still map to
116116
the same hash slot (or ``numkeys`` may be ``0``, in which case the command is
117-
routed to a random primary). The script must already be loaded on the target
118-
node, for example via ``SCRIPT LOAD`` on the cluster client, which loads the
119-
script on all primaries. Other scripting helpers such as ``EVAL``,
120-
``load_scripts``, and ``script_load_for_pipeline`` remain unsupported on
121-
cluster pipelines.
117+
routed to a random primary). In a transactional pipeline
118+
(``pipeline(transaction=True)``), zero-key ``EVALSHA`` reuses the
119+
transaction's existing slot when one is already chosen so multiple zero-key
120+
scripts (or a mix with keyed commands) stay single-slot. The script must
121+
already be loaded on the target node, for example via ``SCRIPT LOAD`` on the
122+
cluster client, which loads the script on all primaries. Other scripting
123+
helpers such as ``EVAL``, ``load_scripts``, and ``script_load_for_pipeline``
124+
remain unsupported on cluster pipelines.

redis/asyncio/cluster.py

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@
7575
LoadBalancingStrategy,
7676
block_pipeline_command,
7777
get_node_name,
78+
is_zero_key_eval_command,
7879
parse_cluster_shards,
7980
parse_cluster_shards_unified,
8081
parse_cluster_shards_with_str_keys,
@@ -2917,6 +2918,8 @@ def __init__(self, pipe: ClusterPipeline) -> None:
29172918
self._explicit_transaction = False
29182919
self._watching = False
29192920
self._pipeline_slots: Set[int] = set()
2921+
# True once a keyed (non-slot-agnostic) command has fixed the slot
2922+
self._transaction_has_keyed_slot = False
29202923
self._transaction_node: Optional[ClusterNode] = None
29212924
self._transaction_connection: Optional[Connection] = None
29222925
self._executing = False
@@ -2925,6 +2928,34 @@ def __init__(self, pipe: ClusterPipeline) -> None:
29252928
RedisCluster.ERRORS_ALLOW_RETRY + self.SLOT_REDIRECT_ERRORS
29262929
)
29272930

2931+
async def _resolve_transaction_slot(self, *args) -> Optional[int]:
2932+
"""
2933+
Pick a slot for a transactional pipeline command.
2934+
2935+
Zero-key EVAL/EVALSHA can run on any primary. Reuse an existing
2936+
transaction slot when present so multiple zero-key scripts (or a
2937+
mix with keyed commands) stay single-slot.
2938+
"""
2939+
if args[0] in self.NO_SLOTS_COMMANDS:
2940+
return None
2941+
2942+
if is_zero_key_eval_command(*args):
2943+
if self._pipeline_slots:
2944+
return next(iter(self._pipeline_slots))
2945+
return await self._pipe.cluster_client._determine_slot(*args)
2946+
2947+
slot_number = await self._pipe.cluster_client._determine_slot(*args)
2948+
if (
2949+
slot_number is not None
2950+
and self._pipeline_slots
2951+
and slot_number not in self._pipeline_slots
2952+
and not self._transaction_has_keyed_slot
2953+
):
2954+
# Prior slots came only from zero-key EVAL/EVALSHA; retarget.
2955+
self._pipeline_slots.clear()
2956+
self._transaction_has_keyed_slot = True
2957+
return slot_number
2958+
29282959
def _get_client_and_connection_for_transaction(
29292960
self,
29302961
) -> Tuple[ClusterNode, Connection]:
@@ -2982,7 +3013,7 @@ async def _execute_command(
29823013

29833014
slot_number: Optional[int] = None
29843015
if args[0] not in self.NO_SLOTS_COMMANDS:
2985-
slot_number = await self._pipe.cluster_client._determine_slot(*args)
3016+
slot_number = await self._resolve_transaction_slot(*args)
29863017

29873018
if (
29883019
self._watching or args[0] in self.IMMEDIATE_EXECUTE_COMMANDS
@@ -3327,6 +3358,7 @@ async def reset(self):
33273358
self._watching = False
33283359
self._explicit_transaction = False
33293360
self._pipeline_slots = set()
3361+
self._transaction_has_keyed_slot = False
33303362
self._executing = False
33313363

33323364
def multi(self):

redis/cluster.py

Lines changed: 62 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -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
36273641
PIPELINE_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(

tests/test_asyncio/test_cluster.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3863,6 +3863,74 @@ async def test_blocked_methods(self, r: RedisCluster) -> None:
38633863
"when running redis in cluster mode..."
38643864
)
38653865

3866+
async def test_evalsha_not_blocked_on_cluster_pipeline(self) -> None:
3867+
"""EVALSHA must be usable on async ClusterPipeline (see #2914)."""
3868+
assert "EVALSHA" not in PIPELINE_BLOCKED_COMMANDS
3869+
r = await get_mocked_redis_client(host=default_host, port=default_port)
3870+
try:
3871+
pipe = r.pipeline()
3872+
sha = "a" * 40
3873+
returned = pipe.evalsha(sha, 1, "foo", "bar")
3874+
assert returned is pipe
3875+
queue = pipe._execution_strategy._command_queue
3876+
assert len(queue) == 1
3877+
assert queue[0].args == ("EVALSHA", sha, 1, "foo", "bar")
3878+
finally:
3879+
await r.aclose()
3880+
3881+
async def test_evalsha_zero_keys_pipeline_execute(self) -> None:
3882+
"""Async ClusterPipeline must execute EVALSHA with numkeys=0."""
3883+
r = await get_mocked_redis_client(host=default_host, port=default_port)
3884+
try:
3885+
mock_all_nodes_resp(r, 42)
3886+
sha = "a" * 40
3887+
async with r.pipeline() as pipe:
3888+
pipe.evalsha(sha, 0)
3889+
assert await pipe.execute() == [42]
3890+
finally:
3891+
await r.aclose()
3892+
3893+
async def test_evalsha_zero_keys_reuses_slot_in_transaction(self) -> None:
3894+
"""Zero-key EVALSHA in a transactional async pipeline reuses one slot."""
3895+
r = await get_mocked_redis_client(host=default_host, port=default_port)
3896+
try:
3897+
sha = "a" * 40
3898+
async with r.pipeline(transaction=True) as pipe:
3899+
pipe.evalsha(sha, 0)
3900+
pipe.evalsha(sha, 0)
3901+
slots = pipe._execution_strategy._pipeline_slots
3902+
assert len(slots) == 1
3903+
3904+
keyed_slot = key_slot(b"foo")
3905+
3906+
async def _fake_determine_slot(*_args, **_kwargs):
3907+
return keyed_slot
3908+
3909+
with mock.patch.object(
3910+
pipe.cluster_client,
3911+
"_determine_slot",
3912+
side_effect=_fake_determine_slot,
3913+
):
3914+
pipe.set("foo", "bar")
3915+
assert pipe._execution_strategy._pipeline_slots == {keyed_slot}
3916+
assert pipe._execution_strategy._transaction_has_keyed_slot is True
3917+
finally:
3918+
await r.aclose()
3919+
3920+
async def test_evalsha_zero_keys_follows_keyed_slot_in_transaction(self) -> None:
3921+
"""Zero-key EVALSHA after a keyed slot is fixed reuses that slot."""
3922+
r = await get_mocked_redis_client(host=default_host, port=default_port)
3923+
try:
3924+
sha = "a" * 40
3925+
async with r.pipeline(transaction=True) as pipe:
3926+
strategy = pipe._execution_strategy
3927+
strategy._pipeline_slots = {key_slot(b"foo")}
3928+
strategy._transaction_has_keyed_slot = True
3929+
pipe.evalsha(sha, 0)
3930+
assert strategy._pipeline_slots == {key_slot(b"foo")}
3931+
finally:
3932+
await r.aclose()
3933+
38663934
async def test_empty_stack(self, r: RedisCluster) -> None:
38673935
"""If a pipeline is executed with no commands it should return a empty list."""
38683936
p = r.pipeline()

tests/test_cluster.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3886,6 +3886,45 @@ def test_evalsha_zero_keys_pipeline_execute_skips_get_command_keys():
38863886
r.close()
38873887

38883888

3889+
def test_evalsha_zero_keys_reuses_slot_in_transaction():
3890+
"""
3891+
Zero-key EVALSHA in a transactional pipeline must reuse one slot so
3892+
execute() does not raise CrossSlotTransactionError.
3893+
"""
3894+
r = get_mocked_redis_client(host=default_host, port=default_port)
3895+
try:
3896+
sha = "a" * 40
3897+
with r.pipeline(transaction=True) as pipe:
3898+
pipe.evalsha(sha, 0)
3899+
pipe.evalsha(sha, 0)
3900+
slots = pipe._execution_strategy._pipeline_slots
3901+
assert len(slots) == 1
3902+
3903+
# Keyed follow-up must retarget without needing a live COMMAND map.
3904+
keyed_slot = key_slot(b"foo")
3905+
with patch.object(pipe, "determine_slot", return_value=keyed_slot):
3906+
pipe.set("foo", "bar")
3907+
assert pipe._execution_strategy._pipeline_slots == {keyed_slot}
3908+
assert pipe._execution_strategy._transaction_has_keyed_slot is True
3909+
finally:
3910+
r.close()
3911+
3912+
3913+
def test_evalsha_zero_keys_follows_keyed_slot_in_transaction():
3914+
"""Zero-key EVALSHA after a keyed slot is fixed reuses that slot."""
3915+
r = get_mocked_redis_client(host=default_host, port=default_port)
3916+
try:
3917+
sha = "a" * 40
3918+
with r.pipeline(transaction=True) as pipe:
3919+
strategy = pipe._execution_strategy
3920+
strategy._pipeline_slots = {key_slot(b"foo")}
3921+
strategy._transaction_has_keyed_slot = True
3922+
pipe.evalsha(sha, 0)
3923+
assert strategy._pipeline_slots == {key_slot(b"foo")}
3924+
finally:
3925+
r.close()
3926+
3927+
38893928
@pytest.mark.onlycluster
38903929
class TestClusterPipeline:
38913930
"""
@@ -3944,6 +3983,17 @@ def test_evalsha_zero_keys_in_pipeline(self, r):
39443983
pipe.evalsha(sha, 0)
39453984
assert pipe.execute() == [42]
39463985

3986+
def test_evalsha_zero_keys_in_transaction_pipeline(self, r):
3987+
"""
3988+
Multiple zero-key EVALSHA calls in a transactional pipeline share
3989+
one slot and execute successfully.
3990+
"""
3991+
sha = r.script_load("return 42")
3992+
with r.pipeline(transaction=True) as pipe:
3993+
pipe.evalsha(sha, 0)
3994+
pipe.evalsha(sha, 0)
3995+
assert pipe.execute() == [42, 42]
3996+
39473997
def test_redis_cluster_pipeline(self, r):
39483998
"""
39493999
Test that we can use a pipeline with the RedisCluster class

0 commit comments

Comments
 (0)