threadmill 0.4.0__tar.gz → 0.5.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.4.0 → threadmill-0.5.0}/PKG-INFO +1 -1
- {threadmill-0.4.0 → threadmill-0.5.0}/threadmill/_version.py +3 -3
- {threadmill-0.4.0 → threadmill-0.5.0}/threadmill/backends/base.py +18 -1
- {threadmill-0.4.0 → threadmill-0.5.0}/threadmill/backends/lua/acquire.lua +1 -1
- {threadmill-0.4.0 → threadmill-0.5.0}/threadmill/backends/redis.py +68 -65
- {threadmill-0.4.0 → threadmill-0.5.0}/threadmill/inspector/app.py +102 -7
- {threadmill-0.4.0 → threadmill-0.5.0}/threadmill/inspector/inspector.scss +31 -0
- threadmill-0.5.0/threadmill/inspector/screens.py +52 -0
- {threadmill-0.4.0 → threadmill-0.5.0}/LICENSE +0 -0
- {threadmill-0.4.0 → threadmill-0.5.0}/README.md +0 -0
- {threadmill-0.4.0 → threadmill-0.5.0}/pyproject.toml +0 -0
- {threadmill-0.4.0 → threadmill-0.5.0}/threadmill/__init__.py +0 -0
- {threadmill-0.4.0 → threadmill-0.5.0}/threadmill/backends/__init__.py +0 -0
- {threadmill-0.4.0 → threadmill-0.5.0}/threadmill/backends/lua/acknowledge.lua +0 -0
- {threadmill-0.4.0 → threadmill-0.5.0}/threadmill/backends/lua/mover.lua +0 -0
- {threadmill-0.4.0 → threadmill-0.5.0}/threadmill/backends/lua/reaper.lua +0 -0
- {threadmill-0.4.0 → threadmill-0.5.0}/threadmill/exceptions.py +0 -0
- {threadmill-0.4.0 → threadmill-0.5.0}/threadmill/executor.py +0 -0
- {threadmill-0.4.0 → threadmill-0.5.0}/threadmill/inspector/__init__.py +0 -0
- {threadmill-0.4.0 → threadmill-0.5.0}/threadmill/management/__init__.py +0 -0
- {threadmill-0.4.0 → threadmill-0.5.0}/threadmill/management/commands/__init__.py +0 -0
- {threadmill-0.4.0 → threadmill-0.5.0}/threadmill/management/commands/threadmill.py +0 -0
- {threadmill-0.4.0 → threadmill-0.5.0}/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.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 5, 0)
|
|
23
23
|
|
|
24
|
-
__commit_id__ = commit_id = '
|
|
24
|
+
__commit_id__ = commit_id = 'g190acbe27'
|
|
@@ -217,7 +217,12 @@ class ThreadmillTaskBackend(BaseTaskBackend, ABC):
|
|
|
217
217
|
raise NotImplementedError
|
|
218
218
|
|
|
219
219
|
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`.
|
|
220
|
+
"""Re-queue a failed task result for a retry attempt after `run_after`.
|
|
221
|
+
|
|
222
|
+
Cleans up any persisted failed result so the method works both for
|
|
223
|
+
in-flight retries (task still running) and inspector-driven requeues
|
|
224
|
+
of already-failed tasks.
|
|
225
|
+
"""
|
|
221
226
|
raise NotImplementedError
|
|
222
227
|
|
|
223
228
|
def peek(
|
|
@@ -246,3 +251,15 @@ class ThreadmillTaskBackend(BaseTaskBackend, ABC):
|
|
|
246
251
|
interval: The time window for rolling rates.
|
|
247
252
|
"""
|
|
248
253
|
raise NotImplementedError
|
|
254
|
+
|
|
255
|
+
def dequeue(self, task_result: TaskResult) -> None:
|
|
256
|
+
"""Delete a single task from its current status segment."""
|
|
257
|
+
raise NotImplementedError
|
|
258
|
+
|
|
259
|
+
def purge(self, queue_name: str) -> None:
|
|
260
|
+
"""Delete every task across all segments of a queue.
|
|
261
|
+
|
|
262
|
+
Args:
|
|
263
|
+
queue_name: The queue to purge.
|
|
264
|
+
"""
|
|
265
|
+
raise NotImplementedError
|
|
@@ -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
|
|
@@ -55,14 +55,12 @@ class RedisBroker(Broker):
|
|
|
55
55
|
deferred_key = self.backend.DEFERRED_KEY.format(
|
|
56
56
|
prefix=self.backend.key_prefix, queue_name=queue_name
|
|
57
57
|
)
|
|
58
|
-
queue_key = self.backend.
|
|
59
|
-
prefix=self.backend.key_prefix, queue_name=queue_name
|
|
60
|
-
)
|
|
58
|
+
queue_key = self.backend._segment_key(TaskResultStatus.READY, queue_name)
|
|
61
59
|
self._mover_script(
|
|
62
60
|
keys=[deferred_key, queue_key],
|
|
63
61
|
args=[
|
|
64
62
|
str(time.time() * 1000),
|
|
65
|
-
self.backend.key_prefix
|
|
63
|
+
f"{self.backend.key_prefix}:task:",
|
|
66
64
|
str(self.backend.batch_size),
|
|
67
65
|
],
|
|
68
66
|
)
|
|
@@ -72,18 +70,16 @@ class RedisBroker(Broker):
|
|
|
72
70
|
now = timezone.now()
|
|
73
71
|
now_ms = now.timestamp() * 1000
|
|
74
72
|
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
|
|
73
|
+
running_key = self.backend._segment_key(TaskResultStatus.RUNNING, queue_name)
|
|
74
|
+
failed_results_key = self.backend._segment_key(
|
|
75
|
+
TaskResultStatus.FAILED, queue_name
|
|
80
76
|
)
|
|
81
77
|
self._reaper_script(
|
|
82
78
|
keys=[running_key, failed_results_key],
|
|
83
79
|
args=[
|
|
84
80
|
str(now_ms),
|
|
85
|
-
self.backend.key_prefix
|
|
86
|
-
self.backend.key_prefix
|
|
81
|
+
f"{self.backend.key_prefix}:task:",
|
|
82
|
+
f"{self.backend.key_prefix}:result:",
|
|
87
83
|
str(self.backend.batch_size),
|
|
88
84
|
str(int(self.backend.result_ttl.total_seconds())),
|
|
89
85
|
finished_at_iso,
|
|
@@ -125,20 +121,25 @@ class RedisTaskBackend(ThreadmillTaskBackend):
|
|
|
125
121
|
|
|
126
122
|
broker_class = RedisBroker
|
|
127
123
|
|
|
128
|
-
QUEUE_KEY = "{prefix}:queue:{queue_name}"
|
|
129
|
-
RUNNING_KEY = "{prefix}:running:{queue_name}"
|
|
130
|
-
DEFERRED_KEY = "{prefix}:deferred:{queue_name}"
|
|
131
124
|
TASK_KEY = "{prefix}:task:{task_id}"
|
|
132
125
|
RESULT_KEY = "{prefix}:result:{result_id}"
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
126
|
+
SEGMENT_KEY = "{prefix}:{queue_name}:{status}"
|
|
127
|
+
DEFERRED_KEY = "{prefix}:{queue_name}:deferred"
|
|
128
|
+
|
|
129
|
+
INGRESS_KEY = "{prefix}:{queue_name}:ingress:events"
|
|
136
130
|
|
|
137
131
|
ACQUIRE_SCRIPT = _load_lua("acquire")
|
|
138
132
|
"""Pop the next task from a priority queue and move it directly to the running set."""
|
|
139
133
|
ACKNOWLEDGE_SCRIPT = _load_lua("acknowledge")
|
|
140
134
|
"""Remove from running, persist the result, and clean up."""
|
|
141
135
|
|
|
136
|
+
def _segment_key(self, status: TaskResultStatus, queue_name: str) -> str:
|
|
137
|
+
return self.SEGMENT_KEY.format(
|
|
138
|
+
prefix=self.key_prefix,
|
|
139
|
+
queue_name=queue_name,
|
|
140
|
+
status=status.value.lower(),
|
|
141
|
+
)
|
|
142
|
+
|
|
142
143
|
def __init__(self, alias: str, params: dict) -> None:
|
|
143
144
|
super().__init__(alias=alias, params=params)
|
|
144
145
|
|
|
@@ -224,9 +225,7 @@ class RedisTaskBackend(ThreadmillTaskBackend):
|
|
|
224
225
|
run_after_ms = task.run_after.timestamp() * 1000
|
|
225
226
|
pipe.zadd(deferred_key, {task_result.id: run_after_ms})
|
|
226
227
|
else:
|
|
227
|
-
queue_key = self.
|
|
228
|
-
prefix=self.key_prefix, queue_name=task.queue_name
|
|
229
|
-
)
|
|
228
|
+
queue_key = self._segment_key(TaskResultStatus.READY, task.queue_name)
|
|
230
229
|
pipe.zadd(queue_key, {task_result.id: score})
|
|
231
230
|
|
|
232
231
|
pipe.execute()
|
|
@@ -246,8 +245,8 @@ class RedisTaskBackend(ThreadmillTaskBackend):
|
|
|
246
245
|
key
|
|
247
246
|
for queue_name in queue_names
|
|
248
247
|
for key in (
|
|
249
|
-
self.
|
|
250
|
-
self.
|
|
248
|
+
self._segment_key(TaskResultStatus.RUNNING, queue_name),
|
|
249
|
+
self._segment_key(TaskResultStatus.READY, queue_name),
|
|
251
250
|
)
|
|
252
251
|
]
|
|
253
252
|
|
|
@@ -261,7 +260,7 @@ class RedisTaskBackend(ThreadmillTaskBackend):
|
|
|
261
260
|
args=[
|
|
262
261
|
str(now_ms),
|
|
263
262
|
now_iso,
|
|
264
|
-
self.key_prefix
|
|
263
|
+
f"{self.key_prefix}:task:",
|
|
265
264
|
str(len(queue_names)),
|
|
266
265
|
worker,
|
|
267
266
|
str(int(self.lease_ttl.total_seconds() * 1000)),
|
|
@@ -281,18 +280,18 @@ class RedisTaskBackend(ThreadmillTaskBackend):
|
|
|
281
280
|
|
|
282
281
|
def acknowledge(self, task_result: TaskResult) -> None:
|
|
283
282
|
serialized = self.serialize_task_result(task_result)
|
|
284
|
-
running_key = self.
|
|
285
|
-
|
|
283
|
+
running_key = self._segment_key(
|
|
284
|
+
TaskResultStatus.RUNNING, task_result.task.queue_name
|
|
286
285
|
)
|
|
287
286
|
result_key = self.RESULT_KEY.format(
|
|
288
287
|
prefix=self.key_prefix, result_id=task_result.id
|
|
289
288
|
)
|
|
290
289
|
task_key = self.TASK_KEY.format(prefix=self.key_prefix, task_id=task_result.id)
|
|
291
|
-
successful_results_key = self.
|
|
292
|
-
|
|
290
|
+
successful_results_key = self._segment_key(
|
|
291
|
+
TaskResultStatus.SUCCESSFUL, task_result.task.queue_name
|
|
293
292
|
)
|
|
294
|
-
failed_results_key = self.
|
|
295
|
-
|
|
293
|
+
failed_results_key = self._segment_key(
|
|
294
|
+
TaskResultStatus.FAILED, task_result.task.queue_name
|
|
296
295
|
)
|
|
297
296
|
finished_at = task_result.finished_at or timezone.now()
|
|
298
297
|
finish_score = finished_at.timestamp() * 1000
|
|
@@ -322,13 +321,19 @@ class RedisTaskBackend(ThreadmillTaskBackend):
|
|
|
322
321
|
finished_at=None,
|
|
323
322
|
)
|
|
324
323
|
serialized = self.serialize_task_result(task_result)
|
|
325
|
-
running_key = self.
|
|
326
|
-
|
|
324
|
+
running_key = self._segment_key(
|
|
325
|
+
TaskResultStatus.RUNNING, task_result.task.queue_name
|
|
327
326
|
)
|
|
328
327
|
deferred_key = self.DEFERRED_KEY.format(
|
|
329
328
|
prefix=self.key_prefix, queue_name=task_result.task.queue_name
|
|
330
329
|
)
|
|
330
|
+
failed_key = self._segment_key(
|
|
331
|
+
TaskResultStatus.FAILED, task_result.task.queue_name
|
|
332
|
+
)
|
|
331
333
|
task_key = self.TASK_KEY.format(prefix=self.key_prefix, task_id=task_result.id)
|
|
334
|
+
result_key = self.RESULT_KEY.format(
|
|
335
|
+
prefix=self.key_prefix, result_id=task_result.id
|
|
336
|
+
)
|
|
332
337
|
score = self._compute_score(task_result.task.priority, task_result.enqueued_at)
|
|
333
338
|
run_after_ms = run_after.timestamp() * 1000
|
|
334
339
|
task_data_ttl = int(
|
|
@@ -337,11 +342,26 @@ class RedisTaskBackend(ThreadmillTaskBackend):
|
|
|
337
342
|
|
|
338
343
|
pipe = self.client.pipeline()
|
|
339
344
|
pipe.zrem(running_key, task_result.id)
|
|
345
|
+
pipe.zrem(failed_key, task_result.id)
|
|
346
|
+
pipe.delete(result_key)
|
|
340
347
|
pipe.hset(task_key, mapping={"data": serialized, "score": str(score)})
|
|
341
348
|
pipe.expire(task_key, task_data_ttl)
|
|
342
349
|
pipe.zadd(deferred_key, {task_result.id: run_after_ms})
|
|
343
350
|
pipe.execute()
|
|
344
351
|
|
|
352
|
+
def dequeue(self, task_result: TaskResult) -> None:
|
|
353
|
+
self.client.zrem(
|
|
354
|
+
self._segment_key(task_result.status, task_result.task.queue_name),
|
|
355
|
+
task_result.id,
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
def purge(self, queue_name: str) -> None:
|
|
359
|
+
pattern = f"{self.key_prefix}:{queue_name}:*"
|
|
360
|
+
pipe = self.client.pipeline()
|
|
361
|
+
for key in self.client.scan_iter(match=pattern):
|
|
362
|
+
pipe.delete(key)
|
|
363
|
+
pipe.execute()
|
|
364
|
+
|
|
345
365
|
def peek(
|
|
346
366
|
self,
|
|
347
367
|
queue_name: str = DEFAULT_TASK_QUEUE_NAME,
|
|
@@ -350,34 +370,27 @@ class RedisTaskBackend(ThreadmillTaskBackend):
|
|
|
350
370
|
count: int = 1,
|
|
351
371
|
) -> Generator[TaskResult]:
|
|
352
372
|
match status:
|
|
353
|
-
case TaskResultStatus.READY:
|
|
373
|
+
case TaskResultStatus.READY | TaskResultStatus.RUNNING:
|
|
354
374
|
yield from self._peek(
|
|
355
|
-
self.
|
|
375
|
+
self._segment_key(status, queue_name),
|
|
376
|
+
self.TASK_KEY,
|
|
377
|
+
count,
|
|
378
|
+
"data",
|
|
356
379
|
)
|
|
357
|
-
case TaskResultStatus.
|
|
380
|
+
case TaskResultStatus.SUCCESSFUL | TaskResultStatus.FAILED:
|
|
358
381
|
yield from self._peek(
|
|
359
|
-
self.
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
yield from self._peek(
|
|
363
|
-
self.SUCCESSFUL_RESULTS_KEY, self.RESULT_KEY, queue_name, count
|
|
364
|
-
)
|
|
365
|
-
case TaskResultStatus.FAILED:
|
|
366
|
-
yield from self._peek(
|
|
367
|
-
self.FAILED_RESULTS_KEY, self.RESULT_KEY, queue_name, count
|
|
382
|
+
self._segment_key(status, queue_name),
|
|
383
|
+
self.RESULT_KEY,
|
|
384
|
+
count,
|
|
368
385
|
)
|
|
369
386
|
|
|
370
387
|
def _peek(
|
|
371
388
|
self,
|
|
372
|
-
|
|
389
|
+
zset_key: str,
|
|
373
390
|
data_key_template: str,
|
|
374
|
-
queue_name: str,
|
|
375
391
|
count: int,
|
|
376
392
|
field: str | None = None,
|
|
377
393
|
) -> Generator[TaskResult]:
|
|
378
|
-
zset_key = zset_key_template.format(
|
|
379
|
-
prefix=self.key_prefix, queue_name=queue_name
|
|
380
|
-
)
|
|
381
394
|
pipe = self.client.pipeline()
|
|
382
395
|
for member in self.client.zrange(zset_key, 0, count - 1):
|
|
383
396
|
member_id = member.decode() if isinstance(member, bytes) else member
|
|
@@ -412,16 +425,12 @@ class RedisTaskBackend(ThreadmillTaskBackend):
|
|
|
412
425
|
cutoff,
|
|
413
426
|
)
|
|
414
427
|
pipe.zremrangebyscore(
|
|
415
|
-
self.
|
|
416
|
-
prefix=self.key_prefix, queue_name=queue_name
|
|
417
|
-
),
|
|
428
|
+
self._segment_key(TaskResultStatus.SUCCESSFUL, queue_name),
|
|
418
429
|
0,
|
|
419
430
|
cutoff,
|
|
420
431
|
)
|
|
421
432
|
pipe.zremrangebyscore(
|
|
422
|
-
self.
|
|
423
|
-
prefix=self.key_prefix, queue_name=queue_name
|
|
424
|
-
),
|
|
433
|
+
self._segment_key(TaskResultStatus.FAILED, queue_name),
|
|
425
434
|
0,
|
|
426
435
|
cutoff,
|
|
427
436
|
)
|
|
@@ -436,21 +445,15 @@ class RedisTaskBackend(ThreadmillTaskBackend):
|
|
|
436
445
|
retention_start_ms = now_ms - self.result_ttl.total_seconds() * 1000
|
|
437
446
|
pipe = self.client.pipeline()
|
|
438
447
|
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
|
-
)
|
|
448
|
+
pipe.zcard(self._segment_key(TaskResultStatus.READY, queue_name))
|
|
449
|
+
pipe.zcard(self._segment_key(TaskResultStatus.RUNNING, queue_name))
|
|
445
450
|
pipe.zcard(
|
|
446
451
|
self.DEFERRED_KEY.format(prefix=self.key_prefix, queue_name=queue_name)
|
|
447
452
|
)
|
|
448
|
-
successful_results_key = self.
|
|
449
|
-
|
|
450
|
-
)
|
|
451
|
-
failed_results_key = self.FAILED_RESULTS_KEY.format(
|
|
452
|
-
prefix=self.key_prefix, queue_name=queue_name
|
|
453
|
+
successful_results_key = self._segment_key(
|
|
454
|
+
TaskResultStatus.SUCCESSFUL, queue_name
|
|
453
455
|
)
|
|
456
|
+
failed_results_key = self._segment_key(TaskResultStatus.FAILED, queue_name)
|
|
454
457
|
pipe.zcard(successful_results_key)
|
|
455
458
|
pipe.zcard(failed_results_key)
|
|
456
459
|
ingress_key = self.INGRESS_KEY.format(
|
|
@@ -30,6 +30,7 @@ from textual.widgets import (
|
|
|
30
30
|
)
|
|
31
31
|
|
|
32
32
|
from ..backends.base import BackendTelemetry, ThreadmillTaskBackend
|
|
33
|
+
from .screens import ConfirmScreen, PurgeScreen
|
|
33
34
|
|
|
34
35
|
logger = logging.getLogger(__name__)
|
|
35
36
|
|
|
@@ -163,6 +164,21 @@ class TaskList(Vertical):
|
|
|
163
164
|
counts: reactive[dict[str, int]] = reactive({})
|
|
164
165
|
selected_task: reactive[TaskResult | None] = reactive(None)
|
|
165
166
|
|
|
167
|
+
BINDINGS = [
|
|
168
|
+
Binding("f5", "refresh", "Refresh"),
|
|
169
|
+
Binding("r", "requeue", "Requeue"),
|
|
170
|
+
Binding("d", "dequeue", "Drop"),
|
|
171
|
+
]
|
|
172
|
+
|
|
173
|
+
def check_action(self, action: str, parameters: tuple[object, ...]) -> bool | None:
|
|
174
|
+
"""Show requeue only on the Failed tab, dequeue on non-Running tabs."""
|
|
175
|
+
status = self.active_status()
|
|
176
|
+
if action == "requeue":
|
|
177
|
+
return status == TaskResultStatus.FAILED
|
|
178
|
+
if action == "dequeue":
|
|
179
|
+
return status is not None and status != TaskResultStatus.RUNNING
|
|
180
|
+
return True
|
|
181
|
+
|
|
166
182
|
def compose(self) -> ComposeResult:
|
|
167
183
|
with TabbedContent(initial="tab-ready") as tabs:
|
|
168
184
|
for label, _ in TAB_STATUSES:
|
|
@@ -212,6 +228,11 @@ class TaskList(Vertical):
|
|
|
212
228
|
"""Activate the tab with the given id."""
|
|
213
229
|
self._tabs.active = tab_id
|
|
214
230
|
|
|
231
|
+
def action_refresh(self) -> None:
|
|
232
|
+
"""Refresh telemetry and re-fetch the task list."""
|
|
233
|
+
self.app._refresh_telemetry()
|
|
234
|
+
self.refresh_tasks()
|
|
235
|
+
|
|
215
236
|
def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
|
|
216
237
|
"""Notify the app when a task row is selected."""
|
|
217
238
|
if isinstance(event.row_key.value, str):
|
|
@@ -227,6 +248,7 @@ class TaskList(Vertical):
|
|
|
227
248
|
) -> None:
|
|
228
249
|
"""Refresh the visible table when the user switches tabs."""
|
|
229
250
|
self._refresh_data()
|
|
251
|
+
self.refresh_bindings()
|
|
230
252
|
|
|
231
253
|
def _select_task_by_id(self, task_id: str) -> None:
|
|
232
254
|
"""Find the task result matching the row key in the current results."""
|
|
@@ -235,6 +257,57 @@ class TaskList(Vertical):
|
|
|
235
257
|
None,
|
|
236
258
|
)
|
|
237
259
|
|
|
260
|
+
def active_status(self) -> TaskResultStatus | None:
|
|
261
|
+
"""Return the status segment of the currently active task tab."""
|
|
262
|
+
tab_id = self._tabs.active.removeprefix("tab-")
|
|
263
|
+
return next(
|
|
264
|
+
(status for label, status in TAB_STATUSES if label.lower() == tab_id),
|
|
265
|
+
None,
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
def action_requeue(self) -> None:
|
|
269
|
+
"""Requeue the selected failed task after confirmation."""
|
|
270
|
+
task = self.selected_task
|
|
271
|
+
if task and self.active_status() == TaskResultStatus.FAILED:
|
|
272
|
+
self.app.push_screen(
|
|
273
|
+
ConfirmScreen(f"Requeue task {task.id[:8]}?"),
|
|
274
|
+
lambda confirmed: self._do_requeue(confirmed, task),
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
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.")
|
|
287
|
+
|
|
288
|
+
def action_dequeue(self) -> None:
|
|
289
|
+
"""Remove the selected task from its current segment after confirmation."""
|
|
290
|
+
task = self.selected_task
|
|
291
|
+
if task and self.active_status() is not None:
|
|
292
|
+
self.app.push_screen(
|
|
293
|
+
ConfirmScreen(
|
|
294
|
+
f"Delete task {task.task.func.__name__} ({task.id[:8]})?",
|
|
295
|
+
danger=True,
|
|
296
|
+
),
|
|
297
|
+
lambda confirmed: self._do_dequeue(confirmed, task),
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
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.")
|
|
310
|
+
|
|
238
311
|
def _refresh_data(self) -> None:
|
|
239
312
|
"""Fetch and display tasks for the current queue and active tab."""
|
|
240
313
|
backend = self.backend
|
|
@@ -291,6 +364,10 @@ class QueueList(ListView):
|
|
|
291
364
|
|
|
292
365
|
telemetry: reactive[BackendTelemetry | None] = reactive(None)
|
|
293
366
|
|
|
367
|
+
BINDINGS = [
|
|
368
|
+
Binding("p", "purge", "Purge"),
|
|
369
|
+
]
|
|
370
|
+
|
|
294
371
|
def __init__(self, **kwargs: Any) -> None:
|
|
295
372
|
super().__init__(**kwargs)
|
|
296
373
|
self._items: dict[str, QueueItem] = {}
|
|
@@ -300,12 +377,10 @@ class QueueList(ListView):
|
|
|
300
377
|
self.border_title = "Queues"
|
|
301
378
|
|
|
302
379
|
def watch_telemetry(self, telemetry: BackendTelemetry | None) -> None:
|
|
303
|
-
"""Refresh queue labels when a new telemetry snapshot arrives."""
|
|
304
380
|
if telemetry is not None:
|
|
305
381
|
self.update_telemetry(telemetry)
|
|
306
382
|
|
|
307
383
|
def update_telemetry(self, telemetry: BackendTelemetry) -> None:
|
|
308
|
-
"""Refresh queue labels from a new telemetry snapshot."""
|
|
309
384
|
queues = telemetry.queues
|
|
310
385
|
for queue_name, stats in sorted(queues.items()):
|
|
311
386
|
theme = BUILTIN_THEMES[self.app.theme]
|
|
@@ -334,21 +409,40 @@ class QueueList(ListView):
|
|
|
334
409
|
self._notify_selection(target)
|
|
335
410
|
|
|
336
411
|
def _notify_selection(self, queue_name: str) -> None:
|
|
337
|
-
"""Tell the app which queue to display."""
|
|
338
412
|
self.app._task_list.queue_name = queue_name
|
|
339
413
|
|
|
414
|
+
def action_purge(self) -> None:
|
|
415
|
+
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
|
+
)
|
|
423
|
+
|
|
424
|
+
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}.")
|
|
434
|
+
|
|
340
435
|
|
|
341
436
|
class InspectorApp(App):
|
|
342
437
|
"""Threadmill TUI inspector with backend/queue/task panes."""
|
|
343
438
|
|
|
344
439
|
CSS_PATH = "inspector.scss"
|
|
345
440
|
BINDINGS = [
|
|
346
|
-
Binding("q", "quit", "Quit"),
|
|
347
|
-
Binding("f5", "refresh", "Refresh"),
|
|
348
441
|
*(
|
|
349
442
|
Binding(key, f"switch_tab('tab-{tab_id}')", tab_id.capitalize())
|
|
350
443
|
for tab_id, key in TAB_KEYS.items()
|
|
351
444
|
),
|
|
445
|
+
Binding("q", "quit", "Quit"),
|
|
352
446
|
]
|
|
353
447
|
|
|
354
448
|
backend: reactive[ThreadmillTaskBackend] = reactive(None)
|
|
@@ -415,8 +509,9 @@ class InspectorApp(App):
|
|
|
415
509
|
"""Exit the TUI."""
|
|
416
510
|
self.exit()
|
|
417
511
|
|
|
418
|
-
def
|
|
419
|
-
"""Refresh
|
|
512
|
+
def _after_action(self, message: str) -> None:
|
|
513
|
+
"""Refresh telemetry and tasks after a mutating queue action."""
|
|
514
|
+
self.notify(message)
|
|
420
515
|
self._refresh_telemetry()
|
|
421
516
|
self._task_list.refresh_tasks()
|
|
422
517
|
|
|
@@ -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%;
|
|
@@ -104,3 +109,29 @@ TaskDetail {
|
|
|
104
109
|
overflow: scroll;
|
|
105
110
|
padding: 0 1;
|
|
106
111
|
}
|
|
112
|
+
|
|
113
|
+
#dialog {
|
|
114
|
+
width: 60;
|
|
115
|
+
height: auto;
|
|
116
|
+
background: $surface;
|
|
117
|
+
padding: 1 2;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
#dialog.warning {
|
|
121
|
+
border: thick $warning;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
#dialog.danger {
|
|
125
|
+
border: thick $error;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
#prompt {
|
|
129
|
+
text-align: center;
|
|
130
|
+
margin-bottom: 1;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
#instruction {
|
|
134
|
+
text-align: center;
|
|
135
|
+
color: $text-muted;
|
|
136
|
+
margin-top: 1;
|
|
137
|
+
}
|
|
@@ -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)
|
|
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
|