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.
Files changed (26) hide show
  1. {threadmill-0.6.3 → threadmill-0.7.1}/PKG-INFO +9 -7
  2. {threadmill-0.6.3 → threadmill-0.7.1}/README.md +8 -6
  3. {threadmill-0.6.3 → threadmill-0.7.1}/threadmill/_version.py +3 -3
  4. {threadmill-0.6.3 → threadmill-0.7.1}/threadmill/backends/redis.py +21 -6
  5. {threadmill-0.6.3 → threadmill-0.7.1}/threadmill/executor.py +78 -9
  6. {threadmill-0.6.3 → threadmill-0.7.1}/threadmill/management/commands/threadmill.py +39 -1
  7. {threadmill-0.6.3 → threadmill-0.7.1}/LICENSE +0 -0
  8. {threadmill-0.6.3 → threadmill-0.7.1}/pyproject.toml +0 -0
  9. {threadmill-0.6.3 → threadmill-0.7.1}/threadmill/__init__.py +0 -0
  10. {threadmill-0.6.3 → threadmill-0.7.1}/threadmill/apps.py +0 -0
  11. {threadmill-0.6.3 → threadmill-0.7.1}/threadmill/backends/__init__.py +0 -0
  12. {threadmill-0.6.3 → threadmill-0.7.1}/threadmill/backends/base.py +0 -0
  13. {threadmill-0.6.3 → threadmill-0.7.1}/threadmill/backends/lua/acknowledge.lua +0 -0
  14. {threadmill-0.6.3 → threadmill-0.7.1}/threadmill/backends/lua/acquire.lua +0 -0
  15. {threadmill-0.6.3 → threadmill-0.7.1}/threadmill/backends/lua/mover.lua +0 -0
  16. {threadmill-0.6.3 → threadmill-0.7.1}/threadmill/backends/lua/reaper.lua +0 -0
  17. {threadmill-0.6.3 → threadmill-0.7.1}/threadmill/exceptions.py +0 -0
  18. {threadmill-0.6.3 → threadmill-0.7.1}/threadmill/inspector/__init__.py +0 -0
  19. {threadmill-0.6.3 → threadmill-0.7.1}/threadmill/inspector/app.py +0 -0
  20. {threadmill-0.6.3 → threadmill-0.7.1}/threadmill/inspector/inspector.scss +0 -0
  21. {threadmill-0.6.3 → threadmill-0.7.1}/threadmill/inspector/screens.py +0 -0
  22. {threadmill-0.6.3 → threadmill-0.7.1}/threadmill/inspector/telemetry.py +0 -0
  23. {threadmill-0.6.3 → threadmill-0.7.1}/threadmill/management/__init__.py +0 -0
  24. {threadmill-0.6.3 → threadmill-0.7.1}/threadmill/management/commands/__init__.py +0 -0
  25. {threadmill-0.6.3 → threadmill-0.7.1}/threadmill/retry.py +0 -0
  26. {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.6.3
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 | 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. |
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 | 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. |
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.6.3'
22
- __version_tuple__ = version_tuple = (0, 6, 3)
21
+ __version__ = version = '0.7.1'
22
+ __version_tuple__ = version_tuple = (0, 7, 1)
23
23
 
24
- __commit_id__ = commit_id = 'g814435eae'
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
- if deadline - time.monotonic() <= 0:
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
- else:
283
- time.sleep(0.01)
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(formatter)
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