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.
- {threadmill-0.5.0 → threadmill-0.6.0}/PKG-INFO +11 -13
- threadmill-0.6.0/pyproject.toml +102 -0
- {threadmill-0.5.0 → threadmill-0.6.0}/threadmill/_version.py +3 -3
- {threadmill-0.5.0 → threadmill-0.6.0}/threadmill/backends/base.py +23 -5
- {threadmill-0.5.0 → threadmill-0.6.0}/threadmill/backends/lua/acknowledge.lua +5 -1
- {threadmill-0.5.0 → threadmill-0.6.0}/threadmill/backends/redis.py +60 -76
- {threadmill-0.5.0 → threadmill-0.6.0}/threadmill/inspector/app.py +115 -47
- {threadmill-0.5.0 → threadmill-0.6.0}/threadmill/inspector/inspector.scss +25 -2
- threadmill-0.6.0/threadmill/inspector/telemetry.py +81 -0
- threadmill-0.5.0/pyproject.toml +0 -114
- {threadmill-0.5.0 → threadmill-0.6.0}/LICENSE +0 -0
- {threadmill-0.5.0 → threadmill-0.6.0}/README.md +0 -0
- {threadmill-0.5.0 → threadmill-0.6.0}/threadmill/__init__.py +0 -0
- {threadmill-0.5.0 → threadmill-0.6.0}/threadmill/backends/__init__.py +0 -0
- {threadmill-0.5.0 → threadmill-0.6.0}/threadmill/backends/lua/acquire.lua +0 -0
- {threadmill-0.5.0 → threadmill-0.6.0}/threadmill/backends/lua/mover.lua +0 -0
- {threadmill-0.5.0 → threadmill-0.6.0}/threadmill/backends/lua/reaper.lua +0 -0
- {threadmill-0.5.0 → threadmill-0.6.0}/threadmill/exceptions.py +0 -0
- {threadmill-0.5.0 → threadmill-0.6.0}/threadmill/executor.py +0 -0
- {threadmill-0.5.0 → threadmill-0.6.0}/threadmill/inspector/__init__.py +0 -0
- {threadmill-0.5.0 → threadmill-0.6.0}/threadmill/inspector/screens.py +0 -0
- {threadmill-0.5.0 → threadmill-0.6.0}/threadmill/management/__init__.py +0 -0
- {threadmill-0.5.0 → threadmill-0.6.0}/threadmill/management/commands/__init__.py +0 -0
- {threadmill-0.5.0 → threadmill-0.6.0}/threadmill/management/commands/threadmill.py +0 -0
- {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.
|
|
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 ::
|
|
10
|
-
Classifier: Programming Language :: Python
|
|
9
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
11
10
|
Classifier: Environment :: Web Environment
|
|
12
|
-
Classifier:
|
|
11
|
+
Classifier: Framework :: Django
|
|
12
|
+
Classifier: Framework :: Django :: 6.1
|
|
13
13
|
Classifier: Intended Audience :: Developers
|
|
14
|
-
Classifier:
|
|
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:
|
|
25
|
-
Classifier:
|
|
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.
|
|
25
|
+
Requires-Dist: django>=6.1
|
|
28
26
|
Requires-Dist: textual>=8.2.7 ; extra == "inspector"
|
|
29
|
-
Requires-Dist: redis>=5
|
|
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.
|
|
22
|
-
__version_tuple__ = version_tuple = (0,
|
|
21
|
+
__version__ = version = '0.6.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 6, 0)
|
|
23
23
|
|
|
24
|
-
__commit_id__ = commit_id = '
|
|
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
|
|
261
|
+
async def queue_stats(
|
|
246
262
|
self, *, interval: datetime.timedelta = datetime.timedelta(seconds=60)
|
|
247
263
|
) -> BackendTelemetry:
|
|
248
|
-
"""Return
|
|
264
|
+
"""Return per-queue task counts for all configured queues."""
|
|
265
|
+
raise NotImplementedError
|
|
249
266
|
|
|
250
|
-
|
|
251
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
|
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
|
-
|
|
443
|
-
|
|
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
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
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 *
|
|
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=
|
|
484
|
-
running=
|
|
485
|
-
deferred=
|
|
486
|
-
successful=
|
|
487
|
-
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
|
|
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
|
|
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
|
-
|
|
38
|
-
"""
|
|
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.
|
|
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
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
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
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
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.
|
|
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
|
-
|
|
503
|
-
self.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
552
|
-
|
|
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
|
-
|
|
579
|
+
telemetry = await self.backend.queue_stats()
|
|
555
580
|
except Exception: # noqa: BLE001
|
|
556
|
-
logger.exception("Failed to refresh
|
|
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:
|
|
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:
|
|
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)
|
threadmill-0.5.0/pyproject.toml
DELETED
|
@@ -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
|
|
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
|