threadmill 0.4.0__tar.gz → 0.5.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {threadmill-0.4.0 → threadmill-0.5.1}/PKG-INFO +1 -1
- {threadmill-0.4.0 → threadmill-0.5.1}/threadmill/_version.py +3 -3
- {threadmill-0.4.0 → threadmill-0.5.1}/threadmill/backends/base.py +39 -4
- {threadmill-0.4.0 → threadmill-0.5.1}/threadmill/backends/lua/acknowledge.lua +5 -1
- {threadmill-0.4.0 → threadmill-0.5.1}/threadmill/backends/lua/acquire.lua +1 -1
- {threadmill-0.4.0 → threadmill-0.5.1}/threadmill/backends/redis.py +121 -134
- {threadmill-0.4.0 → threadmill-0.5.1}/threadmill/inspector/app.py +182 -19
- {threadmill-0.4.0 → threadmill-0.5.1}/threadmill/inspector/inspector.scss +56 -2
- threadmill-0.5.1/threadmill/inspector/screens.py +52 -0
- threadmill-0.5.1/threadmill/inspector/telemetry.py +81 -0
- {threadmill-0.4.0 → threadmill-0.5.1}/LICENSE +0 -0
- {threadmill-0.4.0 → threadmill-0.5.1}/README.md +0 -0
- {threadmill-0.4.0 → threadmill-0.5.1}/pyproject.toml +0 -0
- {threadmill-0.4.0 → threadmill-0.5.1}/threadmill/__init__.py +0 -0
- {threadmill-0.4.0 → threadmill-0.5.1}/threadmill/backends/__init__.py +0 -0
- {threadmill-0.4.0 → threadmill-0.5.1}/threadmill/backends/lua/mover.lua +0 -0
- {threadmill-0.4.0 → threadmill-0.5.1}/threadmill/backends/lua/reaper.lua +0 -0
- {threadmill-0.4.0 → threadmill-0.5.1}/threadmill/exceptions.py +0 -0
- {threadmill-0.4.0 → threadmill-0.5.1}/threadmill/executor.py +0 -0
- {threadmill-0.4.0 → threadmill-0.5.1}/threadmill/inspector/__init__.py +0 -0
- {threadmill-0.4.0 → threadmill-0.5.1}/threadmill/management/__init__.py +0 -0
- {threadmill-0.4.0 → threadmill-0.5.1}/threadmill/management/commands/__init__.py +0 -0
- {threadmill-0.4.0 → threadmill-0.5.1}/threadmill/management/commands/threadmill.py +0 -0
- {threadmill-0.4.0 → threadmill-0.5.1}/threadmill/retry.py +0 -0
|
@@ -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.5.1'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 5, 1)
|
|
23
23
|
|
|
24
|
-
__commit_id__ = commit_id = '
|
|
24
|
+
__commit_id__ = commit_id = 'ga1884237b'
|
|
@@ -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
|
|
|
@@ -217,7 +233,12 @@ class ThreadmillTaskBackend(BaseTaskBackend, ABC):
|
|
|
217
233
|
raise NotImplementedError
|
|
218
234
|
|
|
219
235
|
def requeue(self, task_result: TaskResult, run_after: datetime.datetime) -> None:
|
|
220
|
-
"""Re-queue a failed task result for a retry attempt after `run_after`.
|
|
236
|
+
"""Re-queue a failed task result for a retry attempt after `run_after`.
|
|
237
|
+
|
|
238
|
+
Cleans up any persisted failed result so the method works both for
|
|
239
|
+
in-flight retries (task still running) and inspector-driven requeues
|
|
240
|
+
of already-failed tasks.
|
|
241
|
+
"""
|
|
221
242
|
raise NotImplementedError
|
|
222
243
|
|
|
223
244
|
def peek(
|
|
@@ -237,12 +258,26 @@ class ThreadmillTaskBackend(BaseTaskBackend, ABC):
|
|
|
237
258
|
"""
|
|
238
259
|
raise NotImplementedError
|
|
239
260
|
|
|
240
|
-
def
|
|
261
|
+
async def queue_stats(
|
|
241
262
|
self, *, interval: datetime.timedelta = datetime.timedelta(seconds=60)
|
|
242
263
|
) -> BackendTelemetry:
|
|
243
|
-
"""Return
|
|
264
|
+
"""Return per-queue task counts for all configured queues."""
|
|
265
|
+
raise NotImplementedError
|
|
266
|
+
|
|
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."""
|
|
271
|
+
raise NotImplementedError
|
|
272
|
+
|
|
273
|
+
def dequeue(self, task_result: TaskResult) -> None:
|
|
274
|
+
"""Delete a single task from its current status segment."""
|
|
275
|
+
raise NotImplementedError
|
|
276
|
+
|
|
277
|
+
def purge(self, queue_name: str) -> None:
|
|
278
|
+
"""Delete every task across all segments of a queue.
|
|
244
279
|
|
|
245
280
|
Args:
|
|
246
|
-
|
|
281
|
+
queue_name: The queue to purge.
|
|
247
282
|
"""
|
|
248
283
|
raise NotImplementedError
|
|
@@ -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
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
-- KEYS[4] = queue, etc.
|
|
8
8
|
-- ARGV[1] -- current time in milliseconds
|
|
9
9
|
-- ARGV[2] -- current time as ISO-8601 string
|
|
10
|
-
-- ARGV[3] -- task key prefix (e.g. "threadmill:
|
|
10
|
+
-- ARGV[3] -- task key prefix (e.g. "threadmill:task:")
|
|
11
11
|
-- ARGV[4] -- number of queue pairs (N/2)
|
|
12
12
|
-- ARGV[5] -- worker name
|
|
13
13
|
-- ARGV[6] -- lease TTL in milliseconds
|
|
@@ -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
|
|
|
@@ -55,14 +59,12 @@ class RedisBroker(Broker):
|
|
|
55
59
|
deferred_key = self.backend.DEFERRED_KEY.format(
|
|
56
60
|
prefix=self.backend.key_prefix, queue_name=queue_name
|
|
57
61
|
)
|
|
58
|
-
queue_key = self.backend.
|
|
59
|
-
prefix=self.backend.key_prefix, queue_name=queue_name
|
|
60
|
-
)
|
|
62
|
+
queue_key = self.backend._segment_key(TaskResultStatus.READY, queue_name)
|
|
61
63
|
self._mover_script(
|
|
62
64
|
keys=[deferred_key, queue_key],
|
|
63
65
|
args=[
|
|
64
66
|
str(time.time() * 1000),
|
|
65
|
-
self.backend.key_prefix
|
|
67
|
+
f"{self.backend.key_prefix}:task:",
|
|
66
68
|
str(self.backend.batch_size),
|
|
67
69
|
],
|
|
68
70
|
)
|
|
@@ -72,18 +74,16 @@ class RedisBroker(Broker):
|
|
|
72
74
|
now = timezone.now()
|
|
73
75
|
now_ms = now.timestamp() * 1000
|
|
74
76
|
finished_at_iso = now.isoformat()
|
|
75
|
-
running_key = self.backend.
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
failed_results_key = self.backend.FAILED_RESULTS_KEY.format(
|
|
79
|
-
prefix=self.backend.key_prefix, queue_name=queue_name
|
|
77
|
+
running_key = self.backend._segment_key(TaskResultStatus.RUNNING, queue_name)
|
|
78
|
+
failed_results_key = self.backend._segment_key(
|
|
79
|
+
TaskResultStatus.FAILED, queue_name
|
|
80
80
|
)
|
|
81
81
|
self._reaper_script(
|
|
82
82
|
keys=[running_key, failed_results_key],
|
|
83
83
|
args=[
|
|
84
84
|
str(now_ms),
|
|
85
|
-
self.backend.key_prefix
|
|
86
|
-
self.backend.key_prefix
|
|
85
|
+
f"{self.backend.key_prefix}:task:",
|
|
86
|
+
f"{self.backend.key_prefix}:result:",
|
|
87
87
|
str(self.backend.batch_size),
|
|
88
88
|
str(int(self.backend.result_ttl.total_seconds())),
|
|
89
89
|
finished_at_iso,
|
|
@@ -92,7 +92,6 @@ class RedisBroker(Broker):
|
|
|
92
92
|
|
|
93
93
|
def main(self) -> None:
|
|
94
94
|
"""Run mover and running reaper passes for all queues."""
|
|
95
|
-
now_ms = time.time() * 1000
|
|
96
95
|
for queue_name in self.backend.queues:
|
|
97
96
|
try:
|
|
98
97
|
self._move_queue(queue_name)
|
|
@@ -104,11 +103,6 @@ class RedisBroker(Broker):
|
|
|
104
103
|
except Exception: # noqa: BLE001
|
|
105
104
|
logger.exception("Running reaper error for queue %r", queue_name)
|
|
106
105
|
|
|
107
|
-
try:
|
|
108
|
-
self.backend._trim_telemetry(queue_name, now_ms=now_ms)
|
|
109
|
-
except Exception: # noqa: BLE001
|
|
110
|
-
logger.exception("Telemetry trim error for queue %r", queue_name)
|
|
111
|
-
|
|
112
106
|
|
|
113
107
|
class RedisTaskBackend(ThreadmillTaskBackend):
|
|
114
108
|
"""Redis-backed durable priority queue backend.
|
|
@@ -125,20 +119,25 @@ class RedisTaskBackend(ThreadmillTaskBackend):
|
|
|
125
119
|
|
|
126
120
|
broker_class = RedisBroker
|
|
127
121
|
|
|
128
|
-
QUEUE_KEY = "{prefix}:queue:{queue_name}"
|
|
129
|
-
RUNNING_KEY = "{prefix}:running:{queue_name}"
|
|
130
|
-
DEFERRED_KEY = "{prefix}:deferred:{queue_name}"
|
|
131
122
|
TASK_KEY = "{prefix}:task:{task_id}"
|
|
132
123
|
RESULT_KEY = "{prefix}:result:{result_id}"
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
124
|
+
SEGMENT_KEY = "{prefix}:{queue_name}:{status}"
|
|
125
|
+
DEFERRED_KEY = "{prefix}:{queue_name}:deferred"
|
|
126
|
+
|
|
127
|
+
TELEMETRY_CHANNEL = "{prefix}:telemetry"
|
|
136
128
|
|
|
137
129
|
ACQUIRE_SCRIPT = _load_lua("acquire")
|
|
138
130
|
"""Pop the next task from a priority queue and move it directly to the running set."""
|
|
139
131
|
ACKNOWLEDGE_SCRIPT = _load_lua("acknowledge")
|
|
140
132
|
"""Remove from running, persist the result, and clean up."""
|
|
141
133
|
|
|
134
|
+
def _segment_key(self, status: TaskResultStatus, queue_name: str) -> str:
|
|
135
|
+
return self.SEGMENT_KEY.format(
|
|
136
|
+
prefix=self.key_prefix,
|
|
137
|
+
queue_name=queue_name,
|
|
138
|
+
status=status.value.lower(),
|
|
139
|
+
)
|
|
140
|
+
|
|
142
141
|
def __init__(self, alias: str, params: dict) -> None:
|
|
143
142
|
super().__init__(alias=alias, params=params)
|
|
144
143
|
|
|
@@ -149,13 +148,23 @@ class RedisTaskBackend(ThreadmillTaskBackend):
|
|
|
149
148
|
f"REDIS_URL must be specified in your settings for the {type(self).__name__}."
|
|
150
149
|
) from e
|
|
151
150
|
self.client = redis.from_url(redis_url)
|
|
151
|
+
self._async_client: redis.asyncio.Redis | None = None
|
|
152
|
+
self.redis_url = redis_url
|
|
152
153
|
self.key_prefix = f"threadmill:{{{alias}}}"
|
|
154
|
+
self.telemetry_channel = self.TELEMETRY_CHANNEL.format(prefix=self.key_prefix)
|
|
153
155
|
self.lease_ttl = self.options.get("lease_ttl", datetime.timedelta(hours=1))
|
|
154
156
|
self.result_ttl = self.options.get("result_ttl", datetime.timedelta(days=1))
|
|
155
157
|
self.batch_size = self.options.get("batch_size", 100)
|
|
156
158
|
self._acquire_script = self.client.register_script(self.ACQUIRE_SCRIPT)
|
|
157
159
|
self._acknowledge_script = self.client.register_script(self.ACKNOWLEDGE_SCRIPT)
|
|
158
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
|
+
|
|
159
168
|
def _compute_score(self, priority: int, enqueued_at: datetime.datetime) -> float:
|
|
160
169
|
"""Compute a ZSET score for priority-ordered FIFO queueing.
|
|
161
170
|
|
|
@@ -195,15 +204,11 @@ class RedisTaskBackend(ThreadmillTaskBackend):
|
|
|
195
204
|
)
|
|
196
205
|
|
|
197
206
|
score = self._compute_score(task.priority, enqueued_at)
|
|
198
|
-
enqueued_at_ms = enqueued_at.timestamp() * 1000
|
|
199
207
|
serialized = self.serialize_task_result(task_result)
|
|
200
208
|
task_key = self.TASK_KEY.format(prefix=self.key_prefix, task_id=task_result.id)
|
|
201
209
|
task_data_ttl = int(
|
|
202
210
|
self.lease_ttl.total_seconds() * 3 + self.result_ttl.total_seconds()
|
|
203
211
|
)
|
|
204
|
-
ingress_key = self.INGRESS_KEY.format(
|
|
205
|
-
prefix=self.key_prefix, queue_name=task.queue_name
|
|
206
|
-
)
|
|
207
212
|
|
|
208
213
|
pipe = self.client.pipeline()
|
|
209
214
|
pipe.hset(
|
|
@@ -215,7 +220,7 @@ class RedisTaskBackend(ThreadmillTaskBackend):
|
|
|
215
220
|
},
|
|
216
221
|
)
|
|
217
222
|
pipe.expire(task_key, task_data_ttl)
|
|
218
|
-
pipe.
|
|
223
|
+
pipe.publish(self.telemetry_channel, f"ingress:{task.queue_name}")
|
|
219
224
|
|
|
220
225
|
if task.run_after is not None:
|
|
221
226
|
deferred_key = self.DEFERRED_KEY.format(
|
|
@@ -224,9 +229,7 @@ class RedisTaskBackend(ThreadmillTaskBackend):
|
|
|
224
229
|
run_after_ms = task.run_after.timestamp() * 1000
|
|
225
230
|
pipe.zadd(deferred_key, {task_result.id: run_after_ms})
|
|
226
231
|
else:
|
|
227
|
-
queue_key = self.
|
|
228
|
-
prefix=self.key_prefix, queue_name=task.queue_name
|
|
229
|
-
)
|
|
232
|
+
queue_key = self._segment_key(TaskResultStatus.READY, task.queue_name)
|
|
230
233
|
pipe.zadd(queue_key, {task_result.id: score})
|
|
231
234
|
|
|
232
235
|
pipe.execute()
|
|
@@ -246,8 +249,8 @@ class RedisTaskBackend(ThreadmillTaskBackend):
|
|
|
246
249
|
key
|
|
247
250
|
for queue_name in queue_names
|
|
248
251
|
for key in (
|
|
249
|
-
self.
|
|
250
|
-
self.
|
|
252
|
+
self._segment_key(TaskResultStatus.RUNNING, queue_name),
|
|
253
|
+
self._segment_key(TaskResultStatus.READY, queue_name),
|
|
251
254
|
)
|
|
252
255
|
]
|
|
253
256
|
|
|
@@ -261,7 +264,7 @@ class RedisTaskBackend(ThreadmillTaskBackend):
|
|
|
261
264
|
args=[
|
|
262
265
|
str(now_ms),
|
|
263
266
|
now_iso,
|
|
264
|
-
self.key_prefix
|
|
267
|
+
f"{self.key_prefix}:task:",
|
|
265
268
|
str(len(queue_names)),
|
|
266
269
|
worker,
|
|
267
270
|
str(int(self.lease_ttl.total_seconds() * 1000)),
|
|
@@ -281,18 +284,18 @@ class RedisTaskBackend(ThreadmillTaskBackend):
|
|
|
281
284
|
|
|
282
285
|
def acknowledge(self, task_result: TaskResult) -> None:
|
|
283
286
|
serialized = self.serialize_task_result(task_result)
|
|
284
|
-
running_key = self.
|
|
285
|
-
|
|
287
|
+
running_key = self._segment_key(
|
|
288
|
+
TaskResultStatus.RUNNING, task_result.task.queue_name
|
|
286
289
|
)
|
|
287
290
|
result_key = self.RESULT_KEY.format(
|
|
288
291
|
prefix=self.key_prefix, result_id=task_result.id
|
|
289
292
|
)
|
|
290
293
|
task_key = self.TASK_KEY.format(prefix=self.key_prefix, task_id=task_result.id)
|
|
291
|
-
successful_results_key = self.
|
|
292
|
-
|
|
294
|
+
successful_results_key = self._segment_key(
|
|
295
|
+
TaskResultStatus.SUCCESSFUL, task_result.task.queue_name
|
|
293
296
|
)
|
|
294
|
-
failed_results_key = self.
|
|
295
|
-
|
|
297
|
+
failed_results_key = self._segment_key(
|
|
298
|
+
TaskResultStatus.FAILED, task_result.task.queue_name
|
|
296
299
|
)
|
|
297
300
|
finished_at = task_result.finished_at or timezone.now()
|
|
298
301
|
finish_score = finished_at.timestamp() * 1000
|
|
@@ -311,6 +314,8 @@ class RedisTaskBackend(ThreadmillTaskBackend):
|
|
|
311
314
|
str(int(self.result_ttl.total_seconds())),
|
|
312
315
|
str(finish_score),
|
|
313
316
|
task_result.status.name,
|
|
317
|
+
self.telemetry_channel,
|
|
318
|
+
task_result.task.queue_name,
|
|
314
319
|
],
|
|
315
320
|
)
|
|
316
321
|
|
|
@@ -322,13 +327,19 @@ class RedisTaskBackend(ThreadmillTaskBackend):
|
|
|
322
327
|
finished_at=None,
|
|
323
328
|
)
|
|
324
329
|
serialized = self.serialize_task_result(task_result)
|
|
325
|
-
running_key = self.
|
|
326
|
-
|
|
330
|
+
running_key = self._segment_key(
|
|
331
|
+
TaskResultStatus.RUNNING, task_result.task.queue_name
|
|
327
332
|
)
|
|
328
333
|
deferred_key = self.DEFERRED_KEY.format(
|
|
329
334
|
prefix=self.key_prefix, queue_name=task_result.task.queue_name
|
|
330
335
|
)
|
|
336
|
+
failed_key = self._segment_key(
|
|
337
|
+
TaskResultStatus.FAILED, task_result.task.queue_name
|
|
338
|
+
)
|
|
331
339
|
task_key = self.TASK_KEY.format(prefix=self.key_prefix, task_id=task_result.id)
|
|
340
|
+
result_key = self.RESULT_KEY.format(
|
|
341
|
+
prefix=self.key_prefix, result_id=task_result.id
|
|
342
|
+
)
|
|
332
343
|
score = self._compute_score(task_result.task.priority, task_result.enqueued_at)
|
|
333
344
|
run_after_ms = run_after.timestamp() * 1000
|
|
334
345
|
task_data_ttl = int(
|
|
@@ -337,9 +348,25 @@ class RedisTaskBackend(ThreadmillTaskBackend):
|
|
|
337
348
|
|
|
338
349
|
pipe = self.client.pipeline()
|
|
339
350
|
pipe.zrem(running_key, task_result.id)
|
|
351
|
+
pipe.zrem(failed_key, task_result.id)
|
|
352
|
+
pipe.delete(result_key)
|
|
340
353
|
pipe.hset(task_key, mapping={"data": serialized, "score": str(score)})
|
|
341
354
|
pipe.expire(task_key, task_data_ttl)
|
|
342
355
|
pipe.zadd(deferred_key, {task_result.id: run_after_ms})
|
|
356
|
+
pipe.publish(self.telemetry_channel, f"ingress:{task_result.task.queue_name}")
|
|
357
|
+
pipe.execute()
|
|
358
|
+
|
|
359
|
+
def dequeue(self, task_result: TaskResult) -> None:
|
|
360
|
+
self.client.zrem(
|
|
361
|
+
self._segment_key(task_result.status, task_result.task.queue_name),
|
|
362
|
+
task_result.id,
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
def purge(self, queue_name: str) -> None:
|
|
366
|
+
pattern = f"{self.key_prefix}:{queue_name}:*"
|
|
367
|
+
pipe = self.client.pipeline()
|
|
368
|
+
for key in self.client.scan_iter(match=pattern):
|
|
369
|
+
pipe.delete(key)
|
|
343
370
|
pipe.execute()
|
|
344
371
|
|
|
345
372
|
def peek(
|
|
@@ -350,34 +377,27 @@ class RedisTaskBackend(ThreadmillTaskBackend):
|
|
|
350
377
|
count: int = 1,
|
|
351
378
|
) -> Generator[TaskResult]:
|
|
352
379
|
match status:
|
|
353
|
-
case TaskResultStatus.READY:
|
|
354
|
-
yield from self._peek(
|
|
355
|
-
self.QUEUE_KEY, self.TASK_KEY, queue_name, count, "data"
|
|
356
|
-
)
|
|
357
|
-
case TaskResultStatus.RUNNING:
|
|
380
|
+
case TaskResultStatus.READY | TaskResultStatus.RUNNING:
|
|
358
381
|
yield from self._peek(
|
|
359
|
-
self.
|
|
382
|
+
self._segment_key(status, queue_name),
|
|
383
|
+
self.TASK_KEY,
|
|
384
|
+
count,
|
|
385
|
+
"data",
|
|
360
386
|
)
|
|
361
|
-
case TaskResultStatus.SUCCESSFUL:
|
|
387
|
+
case TaskResultStatus.SUCCESSFUL | TaskResultStatus.FAILED:
|
|
362
388
|
yield from self._peek(
|
|
363
|
-
self.
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
yield from self._peek(
|
|
367
|
-
self.FAILED_RESULTS_KEY, self.RESULT_KEY, queue_name, count
|
|
389
|
+
self._segment_key(status, queue_name),
|
|
390
|
+
self.RESULT_KEY,
|
|
391
|
+
count,
|
|
368
392
|
)
|
|
369
393
|
|
|
370
394
|
def _peek(
|
|
371
395
|
self,
|
|
372
|
-
|
|
396
|
+
zset_key: str,
|
|
373
397
|
data_key_template: str,
|
|
374
|
-
queue_name: str,
|
|
375
398
|
count: int,
|
|
376
399
|
field: str | None = None,
|
|
377
400
|
) -> Generator[TaskResult]:
|
|
378
|
-
zset_key = zset_key_template.format(
|
|
379
|
-
prefix=self.key_prefix, queue_name=queue_name
|
|
380
|
-
)
|
|
381
401
|
pipe = self.client.pipeline()
|
|
382
402
|
for member in self.client.zrange(zset_key, 0, count - 1):
|
|
383
403
|
member_id = member.decode() if isinstance(member, bytes) else member
|
|
@@ -403,94 +423,61 @@ class RedisTaskBackend(ThreadmillTaskBackend):
|
|
|
403
423
|
return self.deserialize_task_result(data)
|
|
404
424
|
raise TaskResultDoesNotExist(f"Task result {result_id!r} does not exist.")
|
|
405
425
|
|
|
406
|
-
def
|
|
407
|
-
cutoff = now_ms - self.result_ttl.total_seconds() * 1000
|
|
408
|
-
pipe = self.client.pipeline()
|
|
409
|
-
pipe.zremrangebyscore(
|
|
410
|
-
self.INGRESS_KEY.format(prefix=self.key_prefix, queue_name=queue_name),
|
|
411
|
-
0,
|
|
412
|
-
cutoff,
|
|
413
|
-
)
|
|
414
|
-
pipe.zremrangebyscore(
|
|
415
|
-
self.SUCCESSFUL_RESULTS_KEY.format(
|
|
416
|
-
prefix=self.key_prefix, queue_name=queue_name
|
|
417
|
-
),
|
|
418
|
-
0,
|
|
419
|
-
cutoff,
|
|
420
|
-
)
|
|
421
|
-
pipe.zremrangebyscore(
|
|
422
|
-
self.FAILED_RESULTS_KEY.format(
|
|
423
|
-
prefix=self.key_prefix, queue_name=queue_name
|
|
424
|
-
),
|
|
425
|
-
0,
|
|
426
|
-
cutoff,
|
|
427
|
-
)
|
|
428
|
-
pipe.execute()
|
|
429
|
-
|
|
430
|
-
def telemetry(
|
|
426
|
+
async def queue_stats(
|
|
431
427
|
self, *, interval: datetime.timedelta = datetime.timedelta(seconds=60)
|
|
432
428
|
) -> BackendTelemetry:
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
exclusive_start = f"({window_start_ms}"
|
|
436
|
-
retention_start_ms = now_ms - self.result_ttl.total_seconds() * 1000
|
|
437
|
-
pipe = self.client.pipeline()
|
|
429
|
+
client = self.async_client
|
|
430
|
+
pipe = client.pipeline()
|
|
438
431
|
for queue_name in self.queues:
|
|
439
|
-
pipe.zcard(
|
|
440
|
-
|
|
441
|
-
)
|
|
442
|
-
pipe.zcard(
|
|
443
|
-
self.RUNNING_KEY.format(prefix=self.key_prefix, queue_name=queue_name)
|
|
444
|
-
)
|
|
432
|
+
pipe.zcard(self._segment_key(TaskResultStatus.READY, queue_name))
|
|
433
|
+
pipe.zcard(self._segment_key(TaskResultStatus.RUNNING, queue_name))
|
|
445
434
|
pipe.zcard(
|
|
446
435
|
self.DEFERRED_KEY.format(prefix=self.key_prefix, queue_name=queue_name)
|
|
447
436
|
)
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
prefix=self.key_prefix, queue_name=queue_name
|
|
453
|
-
)
|
|
454
|
-
pipe.zcard(successful_results_key)
|
|
455
|
-
pipe.zcard(failed_results_key)
|
|
456
|
-
ingress_key = self.INGRESS_KEY.format(
|
|
457
|
-
prefix=self.key_prefix, queue_name=queue_name
|
|
458
|
-
)
|
|
459
|
-
pipe.zcount(ingress_key, exclusive_start, now_ms)
|
|
460
|
-
pipe.zcount(successful_results_key, exclusive_start, now_ms)
|
|
461
|
-
pipe.zcount(failed_results_key, exclusive_start, now_ms)
|
|
462
|
-
pipe.zremrangebyscore(ingress_key, 0, retention_start_ms)
|
|
463
|
-
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)
|
|
464
441
|
queues: dict[str, QueueStats] = {}
|
|
465
442
|
for index, queue_name in enumerate(self.queues):
|
|
466
|
-
base = index *
|
|
467
|
-
(
|
|
468
|
-
ready,
|
|
469
|
-
running,
|
|
470
|
-
deferred,
|
|
471
|
-
successful,
|
|
472
|
-
failed,
|
|
473
|
-
ingress,
|
|
474
|
-
successful_rate,
|
|
475
|
-
failed_rate,
|
|
476
|
-
_,
|
|
477
|
-
) = (int(c or 0) for c in results[base : base + 9])
|
|
443
|
+
base = index * 5
|
|
478
444
|
queues[queue_name] = QueueStats(
|
|
479
445
|
counts=QueueCounts(
|
|
480
|
-
ready=
|
|
481
|
-
running=
|
|
482
|
-
deferred=
|
|
483
|
-
successful=
|
|
484
|
-
failed=
|
|
485
|
-
),
|
|
486
|
-
rates=QueueRates(
|
|
487
|
-
interval=interval,
|
|
488
|
-
ingress=ingress,
|
|
489
|
-
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),
|
|
490
451
|
),
|
|
452
|
+
rates=zero_rates,
|
|
491
453
|
)
|
|
492
454
|
return BackendTelemetry(queues=queues)
|
|
493
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
|
+
|
|
494
480
|
def close(self) -> None:
|
|
495
|
-
"""Close the Redis
|
|
481
|
+
"""Close the Redis connections."""
|
|
496
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,17 +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
|
+
)
|
|
42
|
+
from .screens import ConfirmScreen, PurgeScreen
|
|
43
|
+
from .telemetry import TelemetryBuffer
|
|
33
44
|
|
|
34
45
|
logger = logging.getLogger(__name__)
|
|
35
46
|
|
|
36
|
-
|
|
37
|
-
"""
|
|
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."""
|
|
38
52
|
|
|
39
53
|
TAB_STATUSES: list[tuple[str, TaskResultStatus | None]] = [
|
|
40
54
|
("Running", TaskResultStatus.RUNNING),
|
|
@@ -163,6 +177,21 @@ class TaskList(Vertical):
|
|
|
163
177
|
counts: reactive[dict[str, int]] = reactive({})
|
|
164
178
|
selected_task: reactive[TaskResult | None] = reactive(None)
|
|
165
179
|
|
|
180
|
+
BINDINGS = [
|
|
181
|
+
Binding("f5", "refresh", "Refresh"),
|
|
182
|
+
Binding("r", "requeue", "Requeue"),
|
|
183
|
+
Binding("d", "dequeue", "Drop"),
|
|
184
|
+
]
|
|
185
|
+
|
|
186
|
+
def check_action(self, action: str, parameters: tuple[object, ...]) -> bool | None:
|
|
187
|
+
"""Show requeue only on the Failed tab, dequeue on non-Running tabs."""
|
|
188
|
+
status = self.active_status()
|
|
189
|
+
if action == "requeue":
|
|
190
|
+
return status == TaskResultStatus.FAILED
|
|
191
|
+
if action == "dequeue":
|
|
192
|
+
return status is not None and status != TaskResultStatus.RUNNING
|
|
193
|
+
return True
|
|
194
|
+
|
|
166
195
|
def compose(self) -> ComposeResult:
|
|
167
196
|
with TabbedContent(initial="tab-ready") as tabs:
|
|
168
197
|
for label, _ in TAB_STATUSES:
|
|
@@ -212,6 +241,11 @@ class TaskList(Vertical):
|
|
|
212
241
|
"""Activate the tab with the given id."""
|
|
213
242
|
self._tabs.active = tab_id
|
|
214
243
|
|
|
244
|
+
def action_refresh(self) -> None:
|
|
245
|
+
"""Refresh telemetry and re-fetch the task list."""
|
|
246
|
+
self.app._poll_queue_stats()
|
|
247
|
+
self.refresh_tasks()
|
|
248
|
+
|
|
215
249
|
def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
|
|
216
250
|
"""Notify the app when a task row is selected."""
|
|
217
251
|
if isinstance(event.row_key.value, str):
|
|
@@ -227,6 +261,7 @@ class TaskList(Vertical):
|
|
|
227
261
|
) -> None:
|
|
228
262
|
"""Refresh the visible table when the user switches tabs."""
|
|
229
263
|
self._refresh_data()
|
|
264
|
+
self.refresh_bindings()
|
|
230
265
|
|
|
231
266
|
def _select_task_by_id(self, task_id: str) -> None:
|
|
232
267
|
"""Find the task result matching the row key in the current results."""
|
|
@@ -235,6 +270,55 @@ class TaskList(Vertical):
|
|
|
235
270
|
None,
|
|
236
271
|
)
|
|
237
272
|
|
|
273
|
+
def active_status(self) -> TaskResultStatus | None:
|
|
274
|
+
"""Return the status segment of the currently active task tab."""
|
|
275
|
+
tab_id = self._tabs.active.removeprefix("tab-")
|
|
276
|
+
return next(
|
|
277
|
+
(status for label, status in TAB_STATUSES if label.lower() == tab_id),
|
|
278
|
+
None,
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
def action_requeue(self) -> None:
|
|
282
|
+
"""Requeue the selected failed task after confirmation."""
|
|
283
|
+
task = self.selected_task
|
|
284
|
+
if task and self.active_status() == TaskResultStatus.FAILED:
|
|
285
|
+
self.app.push_screen(
|
|
286
|
+
ConfirmScreen(f"Requeue task {task.id[:8]}?"),
|
|
287
|
+
lambda confirmed: self._do_requeue(confirmed, task),
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
def _do_requeue(self, confirmed: bool, task: TaskResult) -> None:
|
|
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.")
|
|
299
|
+
|
|
300
|
+
def action_dequeue(self) -> None:
|
|
301
|
+
"""Remove the selected task from its current segment after confirmation."""
|
|
302
|
+
task = self.selected_task
|
|
303
|
+
if task and self.active_status() is not None:
|
|
304
|
+
self.app.push_screen(
|
|
305
|
+
ConfirmScreen(
|
|
306
|
+
f"Delete task {task.task.func.__name__} ({task.id[:8]})?",
|
|
307
|
+
danger=True,
|
|
308
|
+
),
|
|
309
|
+
lambda confirmed: self._do_dequeue(confirmed, task),
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
def _do_dequeue(self, confirmed: bool, task: TaskResult) -> None:
|
|
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.")
|
|
321
|
+
|
|
238
322
|
def _refresh_data(self) -> None:
|
|
239
323
|
"""Fetch and display tasks for the current queue and active tab."""
|
|
240
324
|
backend = self.backend
|
|
@@ -291,6 +375,10 @@ class QueueList(ListView):
|
|
|
291
375
|
|
|
292
376
|
telemetry: reactive[BackendTelemetry | None] = reactive(None)
|
|
293
377
|
|
|
378
|
+
BINDINGS = [
|
|
379
|
+
Binding("p", "purge", "Purge"),
|
|
380
|
+
]
|
|
381
|
+
|
|
294
382
|
def __init__(self, **kwargs: Any) -> None:
|
|
295
383
|
super().__init__(**kwargs)
|
|
296
384
|
self._items: dict[str, QueueItem] = {}
|
|
@@ -300,12 +388,10 @@ class QueueList(ListView):
|
|
|
300
388
|
self.border_title = "Queues"
|
|
301
389
|
|
|
302
390
|
def watch_telemetry(self, telemetry: BackendTelemetry | None) -> None:
|
|
303
|
-
"""Refresh queue labels when a new telemetry snapshot arrives."""
|
|
304
391
|
if telemetry is not None:
|
|
305
392
|
self.update_telemetry(telemetry)
|
|
306
393
|
|
|
307
394
|
def update_telemetry(self, telemetry: BackendTelemetry) -> None:
|
|
308
|
-
"""Refresh queue labels from a new telemetry snapshot."""
|
|
309
395
|
queues = telemetry.queues
|
|
310
396
|
for queue_name, stats in sorted(queues.items()):
|
|
311
397
|
theme = BUILTIN_THEMES[self.app.theme]
|
|
@@ -334,21 +420,38 @@ class QueueList(ListView):
|
|
|
334
420
|
self._notify_selection(target)
|
|
335
421
|
|
|
336
422
|
def _notify_selection(self, queue_name: str) -> None:
|
|
337
|
-
"""Tell the app which queue to display."""
|
|
338
423
|
self.app._task_list.queue_name = queue_name
|
|
339
424
|
|
|
425
|
+
def action_purge(self) -> None:
|
|
426
|
+
item = self.highlighted_child
|
|
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
|
+
)
|
|
433
|
+
|
|
434
|
+
def _do_purge(self, confirmed: bool, queue_name: str) -> None:
|
|
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}.")
|
|
443
|
+
|
|
340
444
|
|
|
341
445
|
class InspectorApp(App):
|
|
342
446
|
"""Threadmill TUI inspector with backend/queue/task panes."""
|
|
343
447
|
|
|
344
448
|
CSS_PATH = "inspector.scss"
|
|
345
449
|
BINDINGS = [
|
|
346
|
-
Binding("q", "quit", "Quit"),
|
|
347
|
-
Binding("f5", "refresh", "Refresh"),
|
|
348
450
|
*(
|
|
349
451
|
Binding(key, f"switch_tab('tab-{tab_id}')", tab_id.capitalize())
|
|
350
452
|
for tab_id, key in TAB_KEYS.items()
|
|
351
453
|
),
|
|
454
|
+
Binding("q", "quit", "Quit"),
|
|
352
455
|
]
|
|
353
456
|
|
|
354
457
|
backend: reactive[ThreadmillTaskBackend] = reactive(None)
|
|
@@ -365,8 +468,10 @@ class InspectorApp(App):
|
|
|
365
468
|
self._task_list: TaskList | None = None
|
|
366
469
|
self._task_detail: TaskDetail | None = None
|
|
367
470
|
self._options_static: Static | None = None
|
|
471
|
+
self._sparkline_timer = None
|
|
368
472
|
self._telemetry_timer = None
|
|
369
473
|
self._auto_refresh = auto_refresh
|
|
474
|
+
self.telemetry_buffer = TelemetryBuffer()
|
|
370
475
|
self.set_reactive(InspectorApp.backend, backend)
|
|
371
476
|
|
|
372
477
|
def compose(self) -> ComposeResult:
|
|
@@ -387,6 +492,9 @@ class InspectorApp(App):
|
|
|
387
492
|
telemetry=InspectorApp.telemetry
|
|
388
493
|
)
|
|
389
494
|
with Vertical(id="right-pane"):
|
|
495
|
+
with Horizontal(id="sparkline-row"):
|
|
496
|
+
yield Sparkline(id="ingress-sparkline")
|
|
497
|
+
yield Sparkline(id="egress-sparkline")
|
|
390
498
|
yield TaskList(id="task-list").data_bind(
|
|
391
499
|
backend=InspectorApp.backend,
|
|
392
500
|
telemetry=InspectorApp.telemetry,
|
|
@@ -402,22 +510,31 @@ class InspectorApp(App):
|
|
|
402
510
|
self._task_detail = self.query_one("#task-detail", TaskDetail)
|
|
403
511
|
self._options_static = self.query_one("#backend-options", Static)
|
|
404
512
|
self._refresh_options()
|
|
405
|
-
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()
|
|
406
517
|
if self._auto_refresh:
|
|
407
518
|
self._telemetry_timer = self.set_interval(
|
|
408
|
-
|
|
409
|
-
self.
|
|
519
|
+
TELEMETRY_INTERVAL.total_seconds(),
|
|
520
|
+
self._poll_queue_stats,
|
|
410
521
|
name="telemetry-refresh",
|
|
411
522
|
)
|
|
523
|
+
self._sparkline_timer = self.set_interval(
|
|
524
|
+
SPARKLINE_INTERVAL.total_seconds(),
|
|
525
|
+
self._refresh_sparklines,
|
|
526
|
+
name="sparkline-refresh",
|
|
527
|
+
)
|
|
412
528
|
self._queue_list.focus()
|
|
413
529
|
|
|
414
530
|
def action_quit(self) -> None:
|
|
415
531
|
"""Exit the TUI."""
|
|
416
532
|
self.exit()
|
|
417
533
|
|
|
418
|
-
def
|
|
419
|
-
"""Refresh
|
|
420
|
-
self.
|
|
534
|
+
def _after_action(self, message: str) -> None:
|
|
535
|
+
"""Refresh telemetry and tasks after a mutating queue action."""
|
|
536
|
+
self.notify(message)
|
|
537
|
+
self._poll_queue_stats()
|
|
421
538
|
self._task_list.refresh_tasks()
|
|
422
539
|
|
|
423
540
|
def action_switch_tab(self, tab_id: str) -> None:
|
|
@@ -426,7 +543,9 @@ class InspectorApp(App):
|
|
|
426
543
|
|
|
427
544
|
def watch_backend(self) -> None:
|
|
428
545
|
self._refresh_options()
|
|
429
|
-
self.
|
|
546
|
+
self.telemetry_buffer = TelemetryBuffer()
|
|
547
|
+
self._poll_queue_stats()
|
|
548
|
+
self._listen_telemetry()
|
|
430
549
|
|
|
431
550
|
def on_select_changed(self, event: Select.Changed) -> None:
|
|
432
551
|
"""Switch backend when the user selects a different alias."""
|
|
@@ -453,9 +572,53 @@ class InspectorApp(App):
|
|
|
453
572
|
]
|
|
454
573
|
self._options_static.update(" ".join(parts) or "No options")
|
|
455
574
|
|
|
456
|
-
|
|
457
|
-
|
|
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."""
|
|
458
578
|
try:
|
|
459
|
-
|
|
579
|
+
telemetry = await self.backend.queue_stats()
|
|
460
580
|
except Exception: # noqa: BLE001
|
|
461
|
-
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
|
|
@@ -2,6 +2,11 @@ Screen {
|
|
|
2
2
|
background: $background;
|
|
3
3
|
}
|
|
4
4
|
|
|
5
|
+
ConfirmScreen,
|
|
6
|
+
PurgeScreen {
|
|
7
|
+
align: center middle;
|
|
8
|
+
}
|
|
9
|
+
|
|
5
10
|
* {
|
|
6
11
|
scrollbar-color: $primary 10%;
|
|
7
12
|
scrollbar-color-hover: $primary 80%;
|
|
@@ -37,6 +42,29 @@ Select {
|
|
|
37
42
|
width: 42;
|
|
38
43
|
}
|
|
39
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
|
+
|
|
40
68
|
#backend-row {
|
|
41
69
|
height: 3;
|
|
42
70
|
}
|
|
@@ -71,7 +99,7 @@ QueueList ListItem {
|
|
|
71
99
|
}
|
|
72
100
|
|
|
73
101
|
TaskList {
|
|
74
|
-
height:
|
|
102
|
+
height: 1fr;
|
|
75
103
|
|
|
76
104
|
TabbedContent,
|
|
77
105
|
ContentSwitcher,
|
|
@@ -99,8 +127,34 @@ DataTable {
|
|
|
99
127
|
}
|
|
100
128
|
|
|
101
129
|
TaskDetail {
|
|
102
|
-
height:
|
|
130
|
+
height: 1fr;
|
|
103
131
|
width: 100%;
|
|
104
132
|
overflow: scroll;
|
|
105
133
|
padding: 0 1;
|
|
106
134
|
}
|
|
135
|
+
|
|
136
|
+
#dialog {
|
|
137
|
+
width: 60;
|
|
138
|
+
height: auto;
|
|
139
|
+
background: $surface;
|
|
140
|
+
padding: 1 2;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
#dialog.warning {
|
|
144
|
+
border: thick $warning;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
#dialog.danger {
|
|
148
|
+
border: thick $error;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
#prompt {
|
|
152
|
+
text-align: center;
|
|
153
|
+
margin-bottom: 1;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
#instruction {
|
|
157
|
+
text-align: center;
|
|
158
|
+
color: $text-muted;
|
|
159
|
+
margin-top: 1;
|
|
160
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Modal confirmation screens for inspector queue actions."""
|
|
2
|
+
|
|
3
|
+
from textual.app import ComposeResult
|
|
4
|
+
from textual.containers import Vertical
|
|
5
|
+
from textual.events import Key
|
|
6
|
+
from textual.screen import ModalScreen
|
|
7
|
+
from textual.widgets import Input, Label
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ConfirmScreen(ModalScreen[bool]):
|
|
11
|
+
"""Yes/no confirmation: Enter confirms, Esc cancels."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, prompt: str, *, danger: bool = False) -> None:
|
|
14
|
+
super().__init__()
|
|
15
|
+
self._prompt = prompt
|
|
16
|
+
self._danger = danger
|
|
17
|
+
|
|
18
|
+
def compose(self) -> ComposeResult:
|
|
19
|
+
with Vertical(id="dialog", classes="danger" if self._danger else "warning"):
|
|
20
|
+
yield Label(self._prompt, id="prompt")
|
|
21
|
+
yield Label("Press Enter to confirm, Esc to cancel.", id="instruction")
|
|
22
|
+
|
|
23
|
+
def on_key(self, event: Key) -> None:
|
|
24
|
+
match event.key:
|
|
25
|
+
case "enter":
|
|
26
|
+
self.dismiss(True)
|
|
27
|
+
case "escape":
|
|
28
|
+
self.dismiss(False)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class PurgeScreen(ModalScreen[bool]):
|
|
32
|
+
"""Type the queue name to confirm a purge."""
|
|
33
|
+
|
|
34
|
+
def __init__(self, queue_name: str) -> None:
|
|
35
|
+
super().__init__()
|
|
36
|
+
self._queue_name = queue_name
|
|
37
|
+
|
|
38
|
+
def compose(self) -> ComposeResult:
|
|
39
|
+
with Vertical(id="dialog", classes="danger"):
|
|
40
|
+
yield Label(f"Purge queue [bold]{self._queue_name}[/bold]?", id="prompt")
|
|
41
|
+
yield Label(
|
|
42
|
+
"Type the queue name to confirm, Esc to cancel.", id="instruction"
|
|
43
|
+
)
|
|
44
|
+
yield Input(placeholder=self._queue_name, id="confirm-input")
|
|
45
|
+
|
|
46
|
+
def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
47
|
+
if event.value == self._queue_name:
|
|
48
|
+
self.dismiss(True)
|
|
49
|
+
|
|
50
|
+
def on_key(self, event: Key) -> None:
|
|
51
|
+
if event.key == "escape":
|
|
52
|
+
self.dismiss(False)
|
|
@@ -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)
|
|
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
|