threadmill 0.1.0__tar.gz → 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: threadmill
3
- Version: 0.1.0
4
- Summary: A queue agnostic worker for Django's task framework.
3
+ Version: 0.2.0
4
+ Summary: The most reliable backend for Django's task framework.
5
5
  Keywords: Django,tasks,worker
6
6
  Author-email: Johannes Maron <johannes@maron.family>
7
7
  Requires-Python: >=3.12
@@ -27,6 +27,7 @@ Classifier: Framework :: Django
27
27
  Classifier: Framework :: Django :: 6.1
28
28
  License-File: LICENSE
29
29
  Requires-Dist: django>=6.1a1
30
+ Requires-Dist: redis>=5.0 ; extra == "redis"
30
31
  Project-URL: Changelog, https://github.com/codingjoe/threadmill/releases
31
32
  Project-URL: Documentation, https://github.com/codingjoe/threadmill/
32
33
  Project-URL: Funding, https://github.com/sponsors/codingjoe
@@ -34,6 +35,7 @@ Project-URL: Homepage, https://github.com/codingjoe/threadmill
34
35
  Project-URL: Issues, https://github.com/codingjoe/threadmill/issues
35
36
  Project-URL: Releasenotes, https://github.com/codingjoe/threadmill/releases/latest
36
37
  Project-URL: Source, https://github.com/codingjoe/threadmill
38
+ Provides-Extra: redis
37
39
 
38
40
  # Threadmill
39
41
 
@@ -41,7 +43,7 @@ Project-URL: Source, https://github.com/codingjoe/threadmill
41
43
  <picture>
42
44
  <source media="(prefers-color-scheme: dark)" srcset="https://github.com/codingjoe/threadmill/raw/main/images/logo-dark.svg">
43
45
  <source media="(prefers-color-scheme: light)" srcset="https://github.com/codingjoe/threadmill/raw/main/images/logo-light.svg">
44
- <img alt="Django Grinder: A queue agnostic worker for Django's task framework." src="https://github.com/codingjoe/threadmill/raw/main/images/logo-light.svg">
46
+ <img alt="Threadmill: A queue agnostic worker for Django's task framework." src="https://github.com/codingjoe/threadmill/raw/main/images/logo-light.svg">
45
47
  </picture>
46
48
  <br>
47
49
  <a href="https://github.com/codingjoe/threadmill/">Documentation</a> |
@@ -65,22 +67,37 @@ Project-URL: Source, https://github.com/codingjoe/threadmill
65
67
  [![Test Coverage](https://codecov.io/gh/codingjoe/threadmill/branch/main/graph/badge.svg)](https://codecov.io/gh/codingjoe/threadmill)
66
68
  [![GitHub License](https://img.shields.io/github/license/codingjoe/threadmill)](https://raw.githubusercontent.com/codingjoe/threadmill/master/LICENSE)
67
69
 
70
+ ## Sponsors
71
+
72
+ [![Sponsors](https://django.the-box.sh/sponsors/codingjoe/threadmill.svg)](https://github.com/sponsors/codingjoe)
73
+
68
74
  ## Setup
69
75
 
70
- You need to have [Django's Task framework][django-tasks] setup properly.
76
+ You need to have [Django's Task framework][django-tasks] set up properly.
71
77
 
72
78
  ```console
73
- uv add threadmill
79
+ uv add threadmill[redis]
74
80
  ```
75
81
 
76
- Add `threadmill` to your `INSTALLED_APPS` in `settings.py`:
82
+ Add `threadmill` to your `INSTALLED_APPS` in `settings.py`
83
+ and configure the task backend:
77
84
 
78
85
  ```python
79
86
  # settings.py
87
+ import os
88
+
80
89
  INSTALLED_APPS = [
81
90
  "threadmill",
82
91
  # ...
83
92
  ]
93
+
94
+ TASKS = {
95
+ "default": {
96
+ "BACKEND": "threadmill.backends.redis.RedisTaskBackend",
97
+ "REDIS_URL": os.getenv("REDIS_URL", "redis://localhost:6379/0"),
98
+ },
99
+ # ...
100
+ }
84
101
  ```
85
102
 
86
103
  Finally, you launch the worker pool:
@@ -91,9 +108,11 @@ uv run manage.py threadmill
91
108
 
92
109
  ## Usage
93
110
 
111
+ ### Workers
112
+
94
113
  The workers are inspired by Gunicorn, and the CLI is very similar.
95
114
 
96
- ### Utilization
115
+ #### Utilization
97
116
 
98
117
  Depending on your workload, you can tweak the number of processes and threads.
99
118
  Processes allow for parallel compute (no GIL) while threads are great for low-memory concurrent IO.
@@ -102,7 +121,7 @@ Processes allow for parallel compute (no GIL) while threads are great for low-me
102
121
  uv run manage.py threadmill --processes 4 --threads 2
103
122
  ```
104
123
 
105
- ### Health
124
+ #### Health
106
125
 
107
126
  If your tasks leak memory, you can recycle (restart) the workers after a certain number of tasks have been processed:
108
127
 
@@ -114,62 +133,34 @@ This will restart the workers after 1000 tasks have been processed, with a rando
114
133
 
115
134
  Should a worker crash or be killed, the pool will automatically restart it.
116
135
 
117
- ### Shutdown
136
+ #### Shutdown
118
137
 
119
138
  A graceful shutdown is possible with the `SIGTERM` or a keyboard interrupt.
120
- All workers will finish the tasks they acquired and publish them.
139
+ All workers will finish the tasks they acquired and acknowledge them.
121
140
 
122
141
  You can use `--exit-empty` to exit immediately after all tasks have been processed,
123
142
  which might be useful for draining a one-off queue.
124
143
 
125
- ### Task Backlog
126
-
127
- You can prefetch tasks from a queue to avoid IO latency bottlenecks.
128
- However, this will increase the memory usage of the worker pool.
129
-
130
- ```console
131
- uv run manage.py threadmill --prefetch 100
132
- ```
133
-
134
- ### Task Timeouts
135
-
136
- > [!WARNING]
137
- > Work in progress, this feature is not yet stable.
138
-
139
- Task timeouts are important to ensure the long-term health of your pool.
140
- However, they need to be aligned with your queueing system's timeout settings.
141
- The message queue needs to requeue a task that hasn't been acknowledged within the timeout.
144
+ ### Redis Backend Options
142
145
 
143
- ## Integration
146
+ The `RedisTaskBackend` accepts the following options under `OPTIONS` in your
147
+ `TASKS` configuration:
144
148
 
145
- > [!NOTE]
146
- > This section is for people who want to integrate Threadmill into their queueing system.
149
+ | Option | Default | Description |
150
+ | ----------------- | ---------------------- | ------------------------------------------------------------ |
151
+ | `lease_ttl` | `timedelta(hours=1)` | Max processing time before a started task is marked FAILED. |
152
+ | `result_ttl` | `timedelta(days=1)` | How long task results are retained before automatic removal. |
153
+ | `broker_interval` | `timedelta(seconds=1)` | Interval between background broker maintenance passes. |
154
+ | `batch_size` | `100` | Max tasks to move or requeue per broker pass. |
147
155
 
148
- Threadmill is designed to be durable and requires a queueing system to support late acknowledgement.
156
+ A task that is started but never acknowledged (lease expired) is marked FAILED
157
+ with an `AcknowledgementTimeout` error. Set `lease_ttl` comfortably above your
158
+ worst-case task runtime.
149
159
 
150
- To use Threadmill, your backend will need to inherit from `threadmill.backends.AcknowledgeableTaskBackend` and implement the following methods:
160
+ All keys for one backend alias share a Redis Cluster hash tag (`{alias}`), so
161
+ every multi-key operation — including the cross-queue acquire — runs on a single
162
+ shard. Scale horizontally by running additional backend aliases, not by relying
163
+ on cross-slot operations.
151
164
 
152
- ```python
153
- class AcknowledgeableTaskBackend(BaseTaskBackend, ABC):
154
- """Provide an interface for tasks queues to be processed by the executor."""
155
-
156
- def acquire(
157
- self, *queue_names: str, timeout: datetime.timedelta | None = None
158
- ) -> TaskResult:
159
- """
160
- Return and lock the next task to be processed without removing it from the queue.
161
-
162
- Args:
163
- queue_names: The names of the queues to acquire tasks from.
164
- timeout: The maximum time to wait for a task. If None, wait indefinitely.
165
-
166
- Raises:
167
- TimeoutError: If no task is available within the specified timeout.
168
- """
169
- raise NotImplementedError
170
-
171
- def acknowledge(self, task_result: TaskResult) -> None:
172
- """Remove the task from the queue and publish the result."""
173
- raise NotImplementedError
174
- ```
165
+ [django-tasks]: https://docs.djangoproject.com/en/stable/topics/tasks/
175
166
 
@@ -4,7 +4,7 @@
4
4
  <picture>
5
5
  <source media="(prefers-color-scheme: dark)" srcset="https://github.com/codingjoe/threadmill/raw/main/images/logo-dark.svg">
6
6
  <source media="(prefers-color-scheme: light)" srcset="https://github.com/codingjoe/threadmill/raw/main/images/logo-light.svg">
7
- <img alt="Django Grinder: A queue agnostic worker for Django's task framework." src="https://github.com/codingjoe/threadmill/raw/main/images/logo-light.svg">
7
+ <img alt="Threadmill: A queue agnostic worker for Django's task framework." src="https://github.com/codingjoe/threadmill/raw/main/images/logo-light.svg">
8
8
  </picture>
9
9
  <br>
10
10
  <a href="https://github.com/codingjoe/threadmill/">Documentation</a> |
@@ -28,22 +28,37 @@
28
28
  [![Test Coverage](https://codecov.io/gh/codingjoe/threadmill/branch/main/graph/badge.svg)](https://codecov.io/gh/codingjoe/threadmill)
29
29
  [![GitHub License](https://img.shields.io/github/license/codingjoe/threadmill)](https://raw.githubusercontent.com/codingjoe/threadmill/master/LICENSE)
30
30
 
31
+ ## Sponsors
32
+
33
+ [![Sponsors](https://django.the-box.sh/sponsors/codingjoe/threadmill.svg)](https://github.com/sponsors/codingjoe)
34
+
31
35
  ## Setup
32
36
 
33
- You need to have [Django's Task framework][django-tasks] setup properly.
37
+ You need to have [Django's Task framework][django-tasks] set up properly.
34
38
 
35
39
  ```console
36
- uv add threadmill
40
+ uv add threadmill[redis]
37
41
  ```
38
42
 
39
- Add `threadmill` to your `INSTALLED_APPS` in `settings.py`:
43
+ Add `threadmill` to your `INSTALLED_APPS` in `settings.py`
44
+ and configure the task backend:
40
45
 
41
46
  ```python
42
47
  # settings.py
48
+ import os
49
+
43
50
  INSTALLED_APPS = [
44
51
  "threadmill",
45
52
  # ...
46
53
  ]
54
+
55
+ TASKS = {
56
+ "default": {
57
+ "BACKEND": "threadmill.backends.redis.RedisTaskBackend",
58
+ "REDIS_URL": os.getenv("REDIS_URL", "redis://localhost:6379/0"),
59
+ },
60
+ # ...
61
+ }
47
62
  ```
48
63
 
49
64
  Finally, you launch the worker pool:
@@ -54,9 +69,11 @@ uv run manage.py threadmill
54
69
 
55
70
  ## Usage
56
71
 
72
+ ### Workers
73
+
57
74
  The workers are inspired by Gunicorn, and the CLI is very similar.
58
75
 
59
- ### Utilization
76
+ #### Utilization
60
77
 
61
78
  Depending on your workload, you can tweak the number of processes and threads.
62
79
  Processes allow for parallel compute (no GIL) while threads are great for low-memory concurrent IO.
@@ -65,7 +82,7 @@ Processes allow for parallel compute (no GIL) while threads are great for low-me
65
82
  uv run manage.py threadmill --processes 4 --threads 2
66
83
  ```
67
84
 
68
- ### Health
85
+ #### Health
69
86
 
70
87
  If your tasks leak memory, you can recycle (restart) the workers after a certain number of tasks have been processed:
71
88
 
@@ -77,61 +94,33 @@ This will restart the workers after 1000 tasks have been processed, with a rando
77
94
 
78
95
  Should a worker crash or be killed, the pool will automatically restart it.
79
96
 
80
- ### Shutdown
97
+ #### Shutdown
81
98
 
82
99
  A graceful shutdown is possible with the `SIGTERM` or a keyboard interrupt.
83
- All workers will finish the tasks they acquired and publish them.
100
+ All workers will finish the tasks they acquired and acknowledge them.
84
101
 
85
102
  You can use `--exit-empty` to exit immediately after all tasks have been processed,
86
103
  which might be useful for draining a one-off queue.
87
104
 
88
- ### Task Backlog
89
-
90
- You can prefetch tasks from a queue to avoid IO latency bottlenecks.
91
- However, this will increase the memory usage of the worker pool.
92
-
93
- ```console
94
- uv run manage.py threadmill --prefetch 100
95
- ```
96
-
97
- ### Task Timeouts
98
-
99
- > [!WARNING]
100
- > Work in progress, this feature is not yet stable.
101
-
102
- Task timeouts are important to ensure the long-term health of your pool.
103
- However, they need to be aligned with your queueing system's timeout settings.
104
- The message queue needs to requeue a task that hasn't been acknowledged within the timeout.
105
+ ### Redis Backend Options
105
106
 
106
- ## Integration
107
+ The `RedisTaskBackend` accepts the following options under `OPTIONS` in your
108
+ `TASKS` configuration:
107
109
 
108
- > [!NOTE]
109
- > This section is for people who want to integrate Threadmill into their queueing system.
110
+ | Option | Default | Description |
111
+ | ----------------- | ---------------------- | ------------------------------------------------------------ |
112
+ | `lease_ttl` | `timedelta(hours=1)` | Max processing time before a started task is marked FAILED. |
113
+ | `result_ttl` | `timedelta(days=1)` | How long task results are retained before automatic removal. |
114
+ | `broker_interval` | `timedelta(seconds=1)` | Interval between background broker maintenance passes. |
115
+ | `batch_size` | `100` | Max tasks to move or requeue per broker pass. |
110
116
 
111
- Threadmill is designed to be durable and requires a queueing system to support late acknowledgement.
117
+ A task that is started but never acknowledged (lease expired) is marked FAILED
118
+ with an `AcknowledgementTimeout` error. Set `lease_ttl` comfortably above your
119
+ worst-case task runtime.
112
120
 
113
- To use Threadmill, your backend will need to inherit from `threadmill.backends.AcknowledgeableTaskBackend` and implement the following methods:
121
+ All keys for one backend alias share a Redis Cluster hash tag (`{alias}`), so
122
+ every multi-key operation — including the cross-queue acquire — runs on a single
123
+ shard. Scale horizontally by running additional backend aliases, not by relying
124
+ on cross-slot operations.
114
125
 
115
- ```python
116
- class AcknowledgeableTaskBackend(BaseTaskBackend, ABC):
117
- """Provide an interface for tasks queues to be processed by the executor."""
118
-
119
- def acquire(
120
- self, *queue_names: str, timeout: datetime.timedelta | None = None
121
- ) -> TaskResult:
122
- """
123
- Return and lock the next task to be processed without removing it from the queue.
124
-
125
- Args:
126
- queue_names: The names of the queues to acquire tasks from.
127
- timeout: The maximum time to wait for a task. If None, wait indefinitely.
128
-
129
- Raises:
130
- TimeoutError: If no task is available within the specified timeout.
131
- """
132
- raise NotImplementedError
133
-
134
- def acknowledge(self, task_result: TaskResult) -> None:
135
- """Remove the task from the queue and publish the result."""
136
- raise NotImplementedError
137
- ```
126
+ [django-tasks]: https://docs.djangoproject.com/en/stable/topics/tasks/
@@ -35,6 +35,9 @@ classifiers = [
35
35
  requires-python = ">=3.12"
36
36
  dependencies = ["django>=6.1a1"]
37
37
 
38
+ [project.optional-dependencies]
39
+ redis = ["redis>=5.0"]
40
+
38
41
  [project.urls]
39
42
  # https://packaging.python.org/en/latest/specifications/well-known-project-urls/#well-known-labels
40
43
  Homepage = "https://github.com/codingjoe/threadmill"
@@ -56,9 +59,9 @@ minversion = "6.0"
56
59
  addopts = "--cov --cov-report=xml --cov-report=term --tb=short -rxs --benchmark-autosave --benchmark-group-by=fullname --benchmark-min-rounds=10"
57
60
  testpaths = ["tests"]
58
61
  DJANGO_SETTINGS_MODULE = "tests.testapp.settings"
62
+ asyncio_mode = "auto"
59
63
  markers = [
60
64
  "benchmark: mark benchmark tests.",
61
- "integration: mark integration tests.",
62
65
  ]
63
66
 
64
67
  [tool.coverage.run]
@@ -91,6 +94,7 @@ combine-as-imports = true
91
94
  split-on-trailing-comma = true
92
95
  section-order = ["future", "standard-library", "third-party", "first-party", "local-folder"]
93
96
  force-wrap-aliases = true
97
+ known-first-party = ["threadmill", "tests"]
94
98
 
95
99
  [tool.ruff.lint.pydocstyle]
96
100
  convention = "pep257"
@@ -105,4 +109,5 @@ test = [
105
109
  "pytest-asyncio",
106
110
  "pytest-cov",
107
111
  "pytest-django",
112
+ "redis>=5.0",
108
113
  ]
@@ -1,4 +1,4 @@
1
- """A queue agnostic worker for Django's task framework."""
1
+ """The most reliable backend for Django's task framework."""
2
2
 
3
3
  from . import _version
4
4
 
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '0.1.0'
22
- __version_tuple__ = version_tuple = (0, 1, 0)
21
+ __version__ = version = '0.2.0'
22
+ __version_tuple__ = version_tuple = (0, 2, 0)
23
23
 
24
- __commit_id__ = commit_id = 'g7270fd320'
24
+ __commit_id__ = commit_id = 'gb135558ff'
@@ -0,0 +1,145 @@
1
+ from __future__ import annotations
2
+
3
+ import collections.abc
4
+ import dataclasses
5
+ import datetime
6
+ import json
7
+ import threading
8
+ from abc import ABC
9
+
10
+ from django.core.serializers.json import DjangoJSONEncoder
11
+ from django.tasks import DEFAULT_TASK_QUEUE_NAME, Task, TaskResult, TaskResultStatus
12
+ from django.tasks.backends.base import BaseTaskBackend
13
+ from django.tasks.base import TaskError
14
+ from django.utils.module_loading import import_string
15
+
16
+
17
+ class Broker(threading.Thread):
18
+ """Backend maintenance thread launched by the task executor."""
19
+
20
+ def __init__(
21
+ self,
22
+ backend: ThreadmillTaskBackend | None = None,
23
+ *,
24
+ interval: datetime.timedelta = datetime.timedelta(seconds=1),
25
+ ) -> None:
26
+ super().__init__(daemon=True)
27
+ self.backend = backend
28
+ self.interval = interval
29
+ self.shutdown_requested = threading.Event()
30
+
31
+ def main(self) -> None:
32
+ """Perform one maintenance pass."""
33
+
34
+ def run(self) -> None:
35
+ while not self.shutdown_requested.wait(self.interval.total_seconds()):
36
+ self.main()
37
+
38
+ def shutdown(self) -> None:
39
+ """Request graceful shutdown."""
40
+ self.shutdown_requested.set()
41
+
42
+
43
+ def _parse_datetime(value: object) -> object:
44
+ """Parse an ISO datetime string, returning the value unchanged if not parseable."""
45
+ if isinstance(value, str):
46
+ try:
47
+ return datetime.datetime.fromisoformat(value)
48
+ except ValueError:
49
+ return value
50
+ return value
51
+
52
+
53
+ class TaskResultEncoder(DjangoJSONEncoder):
54
+ """JSON encoder for TaskResult and TaskError objects."""
55
+
56
+ def default(self, o):
57
+ if isinstance(o, (TaskResult, TaskError)):
58
+ return {
59
+ field.name: getattr(o, field.name)
60
+ for field in dataclasses.fields(type(o))
61
+ }
62
+ if isinstance(o, Task):
63
+ return {
64
+ field.name: getattr(o, field.name)
65
+ for field in dataclasses.fields(Task)
66
+ if field.name != "func" # Exclude the function object itself
67
+ } | {"func": o.module_path}
68
+ return super().default(o)
69
+
70
+
71
+ class ThreadmillTaskBackend(BaseTaskBackend, ABC):
72
+ """Interface for task queues to be processed by the executor."""
73
+
74
+ supports_async_task = True
75
+ supports_get_result = True
76
+ broker_class: type[Broker] | None = None
77
+
78
+ result_ttl: datetime.timedelta | None = None
79
+
80
+ @staticmethod
81
+ def serialize_task_result(task_result: TaskResult) -> str:
82
+ return json.dumps(task_result, cls=TaskResultEncoder)
83
+
84
+ @classmethod
85
+ def deserialize_task_result(cls, data: str) -> TaskResult:
86
+ def _object_hook(d: dict) -> dict | TaskResult:
87
+ if "task" in d and isinstance(d["task"], dict) and "func" in d["task"]:
88
+ task_data = d["task"]
89
+ func = import_string(task_data["func"])
90
+ if isinstance(func, cls.task_class):
91
+ func = func.func
92
+ d["task"] = cls.task_class(
93
+ func=func,
94
+ **{
95
+ field.name: _parse_datetime(task_data[field.name])
96
+ for field in dataclasses.fields(cls.task_class)
97
+ if field.name not in {"func", "takes_context"}
98
+ and field.name in task_data
99
+ },
100
+ )
101
+ d["status"] = TaskResultStatus(d["status"])
102
+ d["errors"] = [TaskError(**error) for error in d["errors"]]
103
+ return_value = d.pop("_return_value", None)
104
+ for key, value in d.items():
105
+ d[key] = _parse_datetime(value)
106
+ result = TaskResult(**d)
107
+ object.__setattr__(result, "_return_value", return_value)
108
+ return result
109
+ return d
110
+
111
+ return json.loads(data, object_hook=_object_hook)
112
+
113
+ def acquire(
114
+ self,
115
+ *queue_names: str,
116
+ timeout: datetime.timedelta | None = None,
117
+ worker: str = "",
118
+ ) -> TaskResult:
119
+ """
120
+ Return and lock the next task to be processed without removing it from the queue.
121
+
122
+ Args:
123
+ queue_names: The names of the queues to acquire tasks from.
124
+ timeout: The maximum time to wait for a task. If None, wait indefinitely.
125
+ worker: The name of the worker thread acquiring the task.
126
+
127
+ Raises:
128
+ TimeoutError: If no task is available within the specified timeout.
129
+ queue.Empty: If no task is available and timeout is None.
130
+ """
131
+ raise NotImplementedError
132
+
133
+ def acknowledge(self, task_result: TaskResult) -> None:
134
+ """Remove the task from the queue and publish the result."""
135
+ raise NotImplementedError
136
+
137
+ def peek(
138
+ self,
139
+ queue_name: str = DEFAULT_TASK_QUEUE_NAME,
140
+ *,
141
+ status: TaskResultStatus | None = None,
142
+ count: int = 1,
143
+ ) -> collections.abc.Generator[TaskResult, None, None]:
144
+ """Yield acknowledged results from a queue, optionally filtered by status."""
145
+ raise NotImplementedError
@@ -0,0 +1,24 @@
1
+ -- Finalize a completed task: remove it from the running set, persist the
2
+ -- result with a TTL, delete the task data hash, and add the result to the
3
+ -- results history set. Also evict results whose finish score falls outside the
4
+ -- retention window.
5
+ --
6
+ -- KEYS[1] -- running set (ZSET)
7
+ -- KEYS[2] -- result key (STRING, stores serialized TaskResult)
8
+ -- KEYS[3] -- task data key (HASH, deleted after acknowledge)
9
+ -- KEYS[4] -- results history set (ZSET, ordered by finish time)
10
+ -- ARGV[1] -- task ID
11
+ -- ARGV[2] -- serialized TaskResult JSON
12
+ -- ARGV[3] -- result TTL in seconds
13
+ -- ARGV[4] -- finish timestamp in milliseconds (score for the results set)
14
+ -- Returns: 1 on success, 0 if task was not in the running set
15
+
16
+ local removed = redis.call('ZREM', KEYS[1], ARGV[1])
17
+ if removed == 0 then
18
+ return 0 -- Task already reaped, skip
19
+ end
20
+ redis.call('SET', KEYS[2], ARGV[2], 'EX', ARGV[3])
21
+ redis.call('DEL', KEYS[3])
22
+ redis.call('ZADD', KEYS[4], ARGV[4], ARGV[1])
23
+ redis.call('ZREMRANGEBYSCORE', KEYS[4], 0, tonumber(ARGV[4]) - tonumber(ARGV[3]) * 1000)
24
+ return 1
@@ -0,0 +1,44 @@
1
+ -- Atomically pop the lowest-scored task from any of the given priority queues,
2
+ -- update its JSON data with worker info, and move it directly to the running
3
+ -- set. Iterates queues in key order and returns the first available task.
4
+ --
5
+ -- KEYS[1..N] -- interleaved running keys and queue keys, one pair per queue:
6
+ -- KEYS[1] = running set, KEYS[2] = queue set, KEYS[3] = running,
7
+ -- KEYS[4] = queue, etc.
8
+ -- ARGV[1] -- current time in milliseconds
9
+ -- ARGV[2] -- current time as ISO-8601 string
10
+ -- ARGV[3] -- task key prefix (e.g. "threadmill:default:task:")
11
+ -- ARGV[4] -- number of queue pairs (N/2)
12
+ -- ARGV[5] -- worker name
13
+ -- ARGV[6] -- lease TTL in milliseconds
14
+ -- Returns: updated serialized data on success, nil if all queues are empty.
15
+
16
+ local num_queues = tonumber(ARGV[4])
17
+ local lease_ttl_ms = tonumber(ARGV[6])
18
+ for i = 1, num_queues do
19
+ local result = redis.call('ZPOPMIN', KEYS[i * 2])
20
+ if #result > 0 then
21
+ local task_id = result[1]
22
+ local data = redis.call('HGET', ARGV[3] .. task_id, 'data')
23
+ if data then
24
+ local ok, parsed = pcall(cjson.decode, data)
25
+ if ok then
26
+ parsed.status = 'RUNNING'
27
+ parsed.last_attempted_at = ARGV[2]
28
+ if not parsed.started_at then
29
+ parsed.started_at = ARGV[2]
30
+ end
31
+ if not parsed.worker_ids then
32
+ parsed.worker_ids = {}
33
+ end
34
+ table.insert(parsed.worker_ids, ARGV[5])
35
+ local updated_data = cjson.encode(parsed)
36
+ local deadline = tonumber(ARGV[1]) + lease_ttl_ms
37
+ redis.call('ZADD', KEYS[i * 2 - 1], deadline, task_id)
38
+ redis.call('HSET', ARGV[3] .. task_id, 'data', updated_data)
39
+ return updated_data
40
+ end
41
+ end
42
+ end
43
+ end
44
+ return nil
@@ -0,0 +1,19 @@
1
+ -- Move tasks whose scheduled time has passed from the deferred set to the active
2
+ -- priority queue. Only processes up to a batch limit per call.
3
+ --
4
+ -- KEYS[1] -- deferred set (ZSET, scored by run_after timestamp)
5
+ -- KEYS[2] -- active priority queue (ZSET, scored by priority+time)
6
+ -- ARGV[1] -- current time in milliseconds (all scores <= this are due)
7
+ -- ARGV[2] -- task key prefix (e.g. "threadmill:default:task:")
8
+ -- ARGV[3] -- maximum number of tasks to move per call (batch size)
9
+ -- Returns: number of tasks moved
10
+
11
+ local due = redis.call('ZRANGEBYSCORE', KEYS[1], 0, ARGV[1], 'LIMIT', 0, tonumber(ARGV[3]))
12
+ for _, task_id in ipairs(due) do
13
+ redis.call('ZREM', KEYS[1], task_id)
14
+ local score = redis.call('HGET', ARGV[2] .. task_id, 'score')
15
+ if score then
16
+ redis.call('ZADD', KEYS[2], score, task_id)
17
+ end
18
+ end
19
+ return #due
@@ -0,0 +1,36 @@
1
+ -- Fail tasks whose processing lease has expired.
2
+ --
3
+ -- KEYS[1] -- running set (ZSET)
4
+ -- KEYS[2] -- results history set (ZSET)
5
+ -- ARGV[1] -- current time in milliseconds (for score comparison)
6
+ -- ARGV[2] -- task key prefix (e.g. "threadmill:default:task:")
7
+ -- ARGV[3] -- result key prefix (e.g. "threadmill:default:result:")
8
+ -- ARGV[4] -- batch size
9
+ -- ARGV[5] -- result TTL in seconds
10
+ -- ARGV[6] -- finished_at as ISO format string
11
+ -- Returns: number of tasks failed
12
+
13
+ local stale = redis.call('ZRANGEBYSCORE', KEYS[1], 0, ARGV[1], 'LIMIT', 0, tonumber(ARGV[4]))
14
+ for _, task_id in ipairs(stale) do
15
+ local data = redis.call('HGET', ARGV[2] .. task_id, 'data')
16
+ if data then
17
+ local ok, parsed = pcall(cjson.decode, data)
18
+ if ok then
19
+ parsed.status = 'FAILED'
20
+ parsed.finished_at = ARGV[6]
21
+ if not parsed.errors then
22
+ parsed.errors = {}
23
+ end
24
+ table.insert(parsed.errors, {
25
+ exception_class_path = 'threadmill.exceptions.AcknowledgementTimeout',
26
+ traceback = 'Task processing lease expired.'
27
+ })
28
+ local failed_data = cjson.encode(parsed)
29
+ redis.call('ZREM', KEYS[1], task_id)
30
+ redis.call('SET', ARGV[3] .. task_id, failed_data, 'EX', ARGV[5])
31
+ redis.call('DEL', ARGV[2] .. task_id)
32
+ redis.call('ZADD', KEYS[2], tonumber(ARGV[1]), task_id)
33
+ end
34
+ end
35
+ end
36
+ return #stale