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.
Files changed (26) hide show
  1. {threadmill-0.7.0 → threadmill-0.7.2}/PKG-INFO +10 -7
  2. {threadmill-0.7.0 → threadmill-0.7.2}/README.md +8 -6
  3. {threadmill-0.7.0 → threadmill-0.7.2}/pyproject.toml +1 -0
  4. {threadmill-0.7.0 → threadmill-0.7.2}/threadmill/_version.py +3 -3
  5. {threadmill-0.7.0 → threadmill-0.7.2}/threadmill/backends/lua/acquire.lua +9 -4
  6. {threadmill-0.7.0 → threadmill-0.7.2}/threadmill/backends/redis.py +25 -6
  7. {threadmill-0.7.0 → threadmill-0.7.2}/threadmill/executor.py +10 -0
  8. {threadmill-0.7.0 → threadmill-0.7.2}/threadmill/management/commands/threadmill.py +18 -1
  9. {threadmill-0.7.0 → threadmill-0.7.2}/LICENSE +0 -0
  10. {threadmill-0.7.0 → threadmill-0.7.2}/threadmill/__init__.py +0 -0
  11. {threadmill-0.7.0 → threadmill-0.7.2}/threadmill/apps.py +0 -0
  12. {threadmill-0.7.0 → threadmill-0.7.2}/threadmill/backends/__init__.py +0 -0
  13. {threadmill-0.7.0 → threadmill-0.7.2}/threadmill/backends/base.py +0 -0
  14. {threadmill-0.7.0 → threadmill-0.7.2}/threadmill/backends/lua/acknowledge.lua +0 -0
  15. {threadmill-0.7.0 → threadmill-0.7.2}/threadmill/backends/lua/mover.lua +0 -0
  16. {threadmill-0.7.0 → threadmill-0.7.2}/threadmill/backends/lua/reaper.lua +0 -0
  17. {threadmill-0.7.0 → threadmill-0.7.2}/threadmill/exceptions.py +0 -0
  18. {threadmill-0.7.0 → threadmill-0.7.2}/threadmill/inspector/__init__.py +0 -0
  19. {threadmill-0.7.0 → threadmill-0.7.2}/threadmill/inspector/app.py +0 -0
  20. {threadmill-0.7.0 → threadmill-0.7.2}/threadmill/inspector/inspector.scss +0 -0
  21. {threadmill-0.7.0 → threadmill-0.7.2}/threadmill/inspector/screens.py +0 -0
  22. {threadmill-0.7.0 → threadmill-0.7.2}/threadmill/inspector/telemetry.py +0 -0
  23. {threadmill-0.7.0 → threadmill-0.7.2}/threadmill/management/__init__.py +0 -0
  24. {threadmill-0.7.0 → threadmill-0.7.2}/threadmill/management/commands/__init__.py +0 -0
  25. {threadmill-0.7.0 → threadmill-0.7.2}/threadmill/retry.py +0 -0
  26. {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.0
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 | 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. |
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 | 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
@@ -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.0'
22
- __version_tuple__ = version_tuple = (0, 7, 0)
21
+ __version__ = version = '0.7.2'
22
+ __version_tuple__ = version_tuple = (0, 7, 2)
23
23
 
24
- __commit_id__ = commit_id = 'g0fc4d7ade'
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. Iterates queues in key order and returns the first available task.
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
- for i = 1, num_queues do
19
- local result = redis.call('ZPOPMIN', KEYS[i * 2])
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[i * 2 - 1], deadline, task_id)
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
- if deadline - time.monotonic() <= 0:
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
- else:
283
- time.sleep(0.01)
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