threadmill 0.7.0__tar.gz → 0.7.2__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.
- {threadmill-0.7.0 → threadmill-0.7.2}/PKG-INFO +10 -7
- {threadmill-0.7.0 → threadmill-0.7.2}/README.md +8 -6
- {threadmill-0.7.0 → threadmill-0.7.2}/pyproject.toml +1 -0
- {threadmill-0.7.0 → threadmill-0.7.2}/threadmill/_version.py +3 -3
- {threadmill-0.7.0 → threadmill-0.7.2}/threadmill/backends/lua/acquire.lua +9 -4
- {threadmill-0.7.0 → threadmill-0.7.2}/threadmill/backends/redis.py +25 -6
- {threadmill-0.7.0 → threadmill-0.7.2}/threadmill/executor.py +10 -0
- {threadmill-0.7.0 → threadmill-0.7.2}/threadmill/management/commands/threadmill.py +18 -1
- {threadmill-0.7.0 → threadmill-0.7.2}/LICENSE +0 -0
- {threadmill-0.7.0 → threadmill-0.7.2}/threadmill/__init__.py +0 -0
- {threadmill-0.7.0 → threadmill-0.7.2}/threadmill/apps.py +0 -0
- {threadmill-0.7.0 → threadmill-0.7.2}/threadmill/backends/__init__.py +0 -0
- {threadmill-0.7.0 → threadmill-0.7.2}/threadmill/backends/base.py +0 -0
- {threadmill-0.7.0 → threadmill-0.7.2}/threadmill/backends/lua/acknowledge.lua +0 -0
- {threadmill-0.7.0 → threadmill-0.7.2}/threadmill/backends/lua/mover.lua +0 -0
- {threadmill-0.7.0 → threadmill-0.7.2}/threadmill/backends/lua/reaper.lua +0 -0
- {threadmill-0.7.0 → threadmill-0.7.2}/threadmill/exceptions.py +0 -0
- {threadmill-0.7.0 → threadmill-0.7.2}/threadmill/inspector/__init__.py +0 -0
- {threadmill-0.7.0 → threadmill-0.7.2}/threadmill/inspector/app.py +0 -0
- {threadmill-0.7.0 → threadmill-0.7.2}/threadmill/inspector/inspector.scss +0 -0
- {threadmill-0.7.0 → threadmill-0.7.2}/threadmill/inspector/screens.py +0 -0
- {threadmill-0.7.0 → threadmill-0.7.2}/threadmill/inspector/telemetry.py +0 -0
- {threadmill-0.7.0 → threadmill-0.7.2}/threadmill/management/__init__.py +0 -0
- {threadmill-0.7.0 → threadmill-0.7.2}/threadmill/management/commands/__init__.py +0 -0
- {threadmill-0.7.0 → threadmill-0.7.2}/threadmill/retry.py +0 -0
- {threadmill-0.7.0 → threadmill-0.7.2}/threadmill/signals.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: threadmill
|
|
3
|
-
Version: 0.7.
|
|
3
|
+
Version: 0.7.2
|
|
4
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>
|
|
@@ -18,6 +18,7 @@ Classifier: Operating System :: POSIX
|
|
|
18
18
|
Classifier: Programming Language :: Python
|
|
19
19
|
Classifier: Programming Language :: Python :: 3 :: Only
|
|
20
20
|
Classifier: Programming Language :: Python :: 3.14
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.15
|
|
21
22
|
Classifier: Topic :: Communications :: Email
|
|
22
23
|
Classifier: Topic :: Software Development
|
|
23
24
|
Classifier: Topic :: Text Processing :: Markup :: Markdown
|
|
@@ -151,12 +152,14 @@ uv run manage.py threadmill inspector
|
|
|
151
152
|
The `RedisTaskBackend` accepts the following options under `OPTIONS` in your
|
|
152
153
|
`TASKS` configuration:
|
|
153
154
|
|
|
154
|
-
| Option
|
|
155
|
-
|
|
|
156
|
-
| `lease_ttl`
|
|
157
|
-
| `result_ttl`
|
|
158
|
-
| `broker_interval`
|
|
159
|
-
| `batch_size`
|
|
155
|
+
| Option | Default | Description |
|
|
156
|
+
| ------------------- | ------------------------- | ----------------------------------------------------------------------- |
|
|
157
|
+
| `lease_ttl` | `timedelta(hours=1)` | Max processing time before a started task is marked FAILED. |
|
|
158
|
+
| `result_ttl` | `timedelta(days=1)` | How long task results are retained before automatic removal. |
|
|
159
|
+
| `broker_interval` | `timedelta(seconds=1)` | Interval between background broker maintenance passes. |
|
|
160
|
+
| `batch_size` | `100` | Max tasks to move or requeue per broker pass. |
|
|
161
|
+
| `poll_interval` | `timedelta(seconds=0.01)` | Base wait between idle acquire attempts, doubled after each empty poll. |
|
|
162
|
+
| `poll_max_interval` | `timedelta(seconds=1)` | Max wait between idle acquire attempts. |
|
|
160
163
|
|
|
161
164
|
A task that is started but never acknowledged (lease expired) is marked FAILED
|
|
162
165
|
with an `AcknowledgementTimeout` error. Set `lease_ttl` comfortably above your
|
|
@@ -114,12 +114,14 @@ uv run manage.py threadmill inspector
|
|
|
114
114
|
The `RedisTaskBackend` accepts the following options under `OPTIONS` in your
|
|
115
115
|
`TASKS` configuration:
|
|
116
116
|
|
|
117
|
-
| Option
|
|
118
|
-
|
|
|
119
|
-
| `lease_ttl`
|
|
120
|
-
| `result_ttl`
|
|
121
|
-
| `broker_interval`
|
|
122
|
-
| `batch_size`
|
|
117
|
+
| Option | Default | Description |
|
|
118
|
+
| ------------------- | ------------------------- | ----------------------------------------------------------------------- |
|
|
119
|
+
| `lease_ttl` | `timedelta(hours=1)` | Max processing time before a started task is marked FAILED. |
|
|
120
|
+
| `result_ttl` | `timedelta(days=1)` | How long task results are retained before automatic removal. |
|
|
121
|
+
| `broker_interval` | `timedelta(seconds=1)` | Interval between background broker maintenance passes. |
|
|
122
|
+
| `batch_size` | `100` | Max tasks to move or requeue per broker pass. |
|
|
123
|
+
| `poll_interval` | `timedelta(seconds=0.01)` | Base wait between idle acquire attempts, doubled after each empty poll. |
|
|
124
|
+
| `poll_max_interval` | `timedelta(seconds=1)` | Max wait between idle acquire attempts. |
|
|
123
125
|
|
|
124
126
|
A task that is started but never acknowledged (lease expired) is marked FAILED
|
|
125
127
|
with an `AcknowledgementTimeout` error. Set `lease_ttl` comfortably above your
|
|
@@ -24,6 +24,7 @@ classifiers = [
|
|
|
24
24
|
"Programming Language :: Python",
|
|
25
25
|
"Programming Language :: Python :: 3 :: Only",
|
|
26
26
|
"Programming Language :: Python :: 3.14",
|
|
27
|
+
"Programming Language :: Python :: 3.15",
|
|
27
28
|
"Topic :: Communications :: Email",
|
|
28
29
|
"Topic :: Software Development",
|
|
29
30
|
"Topic :: Text Processing :: Markup :: Markdown",
|
|
@@ -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.7.
|
|
22
|
-
__version_tuple__ = version_tuple = (0, 7,
|
|
21
|
+
__version__ = version = '0.7.2'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 7, 2)
|
|
23
23
|
|
|
24
|
-
__commit_id__ = commit_id = '
|
|
24
|
+
__commit_id__ = commit_id = 'g560d3b3b5'
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
-- Atomically pop the lowest-scored task from any of the given priority queues,
|
|
2
2
|
-- update its JSON data with worker info, and move it directly to the running
|
|
3
|
-
-- set.
|
|
3
|
+
-- set. Scans the queues round-robin from ARGV[7] and returns the first available
|
|
4
|
+
-- task, so a backlogged queue cannot starve its neighbours.
|
|
4
5
|
--
|
|
5
6
|
-- KEYS[1..N] -- interleaved running keys and queue keys, one pair per queue:
|
|
6
7
|
-- KEYS[1] = running set, KEYS[2] = queue set, KEYS[3] = running,
|
|
@@ -11,12 +12,16 @@
|
|
|
11
12
|
-- ARGV[4] -- number of queue pairs (N/2)
|
|
12
13
|
-- ARGV[5] -- worker name
|
|
13
14
|
-- ARGV[6] -- lease TTL in milliseconds
|
|
15
|
+
-- ARGV[7] -- start_index; 0-based index of the queue pair to scan first, so
|
|
16
|
+
-- start_index 0 is the pair at KEYS[1] and KEYS[2]
|
|
14
17
|
-- Returns: updated serialized data on success, nil if all queues are empty.
|
|
15
18
|
|
|
16
19
|
local num_queues = tonumber(ARGV[4])
|
|
17
20
|
local lease_ttl_ms = tonumber(ARGV[6])
|
|
18
|
-
|
|
19
|
-
|
|
21
|
+
local start_index = tonumber(ARGV[7])
|
|
22
|
+
for offset = 0, num_queues - 1 do
|
|
23
|
+
local queue_index = (start_index + offset) % num_queues + 1
|
|
24
|
+
local result = redis.call('ZPOPMIN', KEYS[queue_index * 2])
|
|
20
25
|
if #result > 0 then
|
|
21
26
|
local task_id = result[1]
|
|
22
27
|
local data = redis.call('HGET', ARGV[3] .. task_id, 'data')
|
|
@@ -34,7 +39,7 @@ for i = 1, num_queues do
|
|
|
34
39
|
table.insert(parsed.worker_ids, ARGV[5])
|
|
35
40
|
local updated_data = cjson.encode(parsed)
|
|
36
41
|
local deadline = tonumber(ARGV[1]) + lease_ttl_ms
|
|
37
|
-
redis.call('ZADD', KEYS[
|
|
42
|
+
redis.call('ZADD', KEYS[queue_index * 2 - 1], deadline, task_id)
|
|
38
43
|
redis.call('HSET', ARGV[3] .. task_id, 'data', updated_data)
|
|
39
44
|
return updated_data
|
|
40
45
|
end
|
|
@@ -5,6 +5,7 @@ import dataclasses
|
|
|
5
5
|
import datetime
|
|
6
6
|
import logging
|
|
7
7
|
import queue
|
|
8
|
+
import random
|
|
8
9
|
import time
|
|
9
10
|
import uuid
|
|
10
11
|
from collections.abc import Generator, Sequence
|
|
@@ -155,6 +156,14 @@ class RedisTaskBackend(ThreadmillTaskBackend):
|
|
|
155
156
|
self.lease_ttl = self.options.get("lease_ttl", datetime.timedelta(hours=1))
|
|
156
157
|
self.result_ttl = self.options.get("result_ttl", datetime.timedelta(days=1))
|
|
157
158
|
self.batch_size = self.options.get("batch_size", 100)
|
|
159
|
+
self.poll_interval = self.options.get(
|
|
160
|
+
"poll_interval", datetime.timedelta(seconds=0.01)
|
|
161
|
+
)
|
|
162
|
+
self.poll_max_interval = self.options.get(
|
|
163
|
+
"poll_max_interval", datetime.timedelta(seconds=1)
|
|
164
|
+
)
|
|
165
|
+
self._miss_count = 0
|
|
166
|
+
self._rotation_offset = random.randrange(len(self.queues)) # noqa: S311
|
|
158
167
|
self._acquire_script = self.client.register_script(self.ACQUIRE_SCRIPT)
|
|
159
168
|
self._acknowledge_script = self.client.register_script(self.ACKNOWLEDGE_SCRIPT)
|
|
160
169
|
|
|
@@ -268,19 +277,29 @@ class RedisTaskBackend(ThreadmillTaskBackend):
|
|
|
268
277
|
str(len(queue_names)),
|
|
269
278
|
worker,
|
|
270
279
|
str(int(self.lease_ttl.total_seconds() * 1000)),
|
|
280
|
+
str(self._rotation_offset),
|
|
271
281
|
],
|
|
272
282
|
):
|
|
283
|
+
self._miss_count = 0
|
|
284
|
+
self._rotation_offset = (self._rotation_offset + 1) % len(queue_names)
|
|
273
285
|
return self.deserialize_task_result(data)
|
|
274
286
|
|
|
275
287
|
try:
|
|
276
|
-
|
|
277
|
-
raise TimeoutError(
|
|
278
|
-
"No task available within the specified timeout."
|
|
279
|
-
)
|
|
288
|
+
remaining = deadline - time.monotonic()
|
|
280
289
|
except TypeError:
|
|
281
290
|
raise queue.Empty("No task available.")
|
|
282
|
-
|
|
283
|
-
|
|
291
|
+
if remaining <= 0:
|
|
292
|
+
raise TimeoutError("No task available within the specified timeout.")
|
|
293
|
+
# Stop doubling once the interval reaches poll_max_interval; larger
|
|
294
|
+
# exponents would only overflow the float math.
|
|
295
|
+
cap = int(self.poll_max_interval / self.poll_interval).bit_length()
|
|
296
|
+
interval_secs = min(
|
|
297
|
+
self.poll_interval.total_seconds() * 2 ** min(self._miss_count, cap),
|
|
298
|
+
self.poll_max_interval.total_seconds(),
|
|
299
|
+
remaining,
|
|
300
|
+
)
|
|
301
|
+
self._miss_count += 1
|
|
302
|
+
time.sleep(interval_secs)
|
|
284
303
|
|
|
285
304
|
def acknowledge(self, task_result: TaskResult) -> None:
|
|
286
305
|
serialized = self.serialize_task_result(task_result)
|
|
@@ -96,6 +96,8 @@ class TaskExecutor:
|
|
|
96
96
|
threads: int = 1
|
|
97
97
|
max_tasks: int = 0
|
|
98
98
|
max_tasks_jitter: int = 0
|
|
99
|
+
poll_interval: datetime.timedelta = datetime.timedelta(seconds=0.01)
|
|
100
|
+
poll_max_interval: datetime.timedelta = datetime.timedelta(seconds=1)
|
|
99
101
|
is_publishing: bool = dataclasses.field(default=True, init=False)
|
|
100
102
|
worker_processes: list[WorkerProcess] = dataclasses.field(
|
|
101
103
|
default_factory=list, init=False
|
|
@@ -127,6 +129,8 @@ class TaskExecutor:
|
|
|
127
129
|
backend_alias=self.backend.alias,
|
|
128
130
|
queues=self.queues,
|
|
129
131
|
exit_empty=self.exit_empty,
|
|
132
|
+
poll_interval=self.poll_interval,
|
|
133
|
+
poll_max_interval=self.poll_max_interval,
|
|
130
134
|
log_formatter=self.log_formatter,
|
|
131
135
|
)
|
|
132
136
|
worker.start()
|
|
@@ -188,6 +192,8 @@ class WorkerProcess(multiprocessing.Process):
|
|
|
188
192
|
backend_alias: str = "",
|
|
189
193
|
queues: tuple[str, ...] = (),
|
|
190
194
|
exit_empty: bool = False,
|
|
195
|
+
poll_interval: datetime.timedelta = datetime.timedelta(seconds=0.01),
|
|
196
|
+
poll_max_interval: datetime.timedelta = datetime.timedelta(seconds=1),
|
|
191
197
|
log_formatter: logging.Formatter,
|
|
192
198
|
) -> None:
|
|
193
199
|
"""Create process with dedicated thread pool for task execution."""
|
|
@@ -198,6 +204,8 @@ class WorkerProcess(multiprocessing.Process):
|
|
|
198
204
|
self.backend_alias = backend_alias
|
|
199
205
|
self.queues = queues
|
|
200
206
|
self.exit_empty = exit_empty
|
|
207
|
+
self.poll_interval = poll_interval
|
|
208
|
+
self.poll_max_interval = poll_max_interval
|
|
201
209
|
self.log_formatter = log_formatter
|
|
202
210
|
self.task_count = 0
|
|
203
211
|
self.lock: threading.Lock | None = None
|
|
@@ -211,6 +219,8 @@ class WorkerProcess(multiprocessing.Process):
|
|
|
211
219
|
self.lock = threading.Lock()
|
|
212
220
|
self.expired = threading.Event()
|
|
213
221
|
backend = task_backends[self.backend_alias]
|
|
222
|
+
backend.poll_interval = self.poll_interval
|
|
223
|
+
backend.poll_max_interval = self.poll_max_interval
|
|
214
224
|
consumer_threads = [
|
|
215
225
|
WorkerThread(worker=self, index=index, backend=backend)
|
|
216
226
|
for index in range(self.thread_count)
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import datetime
|
|
1
2
|
import logging
|
|
2
3
|
import signal
|
|
3
4
|
import sys
|
|
@@ -37,7 +38,7 @@ class WorkerCommand(DjangoBaseCommand):
|
|
|
37
38
|
"--queues",
|
|
38
39
|
nargs="+",
|
|
39
40
|
default=[DEFAULT_TASK_QUEUE_NAME],
|
|
40
|
-
help="Queue names to listen to and process tasks from.",
|
|
41
|
+
help="Queue names to listen to and process tasks from (round-robin).",
|
|
41
42
|
)
|
|
42
43
|
parser.add_argument(
|
|
43
44
|
"-w",
|
|
@@ -67,6 +68,18 @@ class WorkerCommand(DjangoBaseCommand):
|
|
|
67
68
|
default=0,
|
|
68
69
|
help="Maximum random jitter to add to the max-tasks value by randint(0, max_tasks_jitter).",
|
|
69
70
|
)
|
|
71
|
+
parser.add_argument(
|
|
72
|
+
"--poll-interval",
|
|
73
|
+
type=float,
|
|
74
|
+
default=0.01,
|
|
75
|
+
help="Base wait between idle acquire attempts in seconds.",
|
|
76
|
+
)
|
|
77
|
+
parser.add_argument(
|
|
78
|
+
"--poll-max-interval",
|
|
79
|
+
type=float,
|
|
80
|
+
default=1,
|
|
81
|
+
help="Maximum wait between idle acquire attempts in seconds.",
|
|
82
|
+
)
|
|
70
83
|
parser.add_argument(
|
|
71
84
|
"--exit-empty",
|
|
72
85
|
action="store_true",
|
|
@@ -90,6 +103,8 @@ class WorkerCommand(DjangoBaseCommand):
|
|
|
90
103
|
threads,
|
|
91
104
|
max_tasks,
|
|
92
105
|
max_tasks_jitter,
|
|
106
|
+
poll_interval,
|
|
107
|
+
poll_max_interval,
|
|
93
108
|
exit_empty,
|
|
94
109
|
log_format,
|
|
95
110
|
**options,
|
|
@@ -127,6 +142,8 @@ class WorkerCommand(DjangoBaseCommand):
|
|
|
127
142
|
threads=threads,
|
|
128
143
|
max_tasks=max_tasks,
|
|
129
144
|
max_tasks_jitter=max_tasks_jitter,
|
|
145
|
+
poll_interval=datetime.timedelta(seconds=poll_interval),
|
|
146
|
+
poll_max_interval=datetime.timedelta(seconds=poll_max_interval),
|
|
130
147
|
exit_empty=exit_empty,
|
|
131
148
|
queues=queues,
|
|
132
149
|
log_formatter=log_formatter,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|