threadmill 0.5.0__tar.gz → 0.6.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 (25) hide show
  1. {threadmill-0.5.0 → threadmill-0.6.0}/PKG-INFO +11 -13
  2. threadmill-0.6.0/pyproject.toml +102 -0
  3. {threadmill-0.5.0 → threadmill-0.6.0}/threadmill/_version.py +3 -3
  4. {threadmill-0.5.0 → threadmill-0.6.0}/threadmill/backends/base.py +23 -5
  5. {threadmill-0.5.0 → threadmill-0.6.0}/threadmill/backends/lua/acknowledge.lua +5 -1
  6. {threadmill-0.5.0 → threadmill-0.6.0}/threadmill/backends/redis.py +60 -76
  7. {threadmill-0.5.0 → threadmill-0.6.0}/threadmill/inspector/app.py +115 -47
  8. {threadmill-0.5.0 → threadmill-0.6.0}/threadmill/inspector/inspector.scss +25 -2
  9. threadmill-0.6.0/threadmill/inspector/telemetry.py +81 -0
  10. threadmill-0.5.0/pyproject.toml +0 -114
  11. {threadmill-0.5.0 → threadmill-0.6.0}/LICENSE +0 -0
  12. {threadmill-0.5.0 → threadmill-0.6.0}/README.md +0 -0
  13. {threadmill-0.5.0 → threadmill-0.6.0}/threadmill/__init__.py +0 -0
  14. {threadmill-0.5.0 → threadmill-0.6.0}/threadmill/backends/__init__.py +0 -0
  15. {threadmill-0.5.0 → threadmill-0.6.0}/threadmill/backends/lua/acquire.lua +0 -0
  16. {threadmill-0.5.0 → threadmill-0.6.0}/threadmill/backends/lua/mover.lua +0 -0
  17. {threadmill-0.5.0 → threadmill-0.6.0}/threadmill/backends/lua/reaper.lua +0 -0
  18. {threadmill-0.5.0 → threadmill-0.6.0}/threadmill/exceptions.py +0 -0
  19. {threadmill-0.5.0 → threadmill-0.6.0}/threadmill/executor.py +0 -0
  20. {threadmill-0.5.0 → threadmill-0.6.0}/threadmill/inspector/__init__.py +0 -0
  21. {threadmill-0.5.0 → threadmill-0.6.0}/threadmill/inspector/screens.py +0 -0
  22. {threadmill-0.5.0 → threadmill-0.6.0}/threadmill/management/__init__.py +0 -0
  23. {threadmill-0.5.0 → threadmill-0.6.0}/threadmill/management/commands/__init__.py +0 -0
  24. {threadmill-0.5.0 → threadmill-0.6.0}/threadmill/management/commands/threadmill.py +0 -0
  25. {threadmill-0.5.0 → threadmill-0.6.0}/threadmill/retry.py +0 -0
@@ -1,32 +1,30 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: threadmill
3
- Version: 0.5.0
3
+ Version: 0.6.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>
7
7
  Requires-Python: >=3.14
8
8
  Description-Content-Type: text/markdown
9
- Classifier: Development Status :: 4 - Beta
10
- Classifier: Programming Language :: Python
9
+ Classifier: Development Status :: 5 - Production/Stable
11
10
  Classifier: Environment :: Web Environment
12
- Classifier: License :: OSI Approved :: BSD License
11
+ Classifier: Framework :: Django
12
+ Classifier: Framework :: Django :: 6.1
13
13
  Classifier: Intended Audience :: Developers
14
- Classifier: Operating System :: Microsoft :: Windows
14
+ Classifier: License :: OSI Approved :: BSD License
15
15
  Classifier: Operating System :: MacOS :: MacOS X
16
+ Classifier: Operating System :: Microsoft :: Windows
16
17
  Classifier: Operating System :: POSIX
17
- Classifier: Topic :: Communications :: Email
18
- Classifier: Topic :: Text Processing :: Markup :: Markdown
19
- Classifier: Topic :: Software Development
20
18
  Classifier: Programming Language :: Python
21
- Classifier: Programming Language :: Python :: 3
22
19
  Classifier: Programming Language :: Python :: 3 :: Only
23
20
  Classifier: Programming Language :: Python :: 3.14
24
- Classifier: Framework :: Django
25
- Classifier: Framework :: Django :: 6.1
21
+ Classifier: Topic :: Communications :: Email
22
+ Classifier: Topic :: Software Development
23
+ Classifier: Topic :: Text Processing :: Markup :: Markdown
26
24
  License-File: LICENSE
27
- Requires-Dist: django>=6.0
25
+ Requires-Dist: django>=6.1
28
26
  Requires-Dist: textual>=8.2.7 ; extra == "inspector"
29
- Requires-Dist: redis>=5.0 ; extra == "redis"
27
+ Requires-Dist: redis>=5 ; extra == "redis"
30
28
  Project-URL: Changelog, https://github.com/codingjoe/threadmill/releases
31
29
  Project-URL: Documentation, https://github.com/codingjoe/threadmill/
32
30
  Project-URL: Funding, https://github.com/sponsors/codingjoe
@@ -0,0 +1,102 @@
1
+ [build-system]
2
+ build-backend = "flit_scm:buildapi"
3
+ requires = [ "flit-core>=3.2", "flit-scm", "wheel" ]
4
+
5
+ [project]
6
+ name = "threadmill"
7
+ readme = "README.md"
8
+ keywords = [ "Django", "tasks", "worker" ]
9
+ license = { file = "LICENSE" }
10
+ authors = [
11
+ { name = "Johannes Maron", email = "johannes@maron.family" },
12
+ ]
13
+ requires-python = ">=3.14"
14
+ classifiers = [
15
+ "Development Status :: 5 - Production/Stable",
16
+ "Environment :: Web Environment",
17
+ "Framework :: Django",
18
+ "Framework :: Django :: 6.1",
19
+ "Intended Audience :: Developers",
20
+ "License :: OSI Approved :: BSD License",
21
+ "Operating System :: MacOS :: MacOS X",
22
+ "Operating System :: Microsoft :: Windows",
23
+ "Operating System :: POSIX",
24
+ "Programming Language :: Python",
25
+ "Programming Language :: Python :: 3 :: Only",
26
+ "Programming Language :: Python :: 3.14",
27
+ "Topic :: Communications :: Email",
28
+ "Topic :: Software Development",
29
+ "Topic :: Text Processing :: Markup :: Markdown",
30
+ ]
31
+ dynamic = [ "description", "version" ]
32
+ dependencies = [ "django>=6.1" ]
33
+ optional-dependencies.inspector = [
34
+ "textual>=8.2.7",
35
+ ]
36
+ optional-dependencies.redis = [ "redis>=5" ]
37
+ urls.Changelog = "https://github.com/codingjoe/threadmill/releases"
38
+ urls.Documentation = "https://github.com/codingjoe/threadmill/"
39
+ urls.Funding = "https://github.com/sponsors/codingjoe"
40
+ # https://packaging.python.org/en/latest/specifications/well-known-project-urls/#well-known-labels
41
+ urls.Homepage = "https://github.com/codingjoe/threadmill"
42
+ urls.Issues = "https://github.com/codingjoe/threadmill/issues"
43
+ urls.Releasenotes = "https://github.com/codingjoe/threadmill/releases/latest"
44
+ urls.Source = "https://github.com/codingjoe/threadmill"
45
+
46
+ [dependency-groups]
47
+ dev = [
48
+ { include-group = "test" },
49
+ ]
50
+ test = [
51
+ "pytest",
52
+ "pytest-asyncio",
53
+ "pytest-benchmark",
54
+ "pytest-cov",
55
+ "pytest-django",
56
+ "redis>=5",
57
+ ]
58
+
59
+ [tool.setuptools_scm]
60
+ write_to = "threadmill/_version.py"
61
+
62
+ [tool.flit]
63
+ module.name = "threadmill"
64
+
65
+ [tool.ruff]
66
+ src = [ "tests", "threadmill" ]
67
+ lint.select = [
68
+ "B", # flake8-bugbear
69
+ "C", # flake8-comprehensions
70
+ "D", # pydocstyle
71
+ "E", # pycodestyle errors
72
+ "F", # pyflakes
73
+ "I", # isort
74
+ "S", # flake8-bandit
75
+ "UP", # pyupgrade
76
+ "W", # pycodestyle warnings
77
+ ]
78
+ lint.ignore = [ "B904", "D1", "E501", "S101" ]
79
+ lint.isort.combine-as-imports = true
80
+ lint.isort.force-wrap-aliases = true
81
+ lint.isort.known-first-party = [ "threadmill", "tests" ]
82
+ lint.isort.section-order = [ "future", "standard-library", "third-party", "first-party", "local-folder" ]
83
+ lint.isort.split-on-trailing-comma = true
84
+ lint.pydocstyle.convention = "pep257"
85
+
86
+ [tool.pytest]
87
+ ini_options.minversion = "6.1"
88
+ ini_options.testpaths = [ "tests" ]
89
+ ini_options.addopts = """\
90
+ --cov --cov-report=xml --cov-report=term --tb=short -rxs --benchmark-autosave --benchmark-group-by=fullname \
91
+ --benchmark-min-rounds=10\
92
+ """
93
+ ini_options.markers = [
94
+ "benchmark: mark benchmark tests.",
95
+ ]
96
+ ini_options.asyncio_mode = "auto"
97
+ ini_options.DJANGO_SETTINGS_MODULE = "tests.testapp.settings"
98
+
99
+ [tool.coverage]
100
+ run.source = [ "threadmill" ]
101
+ report.show_missing = true
102
+ report.skip_covered = true
@@ -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.5.0'
22
- __version_tuple__ = version_tuple = (0, 5, 0)
21
+ __version__ = version = '0.6.0'
22
+ __version_tuple__ = version_tuple = (0, 6, 0)
23
23
 
24
- __commit_id__ = commit_id = 'g190acbe27'
24
+ __commit_id__ = commit_id = 'g2842de1d6'
@@ -1,6 +1,7 @@
1
1
  import collections.abc
2
2
  import dataclasses
3
3
  import datetime
4
+ import enum
4
5
  import json
5
6
  import threading
6
7
  import typing
@@ -95,6 +96,21 @@ class BackendTelemetry:
95
96
  queues: dict[str, QueueStats]
96
97
 
97
98
 
99
+ class TelemetryDirection(enum.Enum):
100
+ """Direction of a telemetry event: a task entering or leaving a queue."""
101
+
102
+ INGRESS = "ingress"
103
+ EGRESS = "egress"
104
+
105
+
106
+ @dataclasses.dataclass(frozen=True, slots=True)
107
+ class TelemetryEvent:
108
+ """A single ingress/egress event published by a worker via pub/sub."""
109
+
110
+ direction: TelemetryDirection
111
+ queue_name: str
112
+
113
+
98
114
  class Broker(threading.Thread):
99
115
  """Backend maintenance thread launched by the task executor."""
100
116
 
@@ -242,14 +258,16 @@ class ThreadmillTaskBackend(BaseTaskBackend, ABC):
242
258
  """
243
259
  raise NotImplementedError
244
260
 
245
- def telemetry(
261
+ async def queue_stats(
246
262
  self, *, interval: datetime.timedelta = datetime.timedelta(seconds=60)
247
263
  ) -> BackendTelemetry:
248
- """Return a snapshot of stats for all configured queues.
264
+ """Return per-queue task counts for all configured queues."""
265
+ raise NotImplementedError
249
266
 
250
- Args:
251
- interval: The time window for rolling rates.
252
- """
267
+ async def worker_telemetry(
268
+ self,
269
+ ) -> collections.abc.AsyncIterator[TelemetryEvent]:
270
+ """Yield ingress/egress telemetry events from the backend's pub/sub stream."""
253
271
  raise NotImplementedError
254
272
 
255
273
  def dequeue(self, task_result: TaskResult) -> None:
@@ -3,7 +3,8 @@
3
3
  -- per-status results history set. Evicts results whose finish score falls
4
4
  -- outside the retention window (result_ttl). The per-status history sets are
5
5
  -- the time series the inspector counts over for telemetry, so no separate
6
- -- egress window or status counters are needed here.
6
+ -- egress window or status counters are needed here. A pub/sub event is
7
+ -- published so the inspector can track live egress throughput.
7
8
  --
8
9
  -- KEYS[1] -- running set (ZSET)
9
10
  -- KEYS[2] -- result key (STRING, stores serialized TaskResult)
@@ -15,6 +16,8 @@
15
16
  -- ARGV[3] -- result TTL in seconds
16
17
  -- ARGV[4] -- finish timestamp in milliseconds (score for the history set)
17
18
  -- ARGV[5] -- status (SUCCESSFUL or FAILED)
19
+ -- ARGV[6] -- telemetry pub/sub channel
20
+ -- ARGV[7] -- queue name
18
21
  -- Returns: 1 on success, 0 if task was not in the running set
19
22
 
20
23
  local removed = redis.call('ZREM', KEYS[1], ARGV[1])
@@ -31,4 +34,5 @@ if ARGV[5] ~= 'SUCCESSFUL' then
31
34
  end
32
35
  redis.call('ZADD', results_key, finish, ARGV[1])
33
36
  redis.call('ZREMRANGEBYSCORE', results_key, 0, cutoff)
37
+ redis.call('PUBLISH', ARGV[6], 'egress:' .. ARGV[7])
34
38
  return 1
@@ -1,5 +1,6 @@
1
1
  """Redis-backed durable priority queue backend for Django's task framework."""
2
2
 
3
+ import collections.abc
3
4
  import dataclasses
4
5
  import datetime
5
6
  import logging
@@ -10,6 +11,7 @@ from collections.abc import Generator, Sequence
10
11
  from pathlib import Path
11
12
 
12
13
  import redis
14
+ import redis.asyncio
13
15
  from django.tasks import DEFAULT_TASK_QUEUE_NAME, TaskResult, TaskResultStatus
14
16
  from django.tasks.exceptions import TaskResultDoesNotExist
15
17
  from django.tasks.signals import task_enqueued
@@ -21,6 +23,8 @@ from threadmill.backends.base import (
21
23
  QueueCounts,
22
24
  QueueRates,
23
25
  QueueStats,
26
+ TelemetryDirection,
27
+ TelemetryEvent,
24
28
  ThreadmillTaskBackend,
25
29
  )
26
30
 
@@ -88,7 +92,6 @@ class RedisBroker(Broker):
88
92
 
89
93
  def main(self) -> None:
90
94
  """Run mover and running reaper passes for all queues."""
91
- now_ms = time.time() * 1000
92
95
  for queue_name in self.backend.queues:
93
96
  try:
94
97
  self._move_queue(queue_name)
@@ -100,11 +103,6 @@ class RedisBroker(Broker):
100
103
  except Exception: # noqa: BLE001
101
104
  logger.exception("Running reaper error for queue %r", queue_name)
102
105
 
103
- try:
104
- self.backend._trim_telemetry(queue_name, now_ms=now_ms)
105
- except Exception: # noqa: BLE001
106
- logger.exception("Telemetry trim error for queue %r", queue_name)
107
-
108
106
 
109
107
  class RedisTaskBackend(ThreadmillTaskBackend):
110
108
  """Redis-backed durable priority queue backend.
@@ -126,7 +124,7 @@ class RedisTaskBackend(ThreadmillTaskBackend):
126
124
  SEGMENT_KEY = "{prefix}:{queue_name}:{status}"
127
125
  DEFERRED_KEY = "{prefix}:{queue_name}:deferred"
128
126
 
129
- INGRESS_KEY = "{prefix}:{queue_name}:ingress:events"
127
+ TELEMETRY_CHANNEL = "{prefix}:telemetry"
130
128
 
131
129
  ACQUIRE_SCRIPT = _load_lua("acquire")
132
130
  """Pop the next task from a priority queue and move it directly to the running set."""
@@ -150,13 +148,23 @@ class RedisTaskBackend(ThreadmillTaskBackend):
150
148
  f"REDIS_URL must be specified in your settings for the {type(self).__name__}."
151
149
  ) from e
152
150
  self.client = redis.from_url(redis_url)
151
+ self._async_client: redis.asyncio.Redis | None = None
152
+ self.redis_url = redis_url
153
153
  self.key_prefix = f"threadmill:{{{alias}}}"
154
+ self.telemetry_channel = self.TELEMETRY_CHANNEL.format(prefix=self.key_prefix)
154
155
  self.lease_ttl = self.options.get("lease_ttl", datetime.timedelta(hours=1))
155
156
  self.result_ttl = self.options.get("result_ttl", datetime.timedelta(days=1))
156
157
  self.batch_size = self.options.get("batch_size", 100)
157
158
  self._acquire_script = self.client.register_script(self.ACQUIRE_SCRIPT)
158
159
  self._acknowledge_script = self.client.register_script(self.ACKNOWLEDGE_SCRIPT)
159
160
 
161
+ @property
162
+ def async_client(self) -> redis.asyncio.Redis:
163
+ """Lazily-created async Redis client, reused across calls."""
164
+ if self._async_client is None:
165
+ self._async_client = redis.asyncio.Redis.from_url(self.redis_url)
166
+ return self._async_client
167
+
160
168
  def _compute_score(self, priority: int, enqueued_at: datetime.datetime) -> float:
161
169
  """Compute a ZSET score for priority-ordered FIFO queueing.
162
170
 
@@ -182,7 +190,7 @@ class RedisTaskBackend(ThreadmillTaskBackend):
182
190
  enqueued_at = timezone.now()
183
191
  task_result = TaskResult(
184
192
  task=task,
185
- id=str(uuid.uuid4()),
193
+ id=str(uuid.uuid7()),
186
194
  status=TaskResultStatus.READY,
187
195
  enqueued_at=enqueued_at,
188
196
  started_at=None,
@@ -196,15 +204,11 @@ class RedisTaskBackend(ThreadmillTaskBackend):
196
204
  )
197
205
 
198
206
  score = self._compute_score(task.priority, enqueued_at)
199
- enqueued_at_ms = enqueued_at.timestamp() * 1000
200
207
  serialized = self.serialize_task_result(task_result)
201
208
  task_key = self.TASK_KEY.format(prefix=self.key_prefix, task_id=task_result.id)
202
209
  task_data_ttl = int(
203
210
  self.lease_ttl.total_seconds() * 3 + self.result_ttl.total_seconds()
204
211
  )
205
- ingress_key = self.INGRESS_KEY.format(
206
- prefix=self.key_prefix, queue_name=task.queue_name
207
- )
208
212
 
209
213
  pipe = self.client.pipeline()
210
214
  pipe.hset(
@@ -216,7 +220,7 @@ class RedisTaskBackend(ThreadmillTaskBackend):
216
220
  },
217
221
  )
218
222
  pipe.expire(task_key, task_data_ttl)
219
- pipe.zadd(ingress_key, {task_result.id: enqueued_at_ms})
223
+ pipe.publish(self.telemetry_channel, f"ingress:{task.queue_name}")
220
224
 
221
225
  if task.run_after is not None:
222
226
  deferred_key = self.DEFERRED_KEY.format(
@@ -310,6 +314,8 @@ class RedisTaskBackend(ThreadmillTaskBackend):
310
314
  str(int(self.result_ttl.total_seconds())),
311
315
  str(finish_score),
312
316
  task_result.status.name,
317
+ self.telemetry_channel,
318
+ task_result.task.queue_name,
313
319
  ],
314
320
  )
315
321
 
@@ -347,6 +353,7 @@ class RedisTaskBackend(ThreadmillTaskBackend):
347
353
  pipe.hset(task_key, mapping={"data": serialized, "score": str(score)})
348
354
  pipe.expire(task_key, task_data_ttl)
349
355
  pipe.zadd(deferred_key, {task_result.id: run_after_ms})
356
+ pipe.publish(self.telemetry_channel, f"ingress:{task_result.task.queue_name}")
350
357
  pipe.execute()
351
358
 
352
359
  def dequeue(self, task_result: TaskResult) -> None:
@@ -416,84 +423,61 @@ class RedisTaskBackend(ThreadmillTaskBackend):
416
423
  return self.deserialize_task_result(data)
417
424
  raise TaskResultDoesNotExist(f"Task result {result_id!r} does not exist.")
418
425
 
419
- def _trim_telemetry(self, queue_name: str, *, now_ms: float) -> None:
420
- cutoff = now_ms - self.result_ttl.total_seconds() * 1000
421
- pipe = self.client.pipeline()
422
- pipe.zremrangebyscore(
423
- self.INGRESS_KEY.format(prefix=self.key_prefix, queue_name=queue_name),
424
- 0,
425
- cutoff,
426
- )
427
- pipe.zremrangebyscore(
428
- self._segment_key(TaskResultStatus.SUCCESSFUL, queue_name),
429
- 0,
430
- cutoff,
431
- )
432
- pipe.zremrangebyscore(
433
- self._segment_key(TaskResultStatus.FAILED, queue_name),
434
- 0,
435
- cutoff,
436
- )
437
- pipe.execute()
438
-
439
- def telemetry(
426
+ async def queue_stats(
440
427
  self, *, interval: datetime.timedelta = datetime.timedelta(seconds=60)
441
428
  ) -> BackendTelemetry:
442
- now_ms = time.time() * 1000
443
- window_start_ms = now_ms - interval.total_seconds() * 1000
444
- exclusive_start = f"({window_start_ms}"
445
- retention_start_ms = now_ms - self.result_ttl.total_seconds() * 1000
446
- pipe = self.client.pipeline()
429
+ client = self.async_client
430
+ pipe = client.pipeline()
447
431
  for queue_name in self.queues:
448
432
  pipe.zcard(self._segment_key(TaskResultStatus.READY, queue_name))
449
433
  pipe.zcard(self._segment_key(TaskResultStatus.RUNNING, queue_name))
450
434
  pipe.zcard(
451
435
  self.DEFERRED_KEY.format(prefix=self.key_prefix, queue_name=queue_name)
452
436
  )
453
- successful_results_key = self._segment_key(
454
- TaskResultStatus.SUCCESSFUL, queue_name
455
- )
456
- failed_results_key = self._segment_key(TaskResultStatus.FAILED, queue_name)
457
- pipe.zcard(successful_results_key)
458
- pipe.zcard(failed_results_key)
459
- ingress_key = self.INGRESS_KEY.format(
460
- prefix=self.key_prefix, queue_name=queue_name
461
- )
462
- pipe.zcount(ingress_key, exclusive_start, now_ms)
463
- pipe.zcount(successful_results_key, exclusive_start, now_ms)
464
- pipe.zcount(failed_results_key, exclusive_start, now_ms)
465
- pipe.zremrangebyscore(ingress_key, 0, retention_start_ms)
466
- results = pipe.execute()
437
+ pipe.zcard(self._segment_key(TaskResultStatus.SUCCESSFUL, queue_name))
438
+ pipe.zcard(self._segment_key(TaskResultStatus.FAILED, queue_name))
439
+ results = await pipe.execute()
440
+ zero_rates = QueueRates(interval=interval, ingress=0, egress=0)
467
441
  queues: dict[str, QueueStats] = {}
468
442
  for index, queue_name in enumerate(self.queues):
469
- base = index * 9
470
- (
471
- ready,
472
- running,
473
- deferred,
474
- successful,
475
- failed,
476
- ingress,
477
- successful_rate,
478
- failed_rate,
479
- _,
480
- ) = (int(c or 0) for c in results[base : base + 9])
443
+ base = index * 5
481
444
  queues[queue_name] = QueueStats(
482
445
  counts=QueueCounts(
483
- ready=ready,
484
- running=running,
485
- deferred=deferred,
486
- successful=successful,
487
- failed=failed,
488
- ),
489
- rates=QueueRates(
490
- interval=interval,
491
- ingress=ingress,
492
- egress=successful_rate + failed_rate,
446
+ ready=int(results[base] or 0),
447
+ running=int(results[base + 1] or 0),
448
+ deferred=int(results[base + 2] or 0),
449
+ successful=int(results[base + 3] or 0),
450
+ failed=int(results[base + 4] or 0),
493
451
  ),
452
+ rates=zero_rates,
494
453
  )
495
454
  return BackendTelemetry(queues=queues)
496
455
 
456
+ async def worker_telemetry(
457
+ self,
458
+ ) -> collections.abc.AsyncGenerator[TelemetryEvent]:
459
+ client = self.async_client
460
+ pubsub = client.pubsub()
461
+ await pubsub.subscribe(self.telemetry_channel)
462
+ pubsub.ignore_subscribe_messages = True
463
+ try:
464
+ async for message in pubsub.listen():
465
+ if (data := message.get("data")) is not None:
466
+ payload = data.decode() if isinstance(data, bytes) else data
467
+ direction, _, queue_name = payload.partition(":")
468
+ try:
469
+ event = TelemetryEvent(
470
+ direction=TelemetryDirection(direction),
471
+ queue_name=queue_name,
472
+ )
473
+ except ValueError:
474
+ continue
475
+ yield event
476
+ finally:
477
+ await pubsub.unsubscribe(self.telemetry_channel)
478
+ await pubsub.aclose()
479
+
497
480
  def close(self) -> None:
498
- """Close the Redis connection."""
481
+ """Close the Redis connections."""
499
482
  self.client.close()
483
+ self._async_client = None
@@ -1,8 +1,11 @@
1
1
  """Textual app for the inspector TUI."""
2
2
 
3
+ import asyncio
4
+ import dataclasses
3
5
  import datetime
4
6
  import logging
5
7
  import math
8
+ import time
6
9
  import typing
7
10
  from typing import Any
8
11
 
@@ -13,6 +16,7 @@ from django.tasks import (
13
16
  TaskResultStatus,
14
17
  task_backends,
15
18
  )
19
+ from textual import work
16
20
  from textual.app import App, ComposeResult
17
21
  from textual.binding import Binding
18
22
  from textual.containers import Horizontal, Vertical
@@ -24,18 +28,27 @@ from textual.widgets import (
24
28
  ListItem,
25
29
  ListView,
26
30
  Select,
31
+ Sparkline,
27
32
  Static,
28
33
  TabbedContent,
29
34
  TabPane,
30
35
  )
31
36
 
32
- from ..backends.base import BackendTelemetry, ThreadmillTaskBackend
37
+ from ..backends.base import (
38
+ BackendTelemetry,
39
+ TelemetryDirection,
40
+ ThreadmillTaskBackend,
41
+ )
33
42
  from .screens import ConfirmScreen, PurgeScreen
43
+ from .telemetry import TelemetryBuffer
34
44
 
35
45
  logger = logging.getLogger(__name__)
36
46
 
37
- TELEMETRY_INTERVAL_SECONDS = 2.0
38
- """Seconds between automatic queue-stat refreshes; the task list stays manual."""
47
+ TELEMETRY_INTERVAL = datetime.timedelta(seconds=2)
48
+ """Between automatic queue-stat refreshes; the task list stays manual."""
49
+
50
+ SPARKLINE_INTERVAL = datetime.timedelta(seconds=1)
51
+ """Between sparkline refreshes from the rolling telemetry buffer."""
39
52
 
40
53
  TAB_STATUSES: list[tuple[str, TaskResultStatus | None]] = [
41
54
  ("Running", TaskResultStatus.RUNNING),
@@ -230,7 +243,7 @@ class TaskList(Vertical):
230
243
 
231
244
  def action_refresh(self) -> None:
232
245
  """Refresh telemetry and re-fetch the task list."""
233
- self.app._refresh_telemetry()
246
+ self.app._poll_queue_stats()
234
247
  self.refresh_tasks()
235
248
 
236
249
  def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
@@ -275,15 +288,14 @@ class TaskList(Vertical):
275
288
  )
276
289
 
277
290
  def _do_requeue(self, confirmed: bool, task: TaskResult) -> None:
278
- if not confirmed:
279
- return
280
- try:
281
- self.backend.requeue(task, datetime.datetime.now(datetime.UTC))
282
- except Exception: # noqa: BLE001
283
- logger.exception("Requeue failed for task %s", task.id)
284
- self.app.notify("Requeue failed.", severity="error")
285
- return
286
- self.app._after_action("Task requeued.")
291
+ if confirmed:
292
+ try:
293
+ self.backend.requeue(task, datetime.datetime.now(datetime.UTC))
294
+ except Exception: # noqa: BLE001
295
+ logger.exception("Requeue failed for task %s", task.id)
296
+ self.app.notify("Requeue failed.", severity="error")
297
+ else:
298
+ self.app._after_action("Task requeued.")
287
299
 
288
300
  def action_dequeue(self) -> None:
289
301
  """Remove the selected task from its current segment after confirmation."""
@@ -298,15 +310,14 @@ class TaskList(Vertical):
298
310
  )
299
311
 
300
312
  def _do_dequeue(self, confirmed: bool, task: TaskResult) -> None:
301
- if not confirmed:
302
- return
303
- try:
304
- self.backend.dequeue(task)
305
- except Exception: # noqa: BLE001
306
- logger.exception("Delete failed for task %s", task.id)
307
- self.app.notify("Delete failed.", severity="error")
308
- return
309
- self.app._after_action("Task deleted.")
313
+ if confirmed:
314
+ try:
315
+ self.backend.dequeue(task)
316
+ except Exception: # noqa: BLE001
317
+ logger.exception("Delete failed for task %s", task.id)
318
+ self.app.notify("Delete failed.", severity="error")
319
+ else:
320
+ self.app._after_action("Task deleted.")
310
321
 
311
322
  def _refresh_data(self) -> None:
312
323
  """Fetch and display tasks for the current queue and active tab."""
@@ -413,24 +424,22 @@ class QueueList(ListView):
413
424
 
414
425
  def action_purge(self) -> None:
415
426
  item = self.highlighted_child
416
- if not isinstance(item, QueueItem):
417
- return
418
- queue_name = item.queue_name
419
- self.app.push_screen(
420
- PurgeScreen(queue_name),
421
- lambda confirmed: self._do_purge(confirmed, queue_name),
422
- )
427
+ if isinstance(item, QueueItem):
428
+ queue_name = item.queue_name
429
+ self.app.push_screen(
430
+ PurgeScreen(queue_name),
431
+ lambda confirmed: self._do_purge(confirmed, queue_name),
432
+ )
423
433
 
424
434
  def _do_purge(self, confirmed: bool, queue_name: str) -> None:
425
- if not confirmed:
426
- return
427
- try:
428
- self.app.backend.purge(queue_name)
429
- except Exception: # noqa: BLE001
430
- logger.exception("Purge failed for %r", queue_name)
431
- self.app.notify("Purge failed.", severity="error")
432
- return
433
- self.app._after_action(f"Purged queue {queue_name}.")
435
+ if confirmed:
436
+ try:
437
+ self.app.backend.purge(queue_name)
438
+ except Exception: # noqa: BLE001
439
+ logger.exception("Purge failed for %r", queue_name)
440
+ self.app.notify("Purge failed.", severity="error")
441
+ else:
442
+ self.app._after_action(f"Purged queue {queue_name}.")
434
443
 
435
444
 
436
445
  class InspectorApp(App):
@@ -459,8 +468,10 @@ class InspectorApp(App):
459
468
  self._task_list: TaskList | None = None
460
469
  self._task_detail: TaskDetail | None = None
461
470
  self._options_static: Static | None = None
471
+ self._sparkline_timer = None
462
472
  self._telemetry_timer = None
463
473
  self._auto_refresh = auto_refresh
474
+ self.telemetry_buffer = TelemetryBuffer()
464
475
  self.set_reactive(InspectorApp.backend, backend)
465
476
 
466
477
  def compose(self) -> ComposeResult:
@@ -481,6 +492,9 @@ class InspectorApp(App):
481
492
  telemetry=InspectorApp.telemetry
482
493
  )
483
494
  with Vertical(id="right-pane"):
495
+ with Horizontal(id="sparkline-row"):
496
+ yield Sparkline(id="ingress-sparkline")
497
+ yield Sparkline(id="egress-sparkline")
484
498
  yield TaskList(id="task-list").data_bind(
485
499
  backend=InspectorApp.backend,
486
500
  telemetry=InspectorApp.telemetry,
@@ -496,13 +510,21 @@ class InspectorApp(App):
496
510
  self._task_detail = self.query_one("#task-detail", TaskDetail)
497
511
  self._options_static = self.query_one("#backend-options", Static)
498
512
  self._refresh_options()
499
- self._refresh_telemetry()
513
+ self.query_one("#ingress-sparkline", Sparkline).border_title = "Ingress"
514
+ self.query_one("#egress-sparkline", Sparkline).border_title = "Egress"
515
+ self._poll_queue_stats()
516
+ self._listen_telemetry()
500
517
  if self._auto_refresh:
501
518
  self._telemetry_timer = self.set_interval(
502
- TELEMETRY_INTERVAL_SECONDS,
503
- self._refresh_telemetry,
519
+ TELEMETRY_INTERVAL.total_seconds(),
520
+ self._poll_queue_stats,
504
521
  name="telemetry-refresh",
505
522
  )
523
+ self._sparkline_timer = self.set_interval(
524
+ SPARKLINE_INTERVAL.total_seconds(),
525
+ self._refresh_sparklines,
526
+ name="sparkline-refresh",
527
+ )
506
528
  self._queue_list.focus()
507
529
 
508
530
  def action_quit(self) -> None:
@@ -512,7 +534,7 @@ class InspectorApp(App):
512
534
  def _after_action(self, message: str) -> None:
513
535
  """Refresh telemetry and tasks after a mutating queue action."""
514
536
  self.notify(message)
515
- self._refresh_telemetry()
537
+ self._poll_queue_stats()
516
538
  self._task_list.refresh_tasks()
517
539
 
518
540
  def action_switch_tab(self, tab_id: str) -> None:
@@ -521,7 +543,9 @@ class InspectorApp(App):
521
543
 
522
544
  def watch_backend(self) -> None:
523
545
  self._refresh_options()
524
- self._refresh_telemetry()
546
+ self.telemetry_buffer = TelemetryBuffer()
547
+ self._poll_queue_stats()
548
+ self._listen_telemetry()
525
549
 
526
550
  def on_select_changed(self, event: Select.Changed) -> None:
527
551
  """Switch backend when the user selects a different alias."""
@@ -548,9 +572,53 @@ class InspectorApp(App):
548
572
  ]
549
573
  self._options_static.update(" ".join(parts) or "No options")
550
574
 
551
- def _refresh_telemetry(self) -> None:
552
- """Poll the backend for fresh telemetry."""
575
+ @work(exclusive=True, group="queue-stats")
576
+ async def _poll_queue_stats(self) -> None:
577
+ """Fetch queue counts and merge live pub/sub rates into the telemetry."""
553
578
  try:
554
- self.telemetry = self.backend.telemetry()
579
+ telemetry = await self.backend.queue_stats()
555
580
  except Exception: # noqa: BLE001
556
- logger.exception("Failed to refresh telemetry")
581
+ logger.exception("Failed to refresh queue stats")
582
+ else:
583
+ for queue_name, stats in telemetry.queues.items():
584
+ live_rates = self.telemetry_buffer.rates_for(queue_name)
585
+ telemetry.queues[queue_name] = dataclasses.replace(
586
+ stats, rates=live_rates
587
+ )
588
+ self.telemetry = telemetry
589
+
590
+ def _refresh_sparklines(self) -> None:
591
+ """Push the latest 60-second rolling series into the ingress/egress sparklines."""
592
+ queue_name = self._task_list.queue_name
593
+ ingress = self.query_one("#ingress-sparkline", Sparkline)
594
+ egress = self.query_one("#egress-sparkline", Sparkline)
595
+ now = time.monotonic()
596
+ ingress.data = (
597
+ self.telemetry_buffer.series(
598
+ queue_name, TelemetryDirection.INGRESS, now=now
599
+ )
600
+ if queue_name
601
+ else [0.0] * 60
602
+ )
603
+ egress.data = (
604
+ self.telemetry_buffer.series(queue_name, TelemetryDirection.EGRESS, now=now)
605
+ if queue_name
606
+ else [0.0] * 60
607
+ )
608
+
609
+ @work(exclusive=True, group="telemetry")
610
+ async def _listen_telemetry(self) -> None:
611
+ """Feed the telemetry buffer from the backend's pub/sub stream."""
612
+ try:
613
+ stream = self.backend.worker_telemetry()
614
+ if asyncio.iscoroutine(stream):
615
+ stream = await stream
616
+ except NotImplementedError:
617
+ pass
618
+ else:
619
+ if stream is not None:
620
+ try:
621
+ async for event in stream:
622
+ self.telemetry_buffer.record(event, now=time.monotonic())
623
+ except asyncio.CancelledError:
624
+ raise
@@ -42,6 +42,29 @@ Select {
42
42
  width: 42;
43
43
  }
44
44
 
45
+ #sparkline-row {
46
+ height: 5;
47
+ margin-bottom: 0;
48
+ }
49
+
50
+ #ingress-sparkline,
51
+ #egress-sparkline {
52
+ width: 1fr;
53
+ height: 1fr;
54
+ padding: 0;
55
+ margin: 0;
56
+ border: round $accent 40%;
57
+ border-title-color: $primary;
58
+ }
59
+
60
+ #ingress-sparkline > .sparkline--max-color {
61
+ color: $success;
62
+ }
63
+
64
+ #egress-sparkline > .sparkline--max-color {
65
+ color: $error;
66
+ }
67
+
45
68
  #backend-row {
46
69
  height: 3;
47
70
  }
@@ -76,7 +99,7 @@ QueueList ListItem {
76
99
  }
77
100
 
78
101
  TaskList {
79
- height: 60%;
102
+ height: 1fr;
80
103
 
81
104
  TabbedContent,
82
105
  ContentSwitcher,
@@ -104,7 +127,7 @@ DataTable {
104
127
  }
105
128
 
106
129
  TaskDetail {
107
- height: 40%;
130
+ height: 1fr;
108
131
  width: 100%;
109
132
  overflow: scroll;
110
133
  padding: 0 1;
@@ -0,0 +1,81 @@
1
+ """In-memory rolling telemetry buffer fed by backend pub/sub events."""
2
+
3
+ import collections
4
+ import datetime
5
+ import time
6
+
7
+ from threadmill.backends.base import QueueRates, TelemetryDirection, TelemetryEvent
8
+
9
+ WINDOW_SECONDS = 60
10
+ """Rolling window the inspector displays for ingress/egress sparklines."""
11
+
12
+
13
+ class TelemetryBuffer:
14
+ """Per-(queue, direction) rolling 60-second count buffer."""
15
+
16
+ def __init__(self, *, window: int = WINDOW_SECONDS) -> None:
17
+ self._window = window
18
+ self._counts: dict[
19
+ tuple[str, TelemetryDirection], collections.OrderedDict[int, int]
20
+ ] = collections.defaultdict(collections.OrderedDict)
21
+
22
+ def record(self, event: TelemetryEvent, *, now: float | None = None) -> None:
23
+ """Increment the current-second bucket for the event's queue+direction."""
24
+ second = int(now if now is not None else time.monotonic())
25
+ buckets = self._counts[event.queue_name, event.direction]
26
+ buckets[second] = buckets.get(second, 0) + 1
27
+ self._evict(buckets, second)
28
+
29
+ def series(
30
+ self,
31
+ queue_name: str,
32
+ direction: TelemetryDirection,
33
+ *,
34
+ now: float | None = None,
35
+ ) -> list[float]:
36
+ """Return ``window`` per-second counts, oldest to newest, for the pair."""
37
+ second = int(now if now is not None else time.monotonic())
38
+ buckets = self._counts.get((queue_name, direction))
39
+ if buckets is not None:
40
+ self._evict(buckets, second)
41
+ return [
42
+ float(buckets.get(second - offset, 0)) if buckets else 0.0
43
+ for offset in reversed(range(self._window))
44
+ ]
45
+
46
+ def rates_for(
47
+ self,
48
+ queue_name: str,
49
+ *,
50
+ interval: datetime.timedelta = datetime.timedelta(seconds=WINDOW_SECONDS),
51
+ now: float | None = None,
52
+ ) -> QueueRates:
53
+ """Sum the last ``interval`` of events into per-second ingress/egress rates."""
54
+ second = int(now if now is not None else time.monotonic())
55
+ span = min(int(interval.total_seconds()), self._window)
56
+ ingress = self._sum_direction(
57
+ queue_name, TelemetryDirection.INGRESS, second, span
58
+ )
59
+ egress = self._sum_direction(
60
+ queue_name, TelemetryDirection.EGRESS, second, span
61
+ )
62
+ return QueueRates(
63
+ interval=datetime.timedelta(seconds=span), ingress=ingress, egress=egress
64
+ )
65
+
66
+ def _sum_direction(
67
+ self, queue_name: str, direction: TelemetryDirection, second: int, span: int
68
+ ) -> int:
69
+ buckets = self._counts.get((queue_name, direction))
70
+ if buckets is None:
71
+ return 0
72
+ self._evict(buckets, second)
73
+ return sum(buckets.get(second - offset, 0) for offset in range(span))
74
+
75
+ def _evict(
76
+ self, buckets: collections.OrderedDict[int, int], now_second: int
77
+ ) -> None:
78
+ """Drop buckets older than the window (LRU pop from front)."""
79
+ cutoff = now_second - self._window
80
+ while buckets and next(iter(buckets)) <= cutoff:
81
+ buckets.popitem(last=False)
@@ -1,114 +0,0 @@
1
- [build-system]
2
- requires = ["flit_core>=3.2", "flit_scm", "wheel"]
3
- build-backend = "flit_scm:buildapi"
4
-
5
- [project]
6
- name = "threadmill"
7
- authors = [
8
- { name = "Johannes Maron", email = "johannes@maron.family" },
9
- ]
10
- readme = "README.md"
11
- license = { file = "LICENSE" }
12
- keywords = ["Django", "tasks", "worker"]
13
- dynamic = ["version", "description"]
14
- classifiers = [
15
- "Development Status :: 4 - Beta",
16
- "Programming Language :: Python",
17
- "Environment :: Web Environment",
18
- "License :: OSI Approved :: BSD License",
19
- "Intended Audience :: Developers",
20
- "Operating System :: Microsoft :: Windows",
21
- "Operating System :: MacOS :: MacOS X",
22
- "Operating System :: POSIX",
23
- "Topic :: Communications :: Email",
24
- "Topic :: Text Processing :: Markup :: Markdown",
25
- "Topic :: Software Development",
26
- "Programming Language :: Python",
27
- "Programming Language :: Python :: 3",
28
- "Programming Language :: Python :: 3 :: Only",
29
- "Programming Language :: Python :: 3.14",
30
- "Framework :: Django",
31
- "Framework :: Django :: 6.1",
32
- ]
33
- requires-python = ">=3.14"
34
- dependencies = ["django>=6.0"]
35
-
36
- [project.optional-dependencies]
37
- redis = ["redis>=5.0"]
38
- inspector = [
39
- "textual>=8.2.7",
40
- ]
41
-
42
- [project.urls]
43
- # https://packaging.python.org/en/latest/specifications/well-known-project-urls/#well-known-labels
44
- Homepage = "https://github.com/codingjoe/threadmill"
45
- Changelog = "https://github.com/codingjoe/threadmill/releases"
46
- Source = "https://github.com/codingjoe/threadmill"
47
- Releasenotes = "https://github.com/codingjoe/threadmill/releases/latest"
48
- Documentation = "https://github.com/codingjoe/threadmill/"
49
- Issues = "https://github.com/codingjoe/threadmill/issues"
50
- Funding = "https://github.com/sponsors/codingjoe"
51
-
52
- [tool.flit.module]
53
- name = "threadmill"
54
-
55
- [tool.setuptools_scm]
56
- write_to = "threadmill/_version.py"
57
-
58
- [tool.pytest.ini_options]
59
- minversion = "6.1a1"
60
- addopts = "--cov --cov-report=xml --cov-report=term --tb=short -rxs --benchmark-autosave --benchmark-group-by=fullname --benchmark-min-rounds=10"
61
- testpaths = ["tests"]
62
- DJANGO_SETTINGS_MODULE = "tests.testapp.settings"
63
- asyncio_mode = "auto"
64
- markers = [
65
- "benchmark: mark benchmark tests.",
66
- ]
67
-
68
- [tool.coverage.run]
69
- source = ["threadmill"]
70
-
71
- [tool.coverage.report]
72
- show_missing = true
73
- skip_covered = true
74
-
75
- [tool.ruff]
76
- src = ["threadmill", "tests"]
77
-
78
- [tool.ruff.lint]
79
- select = [
80
- "E", # pycodestyle errors
81
- "W", # pycodestyle warnings
82
- "F", # pyflakes
83
- "I", # isort
84
- "S", # flake8-bandit
85
- "D", # pydocstyle
86
- "UP", # pyupgrade
87
- "B", # flake8-bugbear
88
- "C", # flake8-comprehensions
89
- ]
90
-
91
- ignore = ["B904", "D1", "E501", "S101"]
92
-
93
- [tool.ruff.lint.isort]
94
- combine-as-imports = true
95
- split-on-trailing-comma = true
96
- section-order = ["future", "standard-library", "third-party", "first-party", "local-folder"]
97
- force-wrap-aliases = true
98
- known-first-party = ["threadmill", "tests"]
99
-
100
- [tool.ruff.lint.pydocstyle]
101
- convention = "pep257"
102
-
103
- [dependency-groups]
104
- dev = [
105
- { include-group = "test" },
106
- ]
107
- test = [
108
- "pytest",
109
- "pytest-benchmark",
110
- "pytest-asyncio",
111
- "pytest-cov",
112
- "pytest-django",
113
- "redis>=5.0",
114
- ]
File without changes
File without changes