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.
Files changed (24) hide show
  1. {threadmill-0.4.0 → threadmill-0.5.1}/PKG-INFO +1 -1
  2. {threadmill-0.4.0 → threadmill-0.5.1}/threadmill/_version.py +3 -3
  3. {threadmill-0.4.0 → threadmill-0.5.1}/threadmill/backends/base.py +39 -4
  4. {threadmill-0.4.0 → threadmill-0.5.1}/threadmill/backends/lua/acknowledge.lua +5 -1
  5. {threadmill-0.4.0 → threadmill-0.5.1}/threadmill/backends/lua/acquire.lua +1 -1
  6. {threadmill-0.4.0 → threadmill-0.5.1}/threadmill/backends/redis.py +121 -134
  7. {threadmill-0.4.0 → threadmill-0.5.1}/threadmill/inspector/app.py +182 -19
  8. {threadmill-0.4.0 → threadmill-0.5.1}/threadmill/inspector/inspector.scss +56 -2
  9. threadmill-0.5.1/threadmill/inspector/screens.py +52 -0
  10. threadmill-0.5.1/threadmill/inspector/telemetry.py +81 -0
  11. {threadmill-0.4.0 → threadmill-0.5.1}/LICENSE +0 -0
  12. {threadmill-0.4.0 → threadmill-0.5.1}/README.md +0 -0
  13. {threadmill-0.4.0 → threadmill-0.5.1}/pyproject.toml +0 -0
  14. {threadmill-0.4.0 → threadmill-0.5.1}/threadmill/__init__.py +0 -0
  15. {threadmill-0.4.0 → threadmill-0.5.1}/threadmill/backends/__init__.py +0 -0
  16. {threadmill-0.4.0 → threadmill-0.5.1}/threadmill/backends/lua/mover.lua +0 -0
  17. {threadmill-0.4.0 → threadmill-0.5.1}/threadmill/backends/lua/reaper.lua +0 -0
  18. {threadmill-0.4.0 → threadmill-0.5.1}/threadmill/exceptions.py +0 -0
  19. {threadmill-0.4.0 → threadmill-0.5.1}/threadmill/executor.py +0 -0
  20. {threadmill-0.4.0 → threadmill-0.5.1}/threadmill/inspector/__init__.py +0 -0
  21. {threadmill-0.4.0 → threadmill-0.5.1}/threadmill/management/__init__.py +0 -0
  22. {threadmill-0.4.0 → threadmill-0.5.1}/threadmill/management/commands/__init__.py +0 -0
  23. {threadmill-0.4.0 → threadmill-0.5.1}/threadmill/management/commands/threadmill.py +0 -0
  24. {threadmill-0.4.0 → threadmill-0.5.1}/threadmill/retry.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: threadmill
3
- Version: 0.4.0
3
+ Version: 0.5.1
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>
@@ -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.4.0'
22
- __version_tuple__ = version_tuple = (0, 4, 0)
21
+ __version__ = version = '0.5.1'
22
+ __version_tuple__ = version_tuple = (0, 5, 1)
23
23
 
24
- __commit_id__ = commit_id = 'gf9749f870'
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 telemetry(
261
+ async def queue_stats(
241
262
  self, *, interval: datetime.timedelta = datetime.timedelta(seconds=60)
242
263
  ) -> BackendTelemetry:
243
- """Return a snapshot of stats for all configured queues.
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
- interval: The time window for rolling rates.
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:default:task:")
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.QUEUE_KEY.format(
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 + ":task:",
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.RUNNING_KEY.format(
76
- prefix=self.backend.key_prefix, queue_name=queue_name
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 + ":task:",
86
- self.backend.key_prefix + ":result:",
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
- SUCCESSFUL_RESULTS_KEY = "{prefix}:results:{queue_name}:successful"
134
- FAILED_RESULTS_KEY = "{prefix}:results:{queue_name}:failed"
135
- INGRESS_KEY = "{prefix}:ingress:{queue_name}:events"
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.zadd(ingress_key, {task_result.id: enqueued_at_ms})
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.QUEUE_KEY.format(
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.RUNNING_KEY.format(prefix=self.key_prefix, queue_name=queue_name),
250
- self.QUEUE_KEY.format(prefix=self.key_prefix, queue_name=queue_name),
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 + ":task:",
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.RUNNING_KEY.format(
285
- prefix=self.key_prefix, queue_name=task_result.task.queue_name
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.SUCCESSFUL_RESULTS_KEY.format(
292
- prefix=self.key_prefix, queue_name=task_result.task.queue_name
294
+ successful_results_key = self._segment_key(
295
+ TaskResultStatus.SUCCESSFUL, task_result.task.queue_name
293
296
  )
294
- failed_results_key = self.FAILED_RESULTS_KEY.format(
295
- prefix=self.key_prefix, queue_name=task_result.task.queue_name
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.RUNNING_KEY.format(
326
- prefix=self.key_prefix, queue_name=task_result.task.queue_name
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.RUNNING_KEY, self.TASK_KEY, queue_name, count, "data"
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.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
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
- zset_key_template: str,
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 _trim_telemetry(self, queue_name: str, *, now_ms: float) -> None:
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
- now_ms = time.time() * 1000
434
- window_start_ms = now_ms - interval.total_seconds() * 1000
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
- self.QUEUE_KEY.format(prefix=self.key_prefix, queue_name=queue_name)
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
- successful_results_key = self.SUCCESSFUL_RESULTS_KEY.format(
449
- prefix=self.key_prefix, queue_name=queue_name
450
- )
451
- failed_results_key = self.FAILED_RESULTS_KEY.format(
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 * 9
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=ready,
481
- running=running,
482
- deferred=deferred,
483
- successful=successful,
484
- failed=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 connection."""
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 BackendTelemetry, ThreadmillTaskBackend
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
- TELEMETRY_INTERVAL_SECONDS = 2.0
37
- """Seconds between automatic queue-stat refreshes; the task list stays manual."""
47
+ TELEMETRY_INTERVAL = datetime.timedelta(seconds=2)
48
+ """Between automatic queue-stat refreshes; the task list stays manual."""
49
+
50
+ SPARKLINE_INTERVAL = datetime.timedelta(seconds=1)
51
+ """Between sparkline refreshes from the rolling telemetry buffer."""
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._refresh_telemetry()
513
+ self.query_one("#ingress-sparkline", Sparkline).border_title = "Ingress"
514
+ self.query_one("#egress-sparkline", Sparkline).border_title = "Egress"
515
+ self._poll_queue_stats()
516
+ self._listen_telemetry()
406
517
  if self._auto_refresh:
407
518
  self._telemetry_timer = self.set_interval(
408
- TELEMETRY_INTERVAL_SECONDS,
409
- self._refresh_telemetry,
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 action_refresh(self) -> None:
419
- """Refresh queue stats and re-fetch the task list on demand."""
420
- self._refresh_telemetry()
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._refresh_telemetry()
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
- def _refresh_telemetry(self) -> None:
457
- """Poll the backend for fresh telemetry."""
575
+ @work(exclusive=True, group="queue-stats")
576
+ async def _poll_queue_stats(self) -> None:
577
+ """Fetch queue counts and merge live pub/sub rates into the telemetry."""
458
578
  try:
459
- self.telemetry = self.backend.telemetry()
579
+ telemetry = await self.backend.queue_stats()
460
580
  except Exception: # noqa: BLE001
461
- logger.exception("Failed to refresh telemetry")
581
+ logger.exception("Failed to refresh queue stats")
582
+ else:
583
+ for queue_name, stats in telemetry.queues.items():
584
+ live_rates = self.telemetry_buffer.rates_for(queue_name)
585
+ telemetry.queues[queue_name] = dataclasses.replace(
586
+ stats, rates=live_rates
587
+ )
588
+ self.telemetry = telemetry
589
+
590
+ def _refresh_sparklines(self) -> None:
591
+ """Push the latest 60-second rolling series into the ingress/egress sparklines."""
592
+ queue_name = self._task_list.queue_name
593
+ ingress = self.query_one("#ingress-sparkline", Sparkline)
594
+ egress = self.query_one("#egress-sparkline", Sparkline)
595
+ now = time.monotonic()
596
+ ingress.data = (
597
+ self.telemetry_buffer.series(
598
+ queue_name, TelemetryDirection.INGRESS, now=now
599
+ )
600
+ if queue_name
601
+ else [0.0] * 60
602
+ )
603
+ egress.data = (
604
+ self.telemetry_buffer.series(queue_name, TelemetryDirection.EGRESS, now=now)
605
+ if queue_name
606
+ else [0.0] * 60
607
+ )
608
+
609
+ @work(exclusive=True, group="telemetry")
610
+ async def _listen_telemetry(self) -> None:
611
+ """Feed the telemetry buffer from the backend's pub/sub stream."""
612
+ try:
613
+ stream = self.backend.worker_telemetry()
614
+ if asyncio.iscoroutine(stream):
615
+ stream = await stream
616
+ except NotImplementedError:
617
+ pass
618
+ else:
619
+ if stream is not None:
620
+ try:
621
+ async for event in stream:
622
+ self.telemetry_buffer.record(event, now=time.monotonic())
623
+ except asyncio.CancelledError:
624
+ raise
@@ -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: 60%;
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: 40%;
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