threadmill 0.6.3__tar.gz → 0.7.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.
- {threadmill-0.6.3 → threadmill-0.7.1}/PKG-INFO +9 -7
- {threadmill-0.6.3 → threadmill-0.7.1}/README.md +8 -6
- {threadmill-0.6.3 → threadmill-0.7.1}/threadmill/_version.py +3 -3
- {threadmill-0.6.3 → threadmill-0.7.1}/threadmill/backends/redis.py +21 -6
- {threadmill-0.6.3 → threadmill-0.7.1}/threadmill/executor.py +78 -9
- {threadmill-0.6.3 → threadmill-0.7.1}/threadmill/management/commands/threadmill.py +39 -1
- {threadmill-0.6.3 → threadmill-0.7.1}/LICENSE +0 -0
- {threadmill-0.6.3 → threadmill-0.7.1}/pyproject.toml +0 -0
- {threadmill-0.6.3 → threadmill-0.7.1}/threadmill/__init__.py +0 -0
- {threadmill-0.6.3 → threadmill-0.7.1}/threadmill/apps.py +0 -0
- {threadmill-0.6.3 → threadmill-0.7.1}/threadmill/backends/__init__.py +0 -0
- {threadmill-0.6.3 → threadmill-0.7.1}/threadmill/backends/base.py +0 -0
- {threadmill-0.6.3 → threadmill-0.7.1}/threadmill/backends/lua/acknowledge.lua +0 -0
- {threadmill-0.6.3 → threadmill-0.7.1}/threadmill/backends/lua/acquire.lua +0 -0
- {threadmill-0.6.3 → threadmill-0.7.1}/threadmill/backends/lua/mover.lua +0 -0
- {threadmill-0.6.3 → threadmill-0.7.1}/threadmill/backends/lua/reaper.lua +0 -0
- {threadmill-0.6.3 → threadmill-0.7.1}/threadmill/exceptions.py +0 -0
- {threadmill-0.6.3 → threadmill-0.7.1}/threadmill/inspector/__init__.py +0 -0
- {threadmill-0.6.3 → threadmill-0.7.1}/threadmill/inspector/app.py +0 -0
- {threadmill-0.6.3 → threadmill-0.7.1}/threadmill/inspector/inspector.scss +0 -0
- {threadmill-0.6.3 → threadmill-0.7.1}/threadmill/inspector/screens.py +0 -0
- {threadmill-0.6.3 → threadmill-0.7.1}/threadmill/inspector/telemetry.py +0 -0
- {threadmill-0.6.3 → threadmill-0.7.1}/threadmill/management/__init__.py +0 -0
- {threadmill-0.6.3 → threadmill-0.7.1}/threadmill/management/commands/__init__.py +0 -0
- {threadmill-0.6.3 → threadmill-0.7.1}/threadmill/retry.py +0 -0
- {threadmill-0.6.3 → threadmill-0.7.1}/threadmill/signals.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: threadmill
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.7.1
|
|
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>
|
|
@@ -151,12 +151,14 @@ uv run manage.py threadmill inspector
|
|
|
151
151
|
The `RedisTaskBackend` accepts the following options under `OPTIONS` in your
|
|
152
152
|
`TASKS` configuration:
|
|
153
153
|
|
|
154
|
-
| Option
|
|
155
|
-
|
|
|
156
|
-
| `lease_ttl`
|
|
157
|
-
| `result_ttl`
|
|
158
|
-
| `broker_interval`
|
|
159
|
-
| `batch_size`
|
|
154
|
+
| Option | Default | Description |
|
|
155
|
+
| ------------------- | ------------------------- | ----------------------------------------------------------------------- |
|
|
156
|
+
| `lease_ttl` | `timedelta(hours=1)` | Max processing time before a started task is marked FAILED. |
|
|
157
|
+
| `result_ttl` | `timedelta(days=1)` | How long task results are retained before automatic removal. |
|
|
158
|
+
| `broker_interval` | `timedelta(seconds=1)` | Interval between background broker maintenance passes. |
|
|
159
|
+
| `batch_size` | `100` | Max tasks to move or requeue per broker pass. |
|
|
160
|
+
| `poll_interval` | `timedelta(seconds=0.01)` | Base wait between idle acquire attempts, doubled after each empty poll. |
|
|
161
|
+
| `poll_max_interval` | `timedelta(seconds=1)` | Max wait between idle acquire attempts. |
|
|
160
162
|
|
|
161
163
|
A task that is started but never acknowledged (lease expired) is marked FAILED
|
|
162
164
|
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
|
|
@@ -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.
|
|
22
|
-
__version_tuple__ = version_tuple = (0,
|
|
21
|
+
__version__ = version = '0.7.1'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 7, 1)
|
|
23
23
|
|
|
24
|
-
__commit_id__ = commit_id = '
|
|
24
|
+
__commit_id__ = commit_id = 'gc60eae976'
|
|
@@ -155,6 +155,13 @@ class RedisTaskBackend(ThreadmillTaskBackend):
|
|
|
155
155
|
self.lease_ttl = self.options.get("lease_ttl", datetime.timedelta(hours=1))
|
|
156
156
|
self.result_ttl = self.options.get("result_ttl", datetime.timedelta(days=1))
|
|
157
157
|
self.batch_size = self.options.get("batch_size", 100)
|
|
158
|
+
self.poll_interval = self.options.get(
|
|
159
|
+
"poll_interval", datetime.timedelta(seconds=0.01)
|
|
160
|
+
)
|
|
161
|
+
self.poll_max_interval = self.options.get(
|
|
162
|
+
"poll_max_interval", datetime.timedelta(seconds=1)
|
|
163
|
+
)
|
|
164
|
+
self._miss_count = 0
|
|
158
165
|
self._acquire_script = self.client.register_script(self.ACQUIRE_SCRIPT)
|
|
159
166
|
self._acknowledge_script = self.client.register_script(self.ACKNOWLEDGE_SCRIPT)
|
|
160
167
|
|
|
@@ -270,17 +277,25 @@ class RedisTaskBackend(ThreadmillTaskBackend):
|
|
|
270
277
|
str(int(self.lease_ttl.total_seconds() * 1000)),
|
|
271
278
|
],
|
|
272
279
|
):
|
|
280
|
+
self._miss_count = 0
|
|
273
281
|
return self.deserialize_task_result(data)
|
|
274
282
|
|
|
275
283
|
try:
|
|
276
|
-
|
|
277
|
-
raise TimeoutError(
|
|
278
|
-
"No task available within the specified timeout."
|
|
279
|
-
)
|
|
284
|
+
remaining = deadline - time.monotonic()
|
|
280
285
|
except TypeError:
|
|
281
286
|
raise queue.Empty("No task available.")
|
|
282
|
-
|
|
283
|
-
|
|
287
|
+
if remaining <= 0:
|
|
288
|
+
raise TimeoutError("No task available within the specified timeout.")
|
|
289
|
+
# Stop doubling once the interval reaches poll_max_interval; larger
|
|
290
|
+
# exponents would only overflow the float math.
|
|
291
|
+
cap = int(self.poll_max_interval / self.poll_interval).bit_length()
|
|
292
|
+
interval_secs = min(
|
|
293
|
+
self.poll_interval.total_seconds() * 2 ** min(self._miss_count, cap),
|
|
294
|
+
self.poll_max_interval.total_seconds(),
|
|
295
|
+
remaining,
|
|
296
|
+
)
|
|
297
|
+
self._miss_count += 1
|
|
298
|
+
time.sleep(interval_secs)
|
|
284
299
|
|
|
285
300
|
def acknowledge(self, task_result: TaskResult) -> None:
|
|
286
301
|
serialized = self.serialize_task_result(task_result)
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import asyncio
|
|
4
4
|
import dataclasses
|
|
5
5
|
import datetime
|
|
6
|
+
import json
|
|
6
7
|
import logging
|
|
7
8
|
import multiprocessing
|
|
8
9
|
import random
|
|
@@ -16,6 +17,7 @@ from queue import Empty
|
|
|
16
17
|
from traceback import format_exception
|
|
17
18
|
|
|
18
19
|
import django
|
|
20
|
+
from django.core.serializers.json import DjangoJSONEncoder
|
|
19
21
|
from django.tasks import TaskResult, task_backends
|
|
20
22
|
from django.tasks.base import TaskContext, TaskError, TaskResultStatus
|
|
21
23
|
from django.tasks.signals import task_finished, task_started
|
|
@@ -25,12 +27,62 @@ from django.utils.json import normalize_json
|
|
|
25
27
|
if typing.TYPE_CHECKING:
|
|
26
28
|
from .backends.base import Broker, ThreadmillTaskBackend
|
|
27
29
|
|
|
30
|
+
|
|
31
|
+
class JsonFormatter(logging.Formatter):
|
|
32
|
+
"""Format log records as single-line JSON objects."""
|
|
33
|
+
|
|
34
|
+
standard_attributes = frozenset(
|
|
35
|
+
{
|
|
36
|
+
"args",
|
|
37
|
+
"asctime",
|
|
38
|
+
"created",
|
|
39
|
+
"exc_info",
|
|
40
|
+
"exc_text",
|
|
41
|
+
"filename",
|
|
42
|
+
"funcName",
|
|
43
|
+
"levelname",
|
|
44
|
+
"levelno",
|
|
45
|
+
"lineno",
|
|
46
|
+
"message",
|
|
47
|
+
"module",
|
|
48
|
+
"msecs",
|
|
49
|
+
"msg",
|
|
50
|
+
"name",
|
|
51
|
+
"pathname",
|
|
52
|
+
"process",
|
|
53
|
+
"processName",
|
|
54
|
+
"relativeCreated",
|
|
55
|
+
"stack_info",
|
|
56
|
+
"taskName",
|
|
57
|
+
"thread",
|
|
58
|
+
"threadName",
|
|
59
|
+
}
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
def format(self, record: logging.LogRecord) -> str:
|
|
63
|
+
payload = {
|
|
64
|
+
"created_at": datetime.datetime.fromtimestamp(
|
|
65
|
+
record.created, tz=timezone.get_current_timezone()
|
|
66
|
+
),
|
|
67
|
+
"level": record.levelname,
|
|
68
|
+
"logger": record.name,
|
|
69
|
+
"message": record.getMessage(),
|
|
70
|
+
"process": record.process,
|
|
71
|
+
"process_name": record.processName,
|
|
72
|
+
"thread": record.threadName,
|
|
73
|
+
} | {
|
|
74
|
+
key: value
|
|
75
|
+
for key, value in record.__dict__.items()
|
|
76
|
+
if key not in self.standard_attributes
|
|
77
|
+
}
|
|
78
|
+
if record.exc_info:
|
|
79
|
+
payload["exception"] = "".join(format_exception(*record.exc_info))
|
|
80
|
+
return json.dumps(payload, cls=DjangoJSONEncoder)
|
|
81
|
+
|
|
82
|
+
|
|
28
83
|
logger = multiprocessing.get_logger()
|
|
29
|
-
formatter = logging.Formatter(
|
|
30
|
-
"%(levelname)s: %(asctime)s - pid=%(process)s - %(message)s"
|
|
31
|
-
)
|
|
32
84
|
handler = logging.StreamHandler()
|
|
33
|
-
handler.setFormatter(
|
|
85
|
+
handler.setFormatter(JsonFormatter())
|
|
34
86
|
logger.addHandler(handler)
|
|
35
87
|
logger.setLevel(logging.INFO)
|
|
36
88
|
|
|
@@ -44,6 +96,8 @@ class TaskExecutor:
|
|
|
44
96
|
threads: int = 1
|
|
45
97
|
max_tasks: int = 0
|
|
46
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)
|
|
47
101
|
is_publishing: bool = dataclasses.field(default=True, init=False)
|
|
48
102
|
worker_processes: list[WorkerProcess] = dataclasses.field(
|
|
49
103
|
default_factory=list, init=False
|
|
@@ -53,6 +107,7 @@ class TaskExecutor:
|
|
|
53
107
|
queues: tuple[str]
|
|
54
108
|
broker: Broker | None = dataclasses.field(default=None, init=False)
|
|
55
109
|
exit_empty: bool = False
|
|
110
|
+
log_formatter: logging.Formatter = dataclasses.field(default_factory=JsonFormatter)
|
|
56
111
|
|
|
57
112
|
def __post_init__(self) -> None:
|
|
58
113
|
"""Initialize derived orchestration fields and queues."""
|
|
@@ -69,17 +124,21 @@ class TaskExecutor:
|
|
|
69
124
|
def create_worker_process(self) -> WorkerProcess:
|
|
70
125
|
"""Create and start a new worker process."""
|
|
71
126
|
worker = WorkerProcess(
|
|
72
|
-
self.thread_count,
|
|
73
|
-
self.get_maximum_tasks_per_child(),
|
|
74
|
-
self.backend.alias,
|
|
75
|
-
self.queues,
|
|
76
|
-
self.exit_empty,
|
|
127
|
+
thread_count=self.thread_count,
|
|
128
|
+
max_tasks=self.get_maximum_tasks_per_child(),
|
|
129
|
+
backend_alias=self.backend.alias,
|
|
130
|
+
queues=self.queues,
|
|
131
|
+
exit_empty=self.exit_empty,
|
|
132
|
+
poll_interval=self.poll_interval,
|
|
133
|
+
poll_max_interval=self.poll_max_interval,
|
|
134
|
+
log_formatter=self.log_formatter,
|
|
77
135
|
)
|
|
78
136
|
worker.start()
|
|
79
137
|
return worker
|
|
80
138
|
|
|
81
139
|
def run(self) -> None:
|
|
82
140
|
"""Start consuming tasks until shutdown is requested."""
|
|
141
|
+
handler.setFormatter(self.log_formatter)
|
|
83
142
|
self.worker_processes = [
|
|
84
143
|
self.create_worker_process() for _ in range(self.process_count)
|
|
85
144
|
]
|
|
@@ -127,11 +186,15 @@ class WorkerProcess(multiprocessing.Process):
|
|
|
127
186
|
|
|
128
187
|
def __init__(
|
|
129
188
|
self,
|
|
189
|
+
*,
|
|
130
190
|
thread_count: int,
|
|
131
191
|
max_tasks: int | None = None,
|
|
132
192
|
backend_alias: str = "",
|
|
133
193
|
queues: tuple[str, ...] = (),
|
|
134
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),
|
|
197
|
+
log_formatter: logging.Formatter,
|
|
135
198
|
) -> None:
|
|
136
199
|
"""Create process with dedicated thread pool for task execution."""
|
|
137
200
|
self.shutdown_requested = multiprocessing.Event()
|
|
@@ -141,17 +204,23 @@ class WorkerProcess(multiprocessing.Process):
|
|
|
141
204
|
self.backend_alias = backend_alias
|
|
142
205
|
self.queues = queues
|
|
143
206
|
self.exit_empty = exit_empty
|
|
207
|
+
self.poll_interval = poll_interval
|
|
208
|
+
self.poll_max_interval = poll_max_interval
|
|
209
|
+
self.log_formatter = log_formatter
|
|
144
210
|
self.task_count = 0
|
|
145
211
|
self.lock: threading.Lock | None = None
|
|
146
212
|
self.expired: threading.Event | None = None
|
|
147
213
|
|
|
148
214
|
def run(self) -> None:
|
|
149
215
|
"""Start consumer execution inside this process."""
|
|
216
|
+
handler.setFormatter(self.log_formatter)
|
|
150
217
|
django.setup()
|
|
151
218
|
logger.info("Starting worker process %s", self.name)
|
|
152
219
|
self.lock = threading.Lock()
|
|
153
220
|
self.expired = threading.Event()
|
|
154
221
|
backend = task_backends[self.backend_alias]
|
|
222
|
+
backend.poll_interval = self.poll_interval
|
|
223
|
+
backend.poll_max_interval = self.poll_max_interval
|
|
155
224
|
consumer_threads = [
|
|
156
225
|
WorkerThread(worker=self, index=index, backend=backend)
|
|
157
226
|
for index in range(self.thread_count)
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import datetime
|
|
2
|
+
import logging
|
|
1
3
|
import signal
|
|
2
4
|
import sys
|
|
3
5
|
|
|
@@ -10,7 +12,7 @@ from django.tasks import (
|
|
|
10
12
|
task_backends,
|
|
11
13
|
)
|
|
12
14
|
|
|
13
|
-
from ...executor import TaskExecutor
|
|
15
|
+
from ...executor import JsonFormatter, TaskExecutor
|
|
14
16
|
|
|
15
17
|
|
|
16
18
|
def kill_softly(signum, frame):
|
|
@@ -66,11 +68,30 @@ class WorkerCommand(DjangoBaseCommand):
|
|
|
66
68
|
default=0,
|
|
67
69
|
help="Maximum random jitter to add to the max-tasks value by randint(0, max_tasks_jitter).",
|
|
68
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
|
+
)
|
|
69
83
|
parser.add_argument(
|
|
70
84
|
"--exit-empty",
|
|
71
85
|
action="store_true",
|
|
72
86
|
help="Drain the task queue and exit with 0.",
|
|
73
87
|
)
|
|
88
|
+
parser.add_argument(
|
|
89
|
+
"--log-format",
|
|
90
|
+
help=(
|
|
91
|
+
"Logging format string for worker log records, e.g."
|
|
92
|
+
" '%%(levelname)s %%(message)s'. Defaults to JSON."
|
|
93
|
+
),
|
|
94
|
+
)
|
|
74
95
|
|
|
75
96
|
def handle(
|
|
76
97
|
self,
|
|
@@ -82,7 +103,10 @@ class WorkerCommand(DjangoBaseCommand):
|
|
|
82
103
|
threads,
|
|
83
104
|
max_tasks,
|
|
84
105
|
max_tasks_jitter,
|
|
106
|
+
poll_interval,
|
|
107
|
+
poll_max_interval,
|
|
85
108
|
exit_empty,
|
|
109
|
+
log_format,
|
|
86
110
|
**options,
|
|
87
111
|
):
|
|
88
112
|
match sys.platform:
|
|
@@ -101,14 +125,28 @@ class WorkerCommand(DjangoBaseCommand):
|
|
|
101
125
|
raise CommandError(
|
|
102
126
|
f"Backend does not support all specified queues: {_non_queues!r}"
|
|
103
127
|
)
|
|
128
|
+
try:
|
|
129
|
+
log_formatter = (
|
|
130
|
+
JsonFormatter() if log_format is None else logging.Formatter(log_format)
|
|
131
|
+
)
|
|
132
|
+
log_formatter.format(
|
|
133
|
+
logging.LogRecord(
|
|
134
|
+
"threadmill", logging.INFO, __file__, 1, "Ready", (), None
|
|
135
|
+
)
|
|
136
|
+
)
|
|
137
|
+
except (TypeError, ValueError) as e:
|
|
138
|
+
raise CommandError(f"Invalid log format: {log_format!r}") from e
|
|
104
139
|
exe = TaskExecutor(
|
|
105
140
|
backend=backend,
|
|
106
141
|
workers=workers,
|
|
107
142
|
threads=threads,
|
|
108
143
|
max_tasks=max_tasks,
|
|
109
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),
|
|
110
147
|
exit_empty=exit_empty,
|
|
111
148
|
queues=queues,
|
|
149
|
+
log_formatter=log_formatter,
|
|
112
150
|
)
|
|
113
151
|
try:
|
|
114
152
|
exe.run()
|
|
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
|
|
File without changes
|
|
File without changes
|