threadmill 0.8.4__tar.gz → 0.9.0__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 (29) hide show
  1. {threadmill-0.8.4 → threadmill-0.9.0}/PKG-INFO +42 -1
  2. {threadmill-0.8.4 → threadmill-0.9.0}/README.md +41 -0
  3. {threadmill-0.8.4 → threadmill-0.9.0}/threadmill/_version.py +3 -3
  4. {threadmill-0.8.4 → threadmill-0.9.0}/threadmill/backends/redis.py +30 -35
  5. threadmill-0.9.0/threadmill/exceptions.py +13 -0
  6. {threadmill-0.8.4 → threadmill-0.9.0}/threadmill/management/commands/threadmill.py +41 -50
  7. threadmill-0.9.0/threadmill/urls.py +13 -0
  8. threadmill-0.9.0/threadmill/views.py +32 -0
  9. threadmill-0.8.4/threadmill/exceptions.py +0 -5
  10. {threadmill-0.8.4 → threadmill-0.9.0}/LICENSE +0 -0
  11. {threadmill-0.8.4 → threadmill-0.9.0}/pyproject.toml +0 -0
  12. {threadmill-0.8.4 → threadmill-0.9.0}/threadmill/__init__.py +0 -0
  13. {threadmill-0.8.4 → threadmill-0.9.0}/threadmill/apps.py +0 -0
  14. {threadmill-0.8.4 → threadmill-0.9.0}/threadmill/backends/__init__.py +0 -0
  15. {threadmill-0.8.4 → threadmill-0.9.0}/threadmill/backends/base.py +0 -0
  16. {threadmill-0.8.4 → threadmill-0.9.0}/threadmill/backends/lua/acknowledge.lua +0 -0
  17. {threadmill-0.8.4 → threadmill-0.9.0}/threadmill/backends/lua/acquire.lua +0 -0
  18. {threadmill-0.8.4 → threadmill-0.9.0}/threadmill/backends/lua/mover.lua +0 -0
  19. {threadmill-0.8.4 → threadmill-0.9.0}/threadmill/backends/lua/reaper.lua +0 -0
  20. {threadmill-0.8.4 → threadmill-0.9.0}/threadmill/executor.py +0 -0
  21. {threadmill-0.8.4 → threadmill-0.9.0}/threadmill/inspector/__init__.py +0 -0
  22. {threadmill-0.8.4 → threadmill-0.9.0}/threadmill/inspector/app.py +0 -0
  23. {threadmill-0.8.4 → threadmill-0.9.0}/threadmill/inspector/inspector.scss +0 -0
  24. {threadmill-0.8.4 → threadmill-0.9.0}/threadmill/inspector/screens.py +0 -0
  25. {threadmill-0.8.4 → threadmill-0.9.0}/threadmill/inspector/telemetry.py +0 -0
  26. {threadmill-0.8.4 → threadmill-0.9.0}/threadmill/management/__init__.py +0 -0
  27. {threadmill-0.8.4 → threadmill-0.9.0}/threadmill/management/commands/__init__.py +0 -0
  28. {threadmill-0.8.4 → threadmill-0.9.0}/threadmill/retry.py +0 -0
  29. {threadmill-0.8.4 → threadmill-0.9.0}/threadmill/signals.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: threadmill
3
- Version: 0.8.4
3
+ Version: 0.9.0
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>
@@ -171,6 +171,47 @@ uv add "threadmill[inspector]"
171
171
  uv run manage.py threadmill inspector
172
172
  ```
173
173
 
174
+ ### Autoscaling
175
+
176
+ Threadmill exposes queue stats as JSON for KEDA's `metrics-api` scaler.
177
+ KEDA is a CNCF autoscaler for Kubernetes.
178
+
179
+ The `ready` value is the number of queued tasks that a worker can pick up now.
180
+
181
+ Include the URL in your project:
182
+
183
+ ```python
184
+ # urls.py
185
+ from django.urls import include, path
186
+
187
+ urlpatterns = [
188
+ path("threadmill/", include("threadmill.urls")),
189
+ ]
190
+ ```
191
+
192
+ Create the ScaledObject with the public URL and both queues:
193
+
194
+ ```yaml
195
+ apiVersion: keda.sh/v1alpha1
196
+ kind: ScaledObject
197
+ metadata:
198
+ name: threadmill-workers
199
+ spec:
200
+ scaleTargetRef:
201
+ name: threadmill-workers
202
+ triggers:
203
+ - type: metrics-api
204
+ metadata:
205
+ url: "https://example.com/threadmill/queue-stats/?queue=emails&queue=reports"
206
+ valueLocation: "ready"
207
+ targetValue: "1000"
208
+ ```
209
+
210
+ Use one endpoint per worker group.
211
+ Set `backend` to use another backend alias. It defaults to `default`.
212
+
213
+ Set `targetValue` to the ready backlog one replica can drain during your scale-up window.
214
+
174
215
  ### Redis Backend Options
175
216
 
176
217
  > [!IMPORTANT]
@@ -131,6 +131,47 @@ uv add "threadmill[inspector]"
131
131
  uv run manage.py threadmill inspector
132
132
  ```
133
133
 
134
+ ### Autoscaling
135
+
136
+ Threadmill exposes queue stats as JSON for KEDA's `metrics-api` scaler.
137
+ KEDA is a CNCF autoscaler for Kubernetes.
138
+
139
+ The `ready` value is the number of queued tasks that a worker can pick up now.
140
+
141
+ Include the URL in your project:
142
+
143
+ ```python
144
+ # urls.py
145
+ from django.urls import include, path
146
+
147
+ urlpatterns = [
148
+ path("threadmill/", include("threadmill.urls")),
149
+ ]
150
+ ```
151
+
152
+ Create the ScaledObject with the public URL and both queues:
153
+
154
+ ```yaml
155
+ apiVersion: keda.sh/v1alpha1
156
+ kind: ScaledObject
157
+ metadata:
158
+ name: threadmill-workers
159
+ spec:
160
+ scaleTargetRef:
161
+ name: threadmill-workers
162
+ triggers:
163
+ - type: metrics-api
164
+ metadata:
165
+ url: "https://example.com/threadmill/queue-stats/?queue=emails&queue=reports"
166
+ valueLocation: "ready"
167
+ targetValue: "1000"
168
+ ```
169
+
170
+ Use one endpoint per worker group.
171
+ Set `backend` to use another backend alias. It defaults to `default`.
172
+
173
+ Set `targetValue` to the ready backlog one replica can drain during your scale-up window.
174
+
134
175
  ### Redis Backend Options
135
176
 
136
177
  > [!IMPORTANT]
@@ -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.8.4'
22
- __version_tuple__ = version_tuple = (0, 8, 4)
21
+ __version__ = version = '0.9.0'
22
+ __version_tuple__ = version_tuple = (0, 9, 0)
23
23
 
24
- __commit_id__ = commit_id = 'g071a4ce76'
24
+ __commit_id__ = commit_id = 'gf1d9fc31e'
@@ -242,7 +242,6 @@ class RedisTaskBackend(ThreadmillTaskBackend):
242
242
  redis.default_backoff(), self.redis_connection_retries
243
243
  ),
244
244
  )
245
- self._async_client: redis.asyncio.Redis | None = None
246
245
  self.redis_url = redis_url
247
246
  self.key_prefix = f"threadmill:{{{alias}}}"
248
247
  self.telemetry_channel = self.TELEMETRY_CHANNEL.format(prefix=self.key_prefix)
@@ -270,18 +269,14 @@ class RedisTaskBackend(ThreadmillTaskBackend):
270
269
  self._idle_backoffs.backoff = backoff
271
270
  return backoff
272
271
 
273
- @property
274
272
  def async_client(self) -> redis.asyncio.Redis:
275
- """Lazily-created async Redis client, reused across calls."""
276
- if self._async_client is None:
277
- self._async_client = redis.asyncio.Redis.from_url(
278
- self.redis_url,
279
- decode_responses=True,
280
- retry=redis.asyncio.retry.Retry(
281
- redis.default_backoff(), self.redis_connection_retries
282
- ),
283
- )
284
- return self._async_client
273
+ return redis.asyncio.Redis.from_url(
274
+ self.redis_url,
275
+ decode_responses=True,
276
+ retry=redis.asyncio.retry.Retry(
277
+ redis.default_backoff(), self.redis_connection_retries
278
+ ),
279
+ )
285
280
 
286
281
  def _compute_score(self, priority: int, enqueued_at: datetime.datetime) -> float:
287
282
  """
@@ -623,17 +618,19 @@ class RedisTaskBackend(ThreadmillTaskBackend):
623
618
  async def queue_stats(
624
619
  self, *, interval: datetime.timedelta = datetime.timedelta(seconds=60)
625
620
  ) -> BackendTelemetry:
626
- client = self.async_client
627
- pipe = client.pipeline()
628
- for queue_name in self.queues:
629
- pipe.zcard(self._segment_key(TaskResultStatus.READY, queue_name))
630
- pipe.zcard(self._segment_key(TaskResultStatus.RUNNING, queue_name))
631
- pipe.zcard(
632
- self.DEFERRED_KEY.format(prefix=self.key_prefix, queue_name=queue_name)
633
- )
634
- pipe.zcard(self._segment_key(TaskResultStatus.SUCCESSFUL, queue_name))
635
- pipe.zcard(self._segment_key(TaskResultStatus.FAILED, queue_name))
636
- results = await pipe.execute()
621
+ async with self.async_client() as client:
622
+ pipe = client.pipeline()
623
+ for queue_name in self.queues:
624
+ pipe.zcard(self._segment_key(TaskResultStatus.READY, queue_name))
625
+ pipe.zcard(self._segment_key(TaskResultStatus.RUNNING, queue_name))
626
+ pipe.zcard(
627
+ self.DEFERRED_KEY.format(
628
+ prefix=self.key_prefix, queue_name=queue_name
629
+ )
630
+ )
631
+ pipe.zcard(self._segment_key(TaskResultStatus.SUCCESSFUL, queue_name))
632
+ pipe.zcard(self._segment_key(TaskResultStatus.FAILED, queue_name))
633
+ results = await pipe.execute()
637
634
  zero_rates = QueueRates(interval=interval, ingress=0, egress=0)
638
635
  queues: dict[str, QueueStats] = {}
639
636
  for index, queue_name in enumerate(self.queues):
@@ -653,14 +650,13 @@ class RedisTaskBackend(ThreadmillTaskBackend):
653
650
  async def worker_telemetry(
654
651
  self,
655
652
  ) -> collections.abc.AsyncGenerator[TelemetryEvent]:
656
- client = self.async_client
657
- pubsub = client.pubsub()
658
- await pubsub.subscribe(self.telemetry_channel)
659
- pubsub.ignore_subscribe_messages = True
660
- try:
661
- async for message in pubsub.listen():
662
- if (data := message.get("data")) is not None:
663
- direction, _, queue_name = data.partition(":")
653
+ async with self.async_client() as client:
654
+ pubsub = client.pubsub()
655
+ await pubsub.subscribe(self.telemetry_channel)
656
+ pubsub.ignore_subscribe_messages = True
657
+ try:
658
+ async for message in pubsub.listen():
659
+ direction, _, queue_name = message["data"].partition(":")
664
660
  try:
665
661
  event = TelemetryEvent(
666
662
  direction=TelemetryDirection(direction),
@@ -669,11 +665,10 @@ class RedisTaskBackend(ThreadmillTaskBackend):
669
665
  except ValueError:
670
666
  continue
671
667
  yield event
672
- finally:
673
- await pubsub.unsubscribe(self.telemetry_channel)
674
- await pubsub.aclose()
668
+ finally:
669
+ await pubsub.unsubscribe(self.telemetry_channel)
670
+ await pubsub.aclose()
675
671
 
676
672
  def close(self) -> None:
677
673
  """Close the Redis connections."""
678
674
  self.client.close()
679
- self._async_client = None
@@ -0,0 +1,13 @@
1
+ """Custom exceptions for the threadmill task framework."""
2
+
3
+
4
+ class MissingExtraError(ImportError):
5
+ """Raised when an optional dependency extra is not installed."""
6
+
7
+ def __init__(self, extra: str) -> None:
8
+ self.extra = extra
9
+ super().__init__(f"Optional dependency missing. Install: threadmill[{extra}]")
10
+
11
+
12
+ class AcknowledgementTimeoutError(Exception):
13
+ """Raised when a task's lease has expired before it could be acknowledged."""
@@ -8,10 +8,10 @@ from django.core.management.base import BaseCommand as DjangoBaseCommand
8
8
  from django.tasks import (
9
9
  DEFAULT_TASK_BACKEND_ALIAS,
10
10
  DEFAULT_TASK_QUEUE_NAME,
11
- InvalidTaskBackend,
12
11
  task_backends,
13
12
  )
14
13
 
14
+ from ...exceptions import MissingExtraError
15
15
  from ...executor import JsonFormatter, TaskExecutor
16
16
 
17
17
 
@@ -22,18 +22,36 @@ def kill_softly(signum, frame):
22
22
  raise KeyboardInterrupt(message)
23
23
 
24
24
 
25
- class WorkerCommand(DjangoBaseCommand):
26
- """Run task workers to process enqueued tasks from the specified backends and queues."""
27
-
28
- help = __doc__
29
-
25
+ class ThreadmillCommand(DjangoBaseCommand):
30
26
  def add_arguments(self, parser):
31
27
  parser.add_argument(
32
28
  "-b",
33
29
  "--backend",
34
30
  default=DEFAULT_TASK_BACKEND_ALIAS,
35
- help="Alias of the tasks backend to use.",
31
+ help="Alias of the tasks backend.",
36
32
  )
33
+
34
+ @staticmethod
35
+ def parse_duration(value: str) -> datetime.timedelta:
36
+ return datetime.timedelta(seconds=float(value))
37
+
38
+ def install_signal_handlers(self) -> None:
39
+ match sys.platform:
40
+ case "win32":
41
+ signal.signal(signal.SIGBREAK, kill_softly)
42
+ case _:
43
+ signal.signal(signal.SIGHUP, kill_softly)
44
+ signal.signal(signal.SIGTERM, kill_softly)
45
+ signal.signal(signal.SIGINT, kill_softly)
46
+
47
+
48
+ class WorkerCommand(ThreadmillCommand):
49
+ """Run task workers to process enqueued tasks from the specified backends and queues."""
50
+
51
+ help = __doc__
52
+
53
+ def add_arguments(self, parser):
54
+ super().add_arguments(parser)
37
55
  parser.add_argument(
38
56
  "-q",
39
57
  "--queues",
@@ -80,14 +98,14 @@ class WorkerCommand(DjangoBaseCommand):
80
98
  )
81
99
  parser.add_argument(
82
100
  "--poll-interval",
83
- type=float,
84
- default=0.01,
101
+ type=self.parse_duration,
102
+ default=datetime.timedelta(seconds=0.01),
85
103
  help="Base wait between idle acquire attempts in seconds.",
86
104
  )
87
105
  parser.add_argument(
88
106
  "--poll-max-interval",
89
- type=float,
90
- default=1,
107
+ type=self.parse_duration,
108
+ default=datetime.timedelta(seconds=1),
91
109
  help="Maximum wait between idle acquire attempts in seconds.",
92
110
  )
93
111
  parser.add_argument(
@@ -120,19 +138,9 @@ class WorkerCommand(DjangoBaseCommand):
120
138
  log_format,
121
139
  **options,
122
140
  ):
123
- match sys.platform:
124
- case "win32":
125
- signal.signal(signal.SIGBREAK, kill_softly)
126
- case _:
127
- signal.signal(signal.SIGHUP, kill_softly)
128
- signal.signal(signal.SIGTERM, kill_softly)
129
- signal.signal(signal.SIGINT, kill_softly)
141
+ self.install_signal_handlers()
130
142
  self.stdout.write(self.style.SUCCESS("Starting workers…"))
131
- try:
132
- backend = task_backends[backend]
133
- except InvalidTaskBackend:
134
- message = f"Invalid backend: {backend!r}"
135
- raise CommandError(message)
143
+ backend = task_backends[backend]
136
144
  if _non_queues := set(queues) - set(backend.queues):
137
145
  message = f"Backend does not support all specified queues: {_non_queues!r}"
138
146
  raise CommandError(message)
@@ -158,8 +166,8 @@ class WorkerCommand(DjangoBaseCommand):
158
166
  max_tasks=max_tasks,
159
167
  max_tasks_jitter=max_tasks_jitter,
160
168
  prefetch_count=prefetch_count,
161
- poll_interval=datetime.timedelta(seconds=poll_interval),
162
- poll_max_interval=datetime.timedelta(seconds=poll_max_interval),
169
+ poll_interval=poll_interval,
170
+ poll_max_interval=poll_max_interval,
163
171
  exit_empty=exit_empty,
164
172
  queues=queues,
165
173
  log_formatter=log_formatter,
@@ -172,37 +180,17 @@ class WorkerCommand(DjangoBaseCommand):
172
180
  exe.shutdown()
173
181
 
174
182
 
175
- class InspectorCommand(DjangoBaseCommand):
183
+ class InspectorCommand(ThreadmillCommand):
176
184
  """Launch the textual TUI inspector for task backends and queues."""
177
185
 
178
186
  help = __doc__
179
187
 
180
- def add_arguments(self, parser):
181
- parser.add_argument(
182
- "-b",
183
- "--backend",
184
- default=DEFAULT_TASK_BACKEND_ALIAS,
185
- help="Alias of the tasks backend to inspect.",
186
- )
187
-
188
188
  def handle(self, *, backend, **options):
189
189
  try:
190
- from ...backends.base import ThreadmillTaskBackend
191
190
  from ...inspector.app import InspectorApp
192
- except ImportError:
193
- message = "Optional dependency missing. Install: threadmill[inspector]"
194
- raise CommandError(message)
195
- try:
196
- backend = task_backends[backend]
197
- except InvalidTaskBackend as e:
198
- message = f"Invalid backend: {backend!r}"
199
- raise CommandError(message) from e
200
-
201
- if not isinstance(backend, ThreadmillTaskBackend):
202
- message = f"Backend {backend.alias!r} does not support inspection."
203
- raise CommandError(message)
204
-
205
- InspectorApp(backend=backend).run()
191
+ except ImportError as e:
192
+ raise MissingExtraError("inspector") from e
193
+ InspectorApp(backend=task_backends[backend]).run()
206
194
 
207
195
 
208
196
  class Command(BaseCommand):
@@ -223,4 +211,7 @@ class Command(BaseCommand):
223
211
 
224
212
  def handle(self, *args, **options):
225
213
  subcommand_name = options.pop("subcommand")
226
- self.subcommands[subcommand_name]().execute(*args, **options)
214
+ try:
215
+ self.subcommands[subcommand_name]().execute(*args, **options)
216
+ except MissingExtraError as e:
217
+ raise CommandError(str(e)) from e
@@ -0,0 +1,13 @@
1
+ from django.urls import path
2
+
3
+ from .views import QueueStatsView
4
+
5
+ app_name = "threadmill"
6
+
7
+ urlpatterns = [
8
+ path(
9
+ "queue-stats/",
10
+ QueueStatsView.as_view(),
11
+ name="queue-stats",
12
+ ),
13
+ ]
@@ -0,0 +1,32 @@
1
+ from django.http import Http404, HttpRequest, JsonResponse
2
+ from django.tasks import (
3
+ DEFAULT_TASK_BACKEND_ALIAS,
4
+ InvalidTaskBackend,
5
+ task_backends,
6
+ )
7
+ from django.views import View
8
+
9
+
10
+ class QueueStatsView(View):
11
+ async def get(self, request: HttpRequest) -> JsonResponse:
12
+ try:
13
+ backend = task_backends[
14
+ request.GET.get("backend", DEFAULT_TASK_BACKEND_ALIAS)
15
+ ]
16
+ except InvalidTaskBackend as exception:
17
+ raise Http404 from exception
18
+ try:
19
+ telemetry = await backend.queue_stats()
20
+ except (AttributeError, NotImplementedError) as exception:
21
+ raise Http404 from exception
22
+ try:
23
+ return JsonResponse(
24
+ {
25
+ "ready": sum(
26
+ telemetry.queues[queue_name].counts.ready
27
+ for queue_name in request.GET.getlist("queue", backend.queues)
28
+ )
29
+ }
30
+ )
31
+ except (AttributeError, KeyError) as exception:
32
+ raise Http404 from exception
@@ -1,5 +0,0 @@
1
- """Custom exceptions for the threadmill task framework."""
2
-
3
-
4
- class AcknowledgementTimeoutError(Exception):
5
- """Raised when a task's lease has expired before it could be acknowledged."""
File without changes
File without changes