Skip to content

Commit b1019e6

Browse files
authored
Rewrite _move helper in Lua (to add Redis 7 support) (#284)
* add 7 support in configs * add a Lua implementation of move_task * remove execute_pipeline * list all used keys in KEYS * address PR comments
1 parent 62e5dff commit b1019e6

7 files changed

Lines changed: 194 additions & 259 deletions

File tree

.github/workflows/test.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ jobs:
3030
matrix:
3131
python-version: ['3.8', '3.9', '3.10', '3.11']
3232
os: ['ubuntu-20.04']
33-
redis-version: [4, 5, "6.2.6"]
33+
redis-version: [4, 5, "6.2.6", "7.0.9"]
3434
# Do not cancel any jobs when a single job fails
3535
fail-fast: false
3636
name: Python ${{ matrix.python-version }} on ${{ matrix.os }} with Redis ${{ matrix.redis-version }}

docker-compose.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
version: "3.7"
22
services:
33
redis:
4-
image: redis:4.0.6
4+
image: redis:7.0.9
55
expose:
66
- 6379
77
tasktiger:

tasktiger/lua/execute_pipeline.lua

Lines changed: 0 additions & 67 deletions
This file was deleted.

tasktiger/lua/move_task.lua

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
local function zadd_w_mode(key, score, member, mode)
2+
if mode == "" then
3+
redis.call('zadd', key, score, member)
4+
elseif mode == "nx" then
5+
zadd_noupdate({ key }, { score, member })
6+
elseif mode == "min" then
7+
zadd_update_min({ key }, { score, member })
8+
else
9+
error("mode " .. mode .. " unsupported")
10+
end
11+
end
12+
13+
14+
local key_task_id = KEYS[1]
15+
local key_task_id_executions = KEYS[2]
16+
local key_task_id_executions_count = KEYS[3]
17+
local key_from_state = KEYS[4]
18+
local key_to_state = KEYS[5]
19+
local key_active_queue = KEYS[6]
20+
local key_queued_queue = KEYS[7]
21+
local key_error_queue = KEYS[8]
22+
local key_scheduled_queue = KEYS[9]
23+
local key_activity = KEYS[10]
24+
25+
local id = ARGV[1]
26+
local queue = ARGV[2]
27+
local from_state = ARGV[3]
28+
local to_state = ARGV[4]
29+
local unique = ARGV[5]
30+
local when = ARGV[6]
31+
local mode = ARGV[7]
32+
local publish_queued_tasks = ARGV[8]
33+
34+
local state_queues_keys_by_state = {
35+
active = key_active_queue,
36+
queued = key_queued_queue,
37+
error = key_error_queue,
38+
scheduled = key_scheduled_queue,
39+
}
40+
local key_from_state_queue = state_queues_keys_by_state[from_state]
41+
local key_to_state_queue = state_queues_keys_by_state[to_state]
42+
43+
assert(redis.call('zscore', key_from_state_queue, id), '<FAIL_IF_NOT_IN_ZSET>')
44+
45+
if to_state ~= "" then
46+
zadd_w_mode(key_to_state_queue, when, id, mode)
47+
redis.call('sadd', key_to_state, queue)
48+
end
49+
redis.call('zrem', key_from_state_queue, id)
50+
51+
if to_state == "" then -- Remove the task if necessary
52+
if unique == 'true' then
53+
-- Delete executions if there were no errors
54+
local to_delete = {
55+
key_task_id_executions,
56+
key_task_id_executions_count,
57+
}
58+
local keys = { unpack(to_delete) }
59+
if from_state ~= 'error' then
60+
table.insert(keys, key_error_queue)
61+
end
62+
-- keys=[to_delete + zsets], args=[len(to_delete), value]
63+
delete_if_not_in_zsets(keys, { #to_delete, id })
64+
65+
-- Only delete task if it's not in any other queue
66+
local to_delete = { key_task_id }
67+
local zsets = {}
68+
for i, v in pairs({ 'active', 'queued', 'error', 'scheduled' }) do
69+
if v ~= from_state then
70+
table.insert(zsets, state_queues_keys_by_state[v])
71+
end
72+
end
73+
-- keys=[to_delete + zsets], args=[len(to_delete), value]
74+
delete_if_not_in_zsets({ unpack(to_delete), unpack(zsets) }, { #to_delete, id })
75+
else
76+
-- Safe to remove
77+
redis.call(
78+
'del',
79+
key_task_id,
80+
key_task_id_executions,
81+
key_task_id_executions_count
82+
)
83+
end
84+
end
85+
86+
-- keys=[key, other_key], args=[member]
87+
srem_if_not_exists({ key_from_state, key_from_state_queue }, { queue })
88+
89+
if to_state == 'queued' and publish_queued_tasks == 'true' then
90+
redis.call('publish', key_activity, queue)
91+
end

tasktiger/redis_scripts.py

Lines changed: 90 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,18 @@
11
import os
2-
from typing import Any, List, Literal, Optional, Tuple, Union
2+
from typing import Any, Callable, List, Literal, Optional, Tuple, Union
33

44
from redis import Redis
5-
from redis.client import Pipeline
65
from redis.commands.core import Script
76

7+
from ._internal import ACTIVE, ERROR, QUEUED, SCHEDULED
8+
9+
LOCAL_FUNC_TEMPLATE = """
10+
local function {func_name}(KEYS, ARGV)
11+
{func_body}
12+
end
13+
14+
"""
15+
816
# ARGV = { score, member }
917
ZADD_NOUPDATE_TEMPLATE = """
1018
if {condition} redis.call('zscore', {key}, {member}) then
@@ -313,8 +321,14 @@ def __init__(self, redis: Redis) -> None:
313321

314322
self._get_expired_tasks = redis.register_script(GET_EXPIRED_TASKS)
315323

316-
self._execute_pipeline = self.register_script_from_file(
317-
"lua/execute_pipeline.lua"
324+
self._move_task = self.register_script_from_file(
325+
"lua/move_task.lua",
326+
include_functions={
327+
"zadd_noupdate": ZADD_NOUPDATE,
328+
"zadd_update_min": ZADD_UPDATE_MIN,
329+
"srem_if_not_exists": SREM_IF_NOT_EXISTS,
330+
"delete_if_not_in_zsets": DELETE_IF_NOT_IN_ZSETS,
331+
},
318332
)
319333

320334
@property
@@ -330,11 +344,25 @@ def can_replicate_commands(self) -> bool:
330344
self._can_replicate_commands = result
331345
return self._can_replicate_commands
332346

333-
def register_script_from_file(self, filename: str) -> Script:
347+
def register_script_from_file(
348+
self, filename: str, include_functions: Optional[dict] = None
349+
) -> Script:
334350
with open(
335351
os.path.join(os.path.dirname(os.path.realpath(__file__)), filename)
336352
) as f:
337-
return self.redis.register_script(f.read())
353+
script = f.read()
354+
if include_functions:
355+
function_definitions = []
356+
for func_name in sorted(include_functions.keys()):
357+
function_definitions.append(
358+
LOCAL_FUNC_TEMPLATE.format(
359+
func_name=func_name,
360+
func_body=include_functions[func_name],
361+
)
362+
)
363+
script = "\n".join(function_definitions + [script])
364+
365+
return self.redis.register_script(script)
338366

339367
def zadd(
340368
self,
@@ -538,77 +566,62 @@ def get_expired_tasks(
538566
# [queue1, task1, queue2, task2] -> [(queue1, task1), (queue2, task2)]
539567
return list(zip(result[::2], result[1::2]))
540568

541-
def execute_pipeline(
542-
self, pipeline: Pipeline, client: Optional[Redis] = None
543-
) -> List[Any]:
569+
def move_task(
570+
self,
571+
id: str,
572+
queue: str,
573+
from_state: str,
574+
to_state: Optional[str],
575+
unique: bool,
576+
when: float,
577+
mode: Optional[str],
578+
key_func: Callable[..., str],
579+
publish_queued_tasks: bool,
580+
client: Optional[Redis] = None,
581+
) -> Any:
544582
"""
545-
Executes the given Redis pipeline as a Lua script. When an error
546-
occurs, the transaction stops executing, and an exception is raised.
547-
This differs from Redis transactions, where execution continues after an
548-
error. On success, a list of results is returned. The pipeline is
549-
cleared after execution and can no longer be reused.
550-
551-
Example:
552-
553-
p = conn.pipeline()
554-
p.lrange('x', 0, -1)
555-
p.set('success', 1)
556-
557-
# If "x" is empty or a list, an array [[...], True] is returned.
558-
# Otherwise, ResponseError is raised and "success" is not set.
559-
results = redis_scripts.execute_pipeline(p)
583+
Refer to task._move internal helper documentation.
560584
"""
561585

562-
client = client or self.redis
563-
564-
executing_pipeline = None
565-
try:
566-
567-
# Prepare args
568-
stack = pipeline.command_stack
569-
script_args = [int(self.can_replicate_commands), len(stack)]
570-
for args, options in stack:
571-
script_args += [len(args) - 1] + list(args)
572-
573-
# Run the pipeline
574-
if self.can_replicate_commands: # Redis 3.2 or higher
575-
# Make sure scripts exist
576-
if pipeline.scripts:
577-
pipeline.load_scripts()
578-
579-
raw_results = self._execute_pipeline(
580-
args=script_args, client=client
581-
)
582-
else:
583-
executing_pipeline = client.pipeline()
584-
585-
# Always load scripts to avoid issues when Redis loads data
586-
# from AOF file / when replicating.
587-
for s in pipeline.scripts:
588-
executing_pipeline.script_load(s.script)
589-
590-
# Run actual pipeline lua script
591-
self._execute_pipeline(
592-
args=script_args, client=executing_pipeline
593-
)
594-
595-
# Always load all scripts and run actual pipeline lua script
596-
raw_results = executing_pipeline.execute()[-1]
597-
598-
# Run response callbacks on results.
599-
results = []
600-
response_callbacks = pipeline.response_callbacks
601-
for ((args, options), result) in zip(stack, raw_results):
602-
command_name = args[0]
603-
if command_name in response_callbacks:
604-
result = response_callbacks[command_name](
605-
result, **options
606-
)
607-
results.append(result)
608-
609-
return results
610-
611-
finally:
612-
if executing_pipeline:
613-
executing_pipeline.reset()
614-
pipeline.reset()
586+
def _bool_to_str(v: bool) -> str:
587+
return "true" if v else "false"
588+
589+
def _none_to_empty_str(v: Optional[str]) -> str:
590+
return v or ""
591+
592+
key_task_id = key_func("task", id)
593+
key_task_id_executions = key_func("task", id, "executions")
594+
key_task_id_executions_count = key_func("task", id, "executions_count")
595+
key_from_state = key_func(from_state)
596+
key_to_state = key_func(to_state) if to_state else ""
597+
key_active_queue = key_func(ACTIVE, queue)
598+
key_queued_queue = key_func(QUEUED, queue)
599+
key_error_queue = key_func(ERROR, queue)
600+
key_scheduled_queue = key_func(SCHEDULED, queue)
601+
key_activity = key_func("activity")
602+
603+
return self._move_task(
604+
keys=[
605+
key_task_id,
606+
key_task_id_executions,
607+
key_task_id_executions_count,
608+
key_from_state,
609+
key_to_state,
610+
key_active_queue,
611+
key_queued_queue,
612+
key_error_queue,
613+
key_scheduled_queue,
614+
key_activity,
615+
],
616+
args=[
617+
id,
618+
queue,
619+
from_state,
620+
_none_to_empty_str(to_state),
621+
_bool_to_str(unique),
622+
when,
623+
_none_to_empty_str(mode),
624+
_bool_to_str(publish_queued_tasks),
625+
],
626+
client=client,
627+
)

0 commit comments

Comments
 (0)