Skip to content

Commit e5ac39a

Browse files
Add cluster support for scripting (#1937)
* Add cluster support for scripting * Fall back to connection_pool.get_encoder if necessary * Add documentation for cluster-based scripting * Add test for flush response Co-authored-by: dvora-h <[email protected]>
1 parent 1983905 commit e5ac39a

File tree

8 files changed

+285
-29
lines changed

8 files changed

+285
-29
lines changed

README.md

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -862,7 +862,8 @@ Monitor object to block until a command is received.
862862
redis-py supports the EVAL, EVALSHA, and SCRIPT commands. However, there
863863
are a number of edge cases that make these commands tedious to use in
864864
real world scenarios. Therefore, redis-py exposes a Script object that
865-
makes scripting much easier to use.
865+
makes scripting much easier to use. (RedisClusters have limited support for
866+
scripting.)
866867

867868
To create a Script instance, use the register_script
868869
function on a client instance passing the Lua code as the first
@@ -955,14 +956,16 @@ C 3
955956

956957
### Cluster Mode
957958

958-
redis-py is now supports cluster mode and provides a client for
959+
redis-py now supports cluster mode and provides a client for
959960
[Redis Cluster](<https://redis.io/topics/cluster-tutorial>).
960961

961962
The cluster client is based on Grokzen's
962963
[redis-py-cluster](https://github.com/Grokzen/redis-py-cluster), has added bug
963964
fixes, and now supersedes that library. Support for these changes is thanks to
964965
his contributions.
965966

967+
To learn more about Redis Cluster, see
968+
[Redis Cluster specifications](https://redis.io/topics/cluster-spec).
966969

967970
**Create RedisCluster:**
968971

@@ -1218,10 +1221,29 @@ according to their respective destination nodes. This means that we can not
12181221
turn the pipeline commands into one transaction block, because in most cases
12191222
they are split up into several smaller pipelines.
12201223

1221-
1222-
See [Redis Cluster tutorial](https://redis.io/topics/cluster-tutorial) and
1223-
[Redis Cluster specifications](https://redis.io/topics/cluster-spec)
1224-
to learn more about Redis Cluster.
1224+
**Lua Scripting in Cluster Mode**
1225+
1226+
Cluster mode has limited support for lua scripting.
1227+
1228+
The following commands are supported, with caveats:
1229+
- `EVAL` and `EVALSHA`: The command is sent to the relevant node, depending on
1230+
the keys (i.e., in `EVAL "<script>" num_keys key_1 ... key_n ...`). The keys
1231+
_must_ all be on the same node. If the script requires 0 keys, _the command is
1232+
sent to a random (primary) node_.
1233+
- `SCRIPT EXISTS`: The command is sent to all primaries. The result is a list
1234+
of booleans corresponding to the input SHA hashes. Each boolean is an AND of
1235+
"does the script exist on each node?". In other words, each boolean is True iff
1236+
the script exists on all nodes.
1237+
- `SCRIPT FLUSH`: The command is sent to all primaries. The result is a bool
1238+
AND over all nodes' responses.
1239+
- `SCRIPT LOAD`: The command is sent to all primaries. The result is the SHA1
1240+
digest.
1241+
1242+
The following commands are not supported:
1243+
- `EVAL_RO`
1244+
- `EVALSHA_RO`
1245+
1246+
Using scripting within pipelines in cluster mode is **not supported**.
12251247

12261248
### Author
12271249

redis/cluster.py

Lines changed: 69 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,9 @@ class RedisCluster(RedisClusterCommands):
289289
[
290290
"FLUSHALL",
291291
"FLUSHDB",
292+
"SCRIPT EXISTS",
293+
"SCRIPT FLUSH",
294+
"SCRIPT LOAD",
292295
],
293296
PRIMARIES,
294297
),
@@ -379,6 +382,24 @@ class RedisCluster(RedisClusterCommands):
379382
],
380383
parse_scan_result,
381384
),
385+
list_keys_to_dict(
386+
[
387+
"SCRIPT LOAD",
388+
],
389+
lambda command, res: list(res.values()).pop(),
390+
),
391+
list_keys_to_dict(
392+
[
393+
"SCRIPT EXISTS",
394+
],
395+
lambda command, res: [all(k) for k in zip(*res.values())],
396+
),
397+
list_keys_to_dict(
398+
[
399+
"SCRIPT FLUSH",
400+
],
401+
lambda command, res: all(res.values()),
402+
),
382403
)
383404

384405
ERRORS_ALLOW_RETRY = (
@@ -778,40 +799,70 @@ def _get_command_keys(self, *args):
778799
"""
779800
Get the keys in the command. If the command has no keys in in, None is
780801
returned.
802+
803+
NOTE: Due to a bug in redis<7.0, this function does not work properly
804+
for EVAL or EVALSHA when the `numkeys` arg is 0.
805+
- issue: https://github.com/redis/redis/issues/9493
806+
- fix: https://github.com/redis/redis/pull/9733
807+
808+
So, don't use this function with EVAL or EVALSHA.
781809
"""
782810
redis_conn = self.get_default_node().redis_connection
783811
return self.commands_parser.get_keys(redis_conn, *args)
784812

785813
def determine_slot(self, *args):
786814
"""
787-
Figure out what slot based on command and args
815+
Figure out what slot to use based on args.
816+
817+
Raises a RedisClusterException if there's a missing key and we can't
818+
determine what slots to map the command to; or, if the keys don't
819+
all map to the same key slot.
788820
"""
789-
if self.command_flags.get(args[0]) == SLOT_ID:
821+
command = args[0]
822+
if self.command_flags.get(command) == SLOT_ID:
790823
# The command contains the slot ID
791824
return args[1]
792825

793826
# Get the keys in the command
794-
keys = self._get_command_keys(*args)
795-
if keys is None or len(keys) == 0:
796-
raise RedisClusterException(
797-
"No way to dispatch this command to Redis Cluster. "
798-
"Missing key.\nYou can execute the command by specifying "
799-
f"target nodes.\nCommand: {args}"
800-
)
801827

802-
if len(keys) > 1:
803-
# multi-key command, we need to make sure all keys are mapped to
804-
# the same slot
805-
slots = {self.keyslot(key) for key in keys}
806-
if len(slots) != 1:
828+
# EVAL and EVALSHA are common enough that it's wasteful to go to the
829+
# redis server to parse the keys. Besides, there is a bug in redis<7.0
830+
# where `self._get_command_keys()` fails anyway. So, we special case
831+
# EVAL/EVALSHA.
832+
if command in ("EVAL", "EVALSHA"):
833+
# command syntax: EVAL "script body" num_keys ...
834+
if len(args) <= 2:
835+
raise RedisClusterException(f"Invalid args in command: {args}")
836+
num_actual_keys = args[2]
837+
eval_keys = args[3 : 3 + num_actual_keys]
838+
# if there are 0 keys, that means the script can be run on any node
839+
# so we can just return a random slot
840+
if len(eval_keys) == 0:
841+
return random.randrange(0, REDIS_CLUSTER_HASH_SLOTS)
842+
keys = eval_keys
843+
else:
844+
keys = self._get_command_keys(*args)
845+
if keys is None or len(keys) == 0:
807846
raise RedisClusterException(
808-
f"{args[0]} - all keys must map to the same key slot"
847+
"No way to dispatch this command to Redis Cluster. "
848+
"Missing key.\nYou can execute the command by specifying "
849+
f"target nodes.\nCommand: {args}"
809850
)
810-
return slots.pop()
811-
else:
812-
# single key command
851+
852+
# single key command
853+
if len(keys) == 1:
813854
return self.keyslot(keys[0])
814855

856+
# multi-key command; we need to make sure all keys are mapped to
857+
# the same slot
858+
slots = {self.keyslot(key) for key in keys}
859+
if len(slots) != 1:
860+
raise RedisClusterException(
861+
f"{command} - all keys must map to the same key slot"
862+
)
863+
864+
return slots.pop()
865+
815866
def reinitialize_caches(self):
816867
self.nodes_manager.initialize()
817868

redis/commands/cluster.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
11
from redis.crc import key_slot
22
from redis.exceptions import RedisClusterException, RedisError
33

4-
from .core import ACLCommands, DataAccessCommands, ManagementCommands, PubSubCommands
4+
from .core import (
5+
ACLCommands,
6+
DataAccessCommands,
7+
ManagementCommands,
8+
PubSubCommands,
9+
ScriptCommands,
10+
)
511
from .helpers import list_or_args
612

713

@@ -205,6 +211,7 @@ class RedisClusterCommands(
205211
ACLCommands,
206212
PubSubCommands,
207213
ClusterDataAccessCommands,
214+
ScriptCommands,
208215
):
209216
"""
210217
A class for all Redis Cluster commands

redis/commands/core.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5399,6 +5399,62 @@ def command(self) -> ResponseT:
53995399
return self.execute_command("COMMAND")
54005400

54015401

5402+
class Script:
5403+
"""
5404+
An executable Lua script object returned by ``register_script``
5405+
"""
5406+
5407+
def __init__(self, registered_client, script):
5408+
self.registered_client = registered_client
5409+
self.script = script
5410+
# Precalculate and store the SHA1 hex digest of the script.
5411+
5412+
if isinstance(script, str):
5413+
# We need the encoding from the client in order to generate an
5414+
# accurate byte representation of the script
5415+
encoder = self.get_encoder()
5416+
script = encoder.encode(script)
5417+
self.sha = hashlib.sha1(script).hexdigest()
5418+
5419+
def __call__(self, keys=[], args=[], client=None):
5420+
"Execute the script, passing any required ``args``"
5421+
if client is None:
5422+
client = self.registered_client
5423+
args = tuple(keys) + tuple(args)
5424+
# make sure the Redis server knows about the script
5425+
from redis.client import Pipeline
5426+
5427+
if isinstance(client, Pipeline):
5428+
# Make sure the pipeline can register the script before executing.
5429+
client.scripts.add(self)
5430+
try:
5431+
return client.evalsha(self.sha, len(keys), *args)
5432+
except NoScriptError:
5433+
# Maybe the client is pointed to a different server than the client
5434+
# that created this instance?
5435+
# Overwrite the sha just in case there was a discrepancy.
5436+
self.sha = client.script_load(self.script)
5437+
return client.evalsha(self.sha, len(keys), *args)
5438+
5439+
def get_encoder(self):
5440+
"""Get the encoder to encode string scripts into bytes."""
5441+
try:
5442+
return self.registered_client.get_encoder()
5443+
except AttributeError:
5444+
# DEPRECATED
5445+
# In version <=4.1.2, this was the code we used to get the encoder.
5446+
# However, after 4.1.2 we added support for scripting in clustered
5447+
# redis. ClusteredRedis doesn't have a `.connection_pool` attribute
5448+
# so we changed the Script class to use
5449+
# `self.registered_client.get_encoder` (see above).
5450+
# However, that is technically a breaking change, as consumers who
5451+
# use Scripts directly might inject a `registered_client` that
5452+
# doesn't have a `.get_encoder` field. This try/except prevents us
5453+
# from breaking backward-compatibility. Ideally, it would be
5454+
# removed in the next major release.
5455+
return self.registered_client.connection_pool.get_encoder()
5456+
5457+
54025458
class AsyncModuleCommands(ModuleCommands):
54035459
async def command_info(self) -> None:
54045460
return super().command_info()

redis/commands/parser.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,14 @@ def initialize(self, r):
2424
# https://github.com/redis/redis/pull/8324
2525
def get_keys(self, redis_conn, *args):
2626
"""
27-
Get the keys from the passed command
27+
Get the keys from the passed command.
28+
29+
NOTE: Due to a bug in redis<7.0, this function does not work properly
30+
for EVAL or EVALSHA when the `numkeys` arg is 0.
31+
- issue: https://github.com/redis/redis/issues/9493
32+
- fix: https://github.com/redis/redis/pull/9733
33+
34+
So, don't use this function with EVAL or EVALSHA.
2835
"""
2936
if len(args) < 2:
3037
# The command has no keys in it
@@ -72,6 +79,14 @@ def get_keys(self, redis_conn, *args):
7279
return keys
7380

7481
def _get_moveable_keys(self, redis_conn, *args):
82+
"""
83+
NOTE: Due to a bug in redis<7.0, this function does not work properly
84+
for EVAL or EVALSHA when the `numkeys` arg is 0.
85+
- issue: https://github.com/redis/redis/issues/9493
86+
- fix: https://github.com/redis/redis/pull/9733
87+
88+
So, don't use this function with EVAL or EVALSHA.
89+
"""
7590
pieces = []
7691
cmd_name = args[0]
7792
# The command name should be splitted into separate arguments,

redis/utils.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ def merge_result(command, res):
6767
Merge all items in `res` into a list.
6868
6969
This command is used when sending a command to multiple nodes
70-
and they result from each node should be merged into a single list.
70+
and the result from each node should be merged into a single list.
7171
7272
res : 'dict'
7373
"""

tests/test_command_parser.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
from redis.commands import CommandsParser
44

5+
from .conftest import skip_if_server_version_lt
6+
57

68
class TestCommandsParser:
79
def test_init_commands(self, r):
@@ -68,6 +70,19 @@ def test_get_moveable_keys(self, r):
6870
assert commands_parser.get_keys(r, *args8) is None
6971
assert commands_parser.get_keys(r, *args9).sort() == ["key1", "key2"].sort()
7072

73+
# A bug in redis<7.0 causes this to fail: https://github.com/redis/redis/issues/9493
74+
@skip_if_server_version_lt("7.0.0")
75+
def test_get_eval_keys_with_0_keys(self, r):
76+
commands_parser = CommandsParser(r)
77+
args = [
78+
"EVAL",
79+
"return {ARGV[1],ARGV[2]}",
80+
0,
81+
"key1",
82+
"key2",
83+
]
84+
assert commands_parser.get_keys(r, *args) == []
85+
7186
def test_get_pubsub_keys(self, r):
7287
commands_parser = CommandsParser(r)
7388
args1 = ["PUBLISH", "foo", "bar"]

0 commit comments

Comments
 (0)