threadmill 0.1.0__tar.gz → 0.3.1__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.
@@ -0,0 +1,180 @@
1
+ Metadata-Version: 2.4
2
+ Name: threadmill
3
+ Version: 0.3.1
4
+ Summary: The most reliable backend for Django's task framework.
5
+ Keywords: Django,tasks,worker
6
+ Author-email: Johannes Maron <johannes@maron.family>
7
+ Requires-Python: >=3.12
8
+ Description-Content-Type: text/markdown
9
+ Classifier: Development Status :: 4 - Beta
10
+ Classifier: Programming Language :: Python
11
+ Classifier: Environment :: Web Environment
12
+ Classifier: License :: OSI Approved :: BSD License
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Operating System :: Microsoft :: Windows
15
+ Classifier: Operating System :: MacOS :: MacOS X
16
+ Classifier: Operating System :: POSIX
17
+ Classifier: Topic :: Communications :: Email
18
+ Classifier: Topic :: Text Processing :: Markup :: Markdown
19
+ Classifier: Topic :: Software Development
20
+ Classifier: Programming Language :: Python
21
+ Classifier: Programming Language :: Python :: 3
22
+ Classifier: Programming Language :: Python :: 3 :: Only
23
+ Classifier: Programming Language :: Python :: 3.12
24
+ Classifier: Programming Language :: Python :: 3.13
25
+ Classifier: Programming Language :: Python :: 3.14
26
+ Classifier: Framework :: Django
27
+ Classifier: Framework :: Django :: 6.0
28
+ Classifier: Framework :: Django :: 6.1
29
+ License-File: LICENSE
30
+ Requires-Dist: django>=6.0
31
+ Requires-Dist: textual>=8.2.7 ; extra == "inspector"
32
+ Requires-Dist: redis>=5.0 ; extra == "redis"
33
+ Project-URL: Changelog, https://github.com/codingjoe/threadmill/releases
34
+ Project-URL: Documentation, https://github.com/codingjoe/threadmill/
35
+ Project-URL: Funding, https://github.com/sponsors/codingjoe
36
+ Project-URL: Homepage, https://github.com/codingjoe/threadmill
37
+ Project-URL: Issues, https://github.com/codingjoe/threadmill/issues
38
+ Project-URL: Releasenotes, https://github.com/codingjoe/threadmill/releases/latest
39
+ Project-URL: Source, https://github.com/codingjoe/threadmill
40
+ Provides-Extra: inspector
41
+ Provides-Extra: redis
42
+
43
+ <p align="center">
44
+ <picture>
45
+ <source media="(prefers-color-scheme: dark)" srcset="https://github.com/codingjoe/threadmill/raw/main/docs/images/logo-dark.svg">
46
+ <source media="(prefers-color-scheme: light)" srcset="https://github.com/codingjoe/threadmill/raw/main/docs/images/logo-light.svg">
47
+ <img alt="Threadmill: Durable high-performance backend for Django's task framework." src="https://github.com/codingjoe/threadmill/raw/main/docs/images/logo-light.svg">
48
+ </picture>
49
+ <br>
50
+ <a href="https://github.com/codingjoe/threadmill/">Documentation</a> |
51
+ <a href="https://github.com/codingjoe/threadmill/issues/new/choose">Issues</a> |
52
+ <a href="https://github.com/codingjoe/threadmill/releases">Changelog</a> |
53
+ <a href="https://github.com/sponsors/codingjoe">Funding</a> 💚
54
+ </p>
55
+
56
+ # Threadmill [![PyPi Version](https://img.shields.io/pypi/v/threadmill.svg)](https://pypi.python.org/pypi/threadmill/) [![Test Coverage](https://codecov.io/gh/codingjoe/threadmill/branch/main/graph/badge.svg)](https://codecov.io/gh/codingjoe/threadmill) [![GitHub License](https://img.shields.io/github/license/codingjoe/threadmill)](https://raw.githubusercontent.com/codingjoe/threadmill/master/LICENSE)
57
+
58
+ **Durable high-performance backend for Django's task framework.**
59
+
60
+ ## Design Principles
61
+
62
+ - **Durability** – We recover from any failures, even poorly written tasks.
63
+ - **Consistency** – We never lose data, even if someone unplugs the power or network.
64
+ - **Utilization** – We keep the CPU saturated with tasks, not with idle time or waiting for locks.
65
+
66
+ ## Sponsors
67
+
68
+ [![Sponsors](https://django.the-box.sh/sponsors/codingjoe/threadmill.svg)](https://github.com/sponsors/codingjoe)
69
+
70
+ ## Setup
71
+
72
+ You need to have [Django's Task framework][django-tasks] set up properly.
73
+
74
+ ```console
75
+ uv add threadmill[redis]
76
+ ```
77
+
78
+ Add `threadmill` to your `INSTALLED_APPS` in `settings.py`
79
+ and configure the task backend:
80
+
81
+ ```python
82
+ # settings.py
83
+ import os
84
+
85
+ INSTALLED_APPS = [
86
+ "threadmill",
87
+ # ...
88
+ ]
89
+
90
+ TASKS = {
91
+ "default": {
92
+ "BACKEND": "threadmill.backends.redis.RedisTaskBackend",
93
+ "REDIS_URL": os.getenv("REDIS_URL", "redis://localhost:6379/0"),
94
+ },
95
+ # ...
96
+ }
97
+ ```
98
+
99
+ Optionally, install the inspector dependency if you want the TUI:
100
+
101
+ ```console
102
+ uv add threadmill[inspector]
103
+ ```
104
+
105
+ Then launch the worker pool:
106
+
107
+ ```console
108
+ uv run manage.py threadmill worker
109
+ ```
110
+
111
+ ## Usage
112
+
113
+ ### Workers
114
+
115
+ The workers are inspired by Gunicorn, and the CLI is very similar.
116
+
117
+ #### Utilization
118
+
119
+ Depending on your workload, you can tweak the number of processes and threads.
120
+ Processes allow for parallel compute (no GIL) while threads are great for low-memory concurrent IO.
121
+
122
+ ```console
123
+ uv run manage.py threadmill worker --processes 4 --threads 2
124
+ ```
125
+
126
+ #### Health
127
+
128
+ If your tasks leak memory, you can recycle (restart) the workers after a certain number of tasks have been processed:
129
+
130
+ ```console
131
+ uv run manage.py threadmill worker --max-tasks 1000 --max-tasks-jitter 100
132
+ ```
133
+
134
+ This will restart the workers after 1000 tasks have been processed, with a random jitter of up to 100 tasks to avoid all workers restarting at the same time.
135
+
136
+ Should a worker crash or be killed, the pool will automatically restart it.
137
+
138
+ #### Shutdown
139
+
140
+ A graceful shutdown is possible with the `SIGTERM` or a keyboard interrupt.
141
+ All workers will finish the tasks they acquired and acknowledge them.
142
+
143
+ You can use `--exit-empty` to exit immediately after all tasks have been processed,
144
+ which might be useful for draining a one-off queue.
145
+
146
+ ### Inspector
147
+
148
+ ![Inspector TUI screenshot](https://github.com/codingjoe/threadmill/raw/main/docs/images/TUI-screenshot.svg)
149
+
150
+ The optional TUI inspector lets you watch queues, tasks, and task details in real-time.
151
+ Install it with the `inspector` extra and launch it from a separate terminal:
152
+
153
+ ```console
154
+ uv add threadmill[inspector]
155
+ uv run manage.py threadmill inspector
156
+ ```
157
+
158
+ ### Redis Backend Options
159
+
160
+ The `RedisTaskBackend` accepts the following options under `OPTIONS` in your
161
+ `TASKS` configuration:
162
+
163
+ | Option | Default | Description |
164
+ | ----------------- | ---------------------- | ------------------------------------------------------------ |
165
+ | `lease_ttl` | `timedelta(hours=1)` | Max processing time before a started task is marked FAILED. |
166
+ | `result_ttl` | `timedelta(days=1)` | How long task results are retained before automatic removal. |
167
+ | `broker_interval` | `timedelta(seconds=1)` | Interval between background broker maintenance passes. |
168
+ | `batch_size` | `100` | Max tasks to move or requeue per broker pass. |
169
+
170
+ A task that is started but never acknowledged (lease expired) is marked FAILED
171
+ with an `AcknowledgementTimeout` error. Set `lease_ttl` comfortably above your
172
+ worst-case task runtime.
173
+
174
+ All keys for one backend alias share a Redis Cluster hash tag (`{alias}`), so
175
+ every multi-key operation — including the cross-queue acquire — runs on a single
176
+ shard. Scale horizontally by running additional backend aliases, not by relying
177
+ on cross-slot operations.
178
+
179
+ [django-tasks]: https://docs.djangoproject.com/en/stable/topics/tasks/
180
+
@@ -0,0 +1,137 @@
1
+ <p align="center">
2
+ <picture>
3
+ <source media="(prefers-color-scheme: dark)" srcset="https://github.com/codingjoe/threadmill/raw/main/docs/images/logo-dark.svg">
4
+ <source media="(prefers-color-scheme: light)" srcset="https://github.com/codingjoe/threadmill/raw/main/docs/images/logo-light.svg">
5
+ <img alt="Threadmill: Durable high-performance backend for Django's task framework." src="https://github.com/codingjoe/threadmill/raw/main/docs/images/logo-light.svg">
6
+ </picture>
7
+ <br>
8
+ <a href="https://github.com/codingjoe/threadmill/">Documentation</a> |
9
+ <a href="https://github.com/codingjoe/threadmill/issues/new/choose">Issues</a> |
10
+ <a href="https://github.com/codingjoe/threadmill/releases">Changelog</a> |
11
+ <a href="https://github.com/sponsors/codingjoe">Funding</a> 💚
12
+ </p>
13
+
14
+ # Threadmill [![PyPi Version](https://img.shields.io/pypi/v/threadmill.svg)](https://pypi.python.org/pypi/threadmill/) [![Test Coverage](https://codecov.io/gh/codingjoe/threadmill/branch/main/graph/badge.svg)](https://codecov.io/gh/codingjoe/threadmill) [![GitHub License](https://img.shields.io/github/license/codingjoe/threadmill)](https://raw.githubusercontent.com/codingjoe/threadmill/master/LICENSE)
15
+
16
+ **Durable high-performance backend for Django's task framework.**
17
+
18
+ ## Design Principles
19
+
20
+ - **Durability** – We recover from any failures, even poorly written tasks.
21
+ - **Consistency** – We never lose data, even if someone unplugs the power or network.
22
+ - **Utilization** – We keep the CPU saturated with tasks, not with idle time or waiting for locks.
23
+
24
+ ## Sponsors
25
+
26
+ [![Sponsors](https://django.the-box.sh/sponsors/codingjoe/threadmill.svg)](https://github.com/sponsors/codingjoe)
27
+
28
+ ## Setup
29
+
30
+ You need to have [Django's Task framework][django-tasks] set up properly.
31
+
32
+ ```console
33
+ uv add threadmill[redis]
34
+ ```
35
+
36
+ Add `threadmill` to your `INSTALLED_APPS` in `settings.py`
37
+ and configure the task backend:
38
+
39
+ ```python
40
+ # settings.py
41
+ import os
42
+
43
+ INSTALLED_APPS = [
44
+ "threadmill",
45
+ # ...
46
+ ]
47
+
48
+ TASKS = {
49
+ "default": {
50
+ "BACKEND": "threadmill.backends.redis.RedisTaskBackend",
51
+ "REDIS_URL": os.getenv("REDIS_URL", "redis://localhost:6379/0"),
52
+ },
53
+ # ...
54
+ }
55
+ ```
56
+
57
+ Optionally, install the inspector dependency if you want the TUI:
58
+
59
+ ```console
60
+ uv add threadmill[inspector]
61
+ ```
62
+
63
+ Then launch the worker pool:
64
+
65
+ ```console
66
+ uv run manage.py threadmill worker
67
+ ```
68
+
69
+ ## Usage
70
+
71
+ ### Workers
72
+
73
+ The workers are inspired by Gunicorn, and the CLI is very similar.
74
+
75
+ #### Utilization
76
+
77
+ Depending on your workload, you can tweak the number of processes and threads.
78
+ Processes allow for parallel compute (no GIL) while threads are great for low-memory concurrent IO.
79
+
80
+ ```console
81
+ uv run manage.py threadmill worker --processes 4 --threads 2
82
+ ```
83
+
84
+ #### Health
85
+
86
+ If your tasks leak memory, you can recycle (restart) the workers after a certain number of tasks have been processed:
87
+
88
+ ```console
89
+ uv run manage.py threadmill worker --max-tasks 1000 --max-tasks-jitter 100
90
+ ```
91
+
92
+ This will restart the workers after 1000 tasks have been processed, with a random jitter of up to 100 tasks to avoid all workers restarting at the same time.
93
+
94
+ Should a worker crash or be killed, the pool will automatically restart it.
95
+
96
+ #### Shutdown
97
+
98
+ A graceful shutdown is possible with the `SIGTERM` or a keyboard interrupt.
99
+ All workers will finish the tasks they acquired and acknowledge them.
100
+
101
+ You can use `--exit-empty` to exit immediately after all tasks have been processed,
102
+ which might be useful for draining a one-off queue.
103
+
104
+ ### Inspector
105
+
106
+ ![Inspector TUI screenshot](https://github.com/codingjoe/threadmill/raw/main/docs/images/TUI-screenshot.svg)
107
+
108
+ The optional TUI inspector lets you watch queues, tasks, and task details in real-time.
109
+ Install it with the `inspector` extra and launch it from a separate terminal:
110
+
111
+ ```console
112
+ uv add threadmill[inspector]
113
+ uv run manage.py threadmill inspector
114
+ ```
115
+
116
+ ### Redis Backend Options
117
+
118
+ The `RedisTaskBackend` accepts the following options under `OPTIONS` in your
119
+ `TASKS` configuration:
120
+
121
+ | Option | Default | Description |
122
+ | ----------------- | ---------------------- | ------------------------------------------------------------ |
123
+ | `lease_ttl` | `timedelta(hours=1)` | Max processing time before a started task is marked FAILED. |
124
+ | `result_ttl` | `timedelta(days=1)` | How long task results are retained before automatic removal. |
125
+ | `broker_interval` | `timedelta(seconds=1)` | Interval between background broker maintenance passes. |
126
+ | `batch_size` | `100` | Max tasks to move or requeue per broker pass. |
127
+
128
+ A task that is started but never acknowledged (lease expired) is marked FAILED
129
+ with an `AcknowledgementTimeout` error. Set `lease_ttl` comfortably above your
130
+ worst-case task runtime.
131
+
132
+ All keys for one backend alias share a Redis Cluster hash tag (`{alias}`), so
133
+ every multi-key operation — including the cross-queue acquire — runs on a single
134
+ shard. Scale horizontally by running additional backend aliases, not by relying
135
+ on cross-slot operations.
136
+
137
+ [django-tasks]: https://docs.djangoproject.com/en/stable/topics/tasks/
@@ -30,10 +30,17 @@ classifiers = [
30
30
  "Programming Language :: Python :: 3.13",
31
31
  "Programming Language :: Python :: 3.14",
32
32
  "Framework :: Django",
33
+ "Framework :: Django :: 6.0",
33
34
  "Framework :: Django :: 6.1",
34
35
  ]
35
36
  requires-python = ">=3.12"
36
- dependencies = ["django>=6.1a1"]
37
+ dependencies = ["django>=6.0"]
38
+
39
+ [project.optional-dependencies]
40
+ redis = ["redis>=5.0"]
41
+ inspector = [
42
+ "textual>=8.2.7",
43
+ ]
37
44
 
38
45
  [project.urls]
39
46
  # https://packaging.python.org/en/latest/specifications/well-known-project-urls/#well-known-labels
@@ -56,9 +63,9 @@ minversion = "6.0"
56
63
  addopts = "--cov --cov-report=xml --cov-report=term --tb=short -rxs --benchmark-autosave --benchmark-group-by=fullname --benchmark-min-rounds=10"
57
64
  testpaths = ["tests"]
58
65
  DJANGO_SETTINGS_MODULE = "tests.testapp.settings"
66
+ asyncio_mode = "auto"
59
67
  markers = [
60
68
  "benchmark: mark benchmark tests.",
61
- "integration: mark integration tests.",
62
69
  ]
63
70
 
64
71
  [tool.coverage.run]
@@ -91,6 +98,7 @@ combine-as-imports = true
91
98
  split-on-trailing-comma = true
92
99
  section-order = ["future", "standard-library", "third-party", "first-party", "local-folder"]
93
100
  force-wrap-aliases = true
101
+ known-first-party = ["threadmill", "tests"]
94
102
 
95
103
  [tool.ruff.lint.pydocstyle]
96
104
  convention = "pep257"
@@ -105,4 +113,5 @@ test = [
105
113
  "pytest-asyncio",
106
114
  "pytest-cov",
107
115
  "pytest-django",
116
+ "redis>=5.0",
108
117
  ]
@@ -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.3.1'
22
+ __version_tuple__ = version_tuple = (0, 3, 1)
23
23
 
24
- __commit_id__ = commit_id = 'g7270fd320'
24
+ __commit_id__ = commit_id = 'g32b680b8b'
@@ -0,0 +1,220 @@
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
+ import django
11
+ from django.core.serializers.json import DjangoJSONEncoder
12
+ from django.tasks import DEFAULT_TASK_QUEUE_NAME, Task, TaskResult, TaskResultStatus
13
+ from django.tasks.backends.base import BaseTaskBackend
14
+ from django.tasks.base import TaskError
15
+ from django.utils.module_loading import import_string
16
+
17
+ if django.VERSION == (6, 0):
18
+ # https://github.com/django/django/commit/8c8b833d32c02d3ae6f43b04bb1e45968796b402
19
+ @dataclasses.dataclass(frozen=True, slots=True, kw_only=True)
20
+ class Task(Task):
21
+ @classmethod
22
+ def _reconstruct(cls, kwargs):
23
+ func_path = kwargs["func"]
24
+ try:
25
+ func = import_string(func_path)
26
+ kwargs["func"] = func.func
27
+ except (ImportError, AttributeError) as e:
28
+ msg = f"Expected {func_path!r} to point to a Task instance."
29
+ raise ValueError(msg) from e
30
+ return cls(**kwargs)
31
+
32
+ def __reduce__(self):
33
+ kwargs = {f.name: getattr(self, f.name) for f in dataclasses.fields(self)}
34
+ kwargs["func"] = self.module_path
35
+
36
+ return (self.__class__._reconstruct, (kwargs,))
37
+
38
+
39
+ @dataclasses.dataclass(kw_only=True, slots=True)
40
+ class QueueCounts:
41
+ """Point-in-time cardinality of each queue segment."""
42
+
43
+ ready: int
44
+ running: int
45
+ deferred: int
46
+ successful: int
47
+ failed: int
48
+
49
+
50
+ @dataclasses.dataclass(kw_only=True, slots=True)
51
+ class QueueRates:
52
+ """Rolling ingress/egress throughput over a time window (time-series data)."""
53
+
54
+ interval: datetime.timedelta
55
+ ingress: int
56
+ egress: int
57
+
58
+
59
+ @dataclasses.dataclass(kw_only=True, slots=True)
60
+ class QueueStats:
61
+ """Telemetry for a single queue: point-in-time counts plus rolling rates."""
62
+
63
+ counts: QueueCounts
64
+ rates: QueueRates
65
+
66
+
67
+ @dataclasses.dataclass(kw_only=True, slots=True)
68
+ class BackendTelemetry:
69
+ """Snapshot of counts and rates across a backend's queues."""
70
+
71
+ queues: dict[str, QueueStats]
72
+
73
+
74
+ class Broker(threading.Thread):
75
+ """Backend maintenance thread launched by the task executor."""
76
+
77
+ def __init__(
78
+ self,
79
+ backend: ThreadmillTaskBackend | None = None,
80
+ *,
81
+ interval: datetime.timedelta = datetime.timedelta(seconds=1),
82
+ ) -> None:
83
+ super().__init__(daemon=True)
84
+ self.backend = backend
85
+ self.interval = interval
86
+ self.shutdown_requested = threading.Event()
87
+
88
+ def main(self) -> None:
89
+ """Perform one maintenance pass."""
90
+
91
+ def run(self) -> None:
92
+ while not self.shutdown_requested.wait(self.interval.total_seconds()):
93
+ self.main()
94
+
95
+ def shutdown(self) -> None:
96
+ """Request graceful shutdown."""
97
+ self.shutdown_requested.set()
98
+
99
+
100
+ def _parse_datetime(value: object) -> object:
101
+ """Parse an ISO datetime string, returning the value unchanged if not parseable."""
102
+ if isinstance(value, str):
103
+ try:
104
+ return datetime.datetime.fromisoformat(value)
105
+ except ValueError:
106
+ return value
107
+ return value
108
+
109
+
110
+ class TaskResultEncoder(DjangoJSONEncoder):
111
+ """JSON encoder for TaskResult and TaskError objects."""
112
+
113
+ def default(self, o):
114
+ if isinstance(o, (TaskResult, TaskError)):
115
+ return {
116
+ field.name: getattr(o, field.name)
117
+ for field in dataclasses.fields(type(o))
118
+ }
119
+ if isinstance(o, Task):
120
+ return {
121
+ field.name: getattr(o, field.name)
122
+ for field in dataclasses.fields(Task)
123
+ if field.name != "func" # Exclude the function object itself
124
+ } | {"func": o.module_path}
125
+ return super().default(o)
126
+
127
+
128
+ class ThreadmillTaskBackend(BaseTaskBackend, ABC):
129
+ """Interface for task queues to be processed by the executor."""
130
+
131
+ task_class = Task # can be removed in the future when Django 6.0 support is dropped
132
+ supports_async_task = True
133
+ supports_get_result = True
134
+ broker_class: type[Broker] | None = None
135
+
136
+ result_ttl: datetime.timedelta | None = None
137
+
138
+ @staticmethod
139
+ def serialize_task_result(task_result: TaskResult) -> str:
140
+ return json.dumps(task_result, cls=TaskResultEncoder)
141
+
142
+ @classmethod
143
+ def deserialize_task_result(cls, data: str) -> TaskResult:
144
+ def _object_hook(d: dict) -> dict | TaskResult:
145
+ if "task" in d and isinstance(d["task"], dict) and "func" in d["task"]:
146
+ task_data = d["task"]
147
+ func = import_string(task_data["func"])
148
+ if isinstance(func, cls.task_class):
149
+ func = func.func
150
+ d["task"] = cls.task_class(
151
+ func=func,
152
+ **{
153
+ field.name: _parse_datetime(task_data[field.name])
154
+ for field in dataclasses.fields(cls.task_class)
155
+ if field.name not in {"func", "takes_context"}
156
+ and field.name in task_data
157
+ },
158
+ )
159
+ d["status"] = TaskResultStatus(d["status"])
160
+ d["errors"] = [TaskError(**error) for error in d["errors"]]
161
+ return_value = d.pop("_return_value", None)
162
+ for key, value in d.items():
163
+ d[key] = _parse_datetime(value)
164
+ result = TaskResult(**d)
165
+ object.__setattr__(result, "_return_value", return_value)
166
+ return result
167
+ return d
168
+
169
+ return json.loads(data, object_hook=_object_hook)
170
+
171
+ def acquire(
172
+ self,
173
+ *queue_names: str,
174
+ timeout: datetime.timedelta | None = None,
175
+ worker: str = "",
176
+ ) -> TaskResult:
177
+ """
178
+ Return and lock the next task to be processed without removing it from the queue.
179
+
180
+ Args:
181
+ queue_names: The names of the queues to acquire tasks from.
182
+ timeout: The maximum time to wait for a task. If None, wait indefinitely.
183
+ worker: The name of the worker thread acquiring the task.
184
+
185
+ Raises:
186
+ TimeoutError: If no task is available within the specified timeout.
187
+ queue.Empty: If no task is available and timeout is None.
188
+ """
189
+ raise NotImplementedError
190
+
191
+ def acknowledge(self, task_result: TaskResult) -> None:
192
+ """Remove the task from the queue and publish the result."""
193
+ raise NotImplementedError
194
+
195
+ def peek(
196
+ self,
197
+ queue_name: str = DEFAULT_TASK_QUEUE_NAME,
198
+ *,
199
+ status: TaskResultStatus,
200
+ count: int = 1,
201
+ ) -> collections.abc.Generator[TaskResult, None, None]:
202
+ """
203
+ Yield up to ``count`` tasks from a queue in the given status segment.
204
+
205
+ Args:
206
+ queue_name: The name of the queue to peek into.
207
+ status: The status of the tasks to yield.
208
+ count: The maximum number of tasks to yield. If 0, yield all available tasks.
209
+ """
210
+ raise NotImplementedError
211
+
212
+ def telemetry(
213
+ self, *, interval: datetime.timedelta = datetime.timedelta(seconds=60)
214
+ ) -> BackendTelemetry:
215
+ """Return a snapshot of stats for all configured queues.
216
+
217
+ Args:
218
+ interval: The time window for rolling rates.
219
+ """
220
+ raise NotImplementedError
@@ -0,0 +1,34 @@
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
+ -- per-status results history set. Evicts results whose finish score falls
4
+ -- outside the retention window (result_ttl). The per-status history sets are
5
+ -- the time series the inspector counts over for telemetry, so no separate
6
+ -- egress window or status counters are needed here.
7
+ --
8
+ -- KEYS[1] -- running set (ZSET)
9
+ -- KEYS[2] -- result key (STRING, stores serialized TaskResult)
10
+ -- KEYS[3] -- task data key (HASH, deleted after acknowledge)
11
+ -- KEYS[4] -- successful results history (ZSET, scored by finish time)
12
+ -- KEYS[5] -- failed results history (ZSET, scored by finish time)
13
+ -- ARGV[1] -- task ID
14
+ -- ARGV[2] -- serialized TaskResult JSON
15
+ -- ARGV[3] -- result TTL in seconds
16
+ -- ARGV[4] -- finish timestamp in milliseconds (score for the history set)
17
+ -- ARGV[5] -- status (SUCCESSFUL or FAILED)
18
+ -- Returns: 1 on success, 0 if task was not in the running set
19
+
20
+ local removed = redis.call('ZREM', KEYS[1], ARGV[1])
21
+ if removed == 0 then
22
+ return 0 -- Task already reaped, skip
23
+ end
24
+ redis.call('SET', KEYS[2], ARGV[2], 'EX', ARGV[3])
25
+ redis.call('DEL', KEYS[3])
26
+ local finish = tonumber(ARGV[4])
27
+ local cutoff = finish - tonumber(ARGV[3]) * 1000
28
+ local results_key = KEYS[4]
29
+ if ARGV[5] ~= 'SUCCESSFUL' then
30
+ results_key = KEYS[5]
31
+ end
32
+ redis.call('ZADD', results_key, finish, ARGV[1])
33
+ redis.call('ZREMRANGEBYSCORE', results_key, 0, cutoff)
34
+ 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