baqueue 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,656 @@
1
+ """Redis driver for BaQueue - high-throughput queue backend."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ from typing import Any
8
+
9
+ from baqueue.drivers.base import BaseDriver
10
+ from baqueue.serializer import JobPayload, _now_ts
11
+
12
+ logger = logging.getLogger("baqueue.redis")
13
+
14
+
15
+ class RedisDriver(BaseDriver):
16
+ """Redis-backed driver using sorted sets for indexed pagination.
17
+
18
+ Key layout (prefix = "baqueue"):
19
+ baqueue:queue:{name} LIST pending job ids (FIFO)
20
+ baqueue:delayed ZSET job_id scored by delay_until
21
+ baqueue:job:{id} HASH full job payload
22
+ baqueue:queues SET known queue names
23
+ baqueue:metrics:{queue} LIST metric entries (capped at 10k)
24
+ baqueue:batch:{id} HASH batch metadata
25
+
26
+ # Secondary indexes scored by created_at — used by get_jobs / count_jobs
27
+ baqueue:jobs:all ZSET every job
28
+ baqueue:jobs:queue:{queue} ZSET jobs in a queue
29
+ baqueue:jobs:status:{status} ZSET jobs in a status
30
+ baqueue:jobs:queue:{queue}:status:{status} ZSET jobs in (queue, status)
31
+
32
+ Filtering by tag or batch_id falls back to a per-result scan of the
33
+ most-specific applicable index — fast for small filtered sets, slow if
34
+ the input set is huge. Filter by queue/status when possible.
35
+ """
36
+
37
+ def __init__(self, url: str = "redis://localhost:6379/0", prefix: str = "baqueue", **kwargs: Any):
38
+ self._url = url
39
+ self._prefix = prefix
40
+ self._kwargs = kwargs
41
+ self._redis: Any = None
42
+
43
+ def is_storage_full_error(self, exc: BaseException) -> bool:
44
+ msg = str(exc).lower()
45
+ # Redis returns "OOM command not allowed when used memory > maxmemory".
46
+ return (
47
+ "oom" in msg
48
+ or "out of memory" in msg
49
+ or "maxmemory" in msg
50
+ or "no space" in msg
51
+ )
52
+
53
+ def _key(self, *parts: str) -> str:
54
+ return ":".join([self._prefix, *parts])
55
+
56
+ def _idx_all(self) -> str:
57
+ return self._key("jobs", "all")
58
+
59
+ def _idx_queue(self, queue: str) -> str:
60
+ return self._key("jobs", "queue", queue)
61
+
62
+ def _idx_status(self, status: str) -> str:
63
+ return self._key("jobs", "status", status)
64
+
65
+ def _idx_queue_status(self, queue: str, status: str) -> str:
66
+ return self._key("jobs", "queue", queue, "status", status)
67
+
68
+ def _index_key(self, queue: str | None, status: str | None) -> str:
69
+ if queue and status:
70
+ return self._idx_queue_status(queue, status)
71
+ if queue:
72
+ return self._idx_queue(queue)
73
+ if status:
74
+ return self._idx_status(status)
75
+ return self._idx_all()
76
+
77
+ def _supervisor_key(self, name: str) -> str:
78
+ return self._key("supervisor", name)
79
+
80
+ async def connect(self) -> None:
81
+ try:
82
+ import redis.asyncio as aioredis
83
+ except ImportError:
84
+ raise ImportError(
85
+ "Redis driver requires the 'redis' package.\n"
86
+ "Install it with: pip install baqueue[redis]"
87
+ ) from None
88
+ self._redis = aioredis.from_url(self._url, decode_responses=True, **self._kwargs)
89
+ await self._redis.ping()
90
+ await self._backfill_indexes_if_needed()
91
+
92
+ async def disconnect(self) -> None:
93
+ if self._redis:
94
+ await self._redis.aclose()
95
+ self._redis = None
96
+
97
+ async def _backfill_indexes_if_needed(self) -> None:
98
+ """One-time rebuild of secondary ZSETs for upgrades from a version
99
+ that didn't maintain them. Safe to call on every connect — exits fast
100
+ when the global index is non-empty."""
101
+ if await self._redis.exists(self._idx_all()):
102
+ return
103
+ cursor: Any = "0"
104
+ pattern = self._key("job", "*")
105
+ backfilled = 0
106
+ while True:
107
+ cursor, keys = await self._redis.scan(cursor=cursor, match=pattern, count=500)
108
+ if keys:
109
+ pipe = self._redis.pipeline()
110
+ for key in keys:
111
+ pipe.hget(key, "data")
112
+ raws = await pipe.execute()
113
+ pipe = self._redis.pipeline()
114
+ for raw in raws:
115
+ if not raw:
116
+ continue
117
+ job = JobPayload.from_json(raw)
118
+ self._index_add(pipe, job)
119
+ backfilled += 1
120
+ await pipe.execute()
121
+ if cursor == "0" or cursor == 0:
122
+ break
123
+ if backfilled:
124
+ logger.info("Backfilled %d job(s) into Redis secondary indexes", backfilled)
125
+
126
+ # ── Index maintenance ──────────────────────────────────────
127
+
128
+ def _index_add(self, pipe: Any, job: JobPayload) -> None:
129
+ score = job.created_at
130
+ pipe.zadd(self._idx_all(), {job.id: score})
131
+ pipe.zadd(self._idx_queue(job.queue), {job.id: score})
132
+ pipe.zadd(self._idx_status(job.status), {job.id: score})
133
+ pipe.zadd(self._idx_queue_status(job.queue, job.status), {job.id: score})
134
+
135
+ def _index_remove(self, pipe: Any, job_id: str, queue: str, status: str) -> None:
136
+ pipe.zrem(self._idx_all(), job_id)
137
+ pipe.zrem(self._idx_queue(queue), job_id)
138
+ pipe.zrem(self._idx_status(status), job_id)
139
+ pipe.zrem(self._idx_queue_status(queue, status), job_id)
140
+
141
+ def _index_status_change(
142
+ self, pipe: Any, job_id: str, queue: str, old_status: str, new_status: str, score: float,
143
+ ) -> None:
144
+ if old_status == new_status:
145
+ return
146
+ pipe.zrem(self._idx_status(old_status), job_id)
147
+ pipe.zrem(self._idx_queue_status(queue, old_status), job_id)
148
+ pipe.zadd(self._idx_status(new_status), {job_id: score})
149
+ pipe.zadd(self._idx_queue_status(queue, new_status), {job_id: score})
150
+
151
+ # ── Push / Pop ──────────────────────────────────────────────
152
+
153
+ async def push(self, payload: JobPayload) -> str:
154
+ payload.status = "pending"
155
+ payload.updated_at = _now_ts()
156
+
157
+ async def _do():
158
+ pipe = self._redis.pipeline()
159
+ pipe.hset(self._key("job", payload.id), mapping={"data": payload.to_json()})
160
+ pipe.sadd(self._key("queues"), payload.queue)
161
+ if payload.delay_until and payload.delay_until > _now_ts():
162
+ pipe.zadd(self._key("delayed"), {payload.id: payload.delay_until})
163
+ else:
164
+ pipe.rpush(self._key("queue", payload.queue), payload.id)
165
+ self._index_add(pipe, payload)
166
+ await pipe.execute()
167
+ await self._with_disk_full_recovery(_do)
168
+ return payload.id
169
+
170
+ async def push_many(self, payloads: list[JobPayload]) -> list[str]:
171
+ ids: list[str] = []
172
+ now = _now_ts()
173
+
174
+ async def _do():
175
+ ids.clear()
176
+ pipe = self._redis.pipeline()
177
+ for p in payloads:
178
+ p.status = "pending"
179
+ p.updated_at = now
180
+ pipe.hset(self._key("job", p.id), mapping={"data": p.to_json()})
181
+ pipe.sadd(self._key("queues"), p.queue)
182
+ if p.delay_until and p.delay_until > now:
183
+ pipe.zadd(self._key("delayed"), {p.id: p.delay_until})
184
+ else:
185
+ pipe.rpush(self._key("queue", p.queue), p.id)
186
+ self._index_add(pipe, p)
187
+ ids.append(p.id)
188
+ await pipe.execute()
189
+ await self._with_disk_full_recovery(_do)
190
+ return ids
191
+
192
+ async def pop(self, queue: str) -> JobPayload | None:
193
+ job_id = await self._redis.lpop(self._key("queue", queue))
194
+ if not job_id:
195
+ return None
196
+ raw = await self._redis.hget(self._key("job", job_id), "data")
197
+ if not raw:
198
+ return None
199
+ payload = JobPayload.from_json(raw)
200
+ old_status = payload.status
201
+ payload.status = "processing"
202
+ now = _now_ts()
203
+ payload.started_at = now
204
+ payload.updated_at = now
205
+ payload.attempts += 1
206
+
207
+ async def _do():
208
+ pipe = self._redis.pipeline()
209
+ pipe.hset(self._key("job", payload.id), mapping={"data": payload.to_json()})
210
+ self._index_status_change(pipe, payload.id, payload.queue, old_status, payload.status, payload.created_at)
211
+ await pipe.execute()
212
+ await self._with_disk_full_recovery(_do)
213
+ return payload
214
+
215
+ async def pop_delayed(self) -> list[JobPayload]:
216
+ now = _now_ts()
217
+ job_ids = await self._redis.zrangebyscore(self._key("delayed"), "-inf", now)
218
+ if not job_ids:
219
+ return []
220
+
221
+ async def _remove_delayed():
222
+ pipe = self._redis.pipeline()
223
+ for job_id in job_ids:
224
+ pipe.zrem(self._key("delayed"), job_id)
225
+ await pipe.execute()
226
+ await self._with_disk_full_recovery(_remove_delayed)
227
+
228
+ moved: list[JobPayload] = []
229
+ for job_id in job_ids:
230
+ raw = await self._redis.hget(self._key("job", job_id), "data")
231
+ if raw:
232
+ payload = JobPayload.from_json(raw)
233
+ payload.delay_until = None
234
+ payload.updated_at = _now_ts()
235
+
236
+ async def _move(payload=payload):
237
+ pipe = self._redis.pipeline()
238
+ pipe.hset(self._key("job", payload.id), mapping={"data": payload.to_json()})
239
+ pipe.rpush(self._key("queue", payload.queue), payload.id)
240
+ await pipe.execute()
241
+ await self._with_disk_full_recovery(_move)
242
+ moved.append(payload)
243
+ return moved
244
+
245
+ # ── Job lifecycle ───────────────────────────────────────────
246
+
247
+ async def _transition(self, payload: JobPayload, new_status: str) -> None:
248
+ old_status = payload.status
249
+ payload.status = new_status
250
+ payload.updated_at = _now_ts()
251
+
252
+ async def _do():
253
+ pipe = self._redis.pipeline()
254
+ pipe.hset(self._key("job", payload.id), mapping={"data": payload.to_json()})
255
+ self._index_status_change(pipe, payload.id, payload.queue, old_status, new_status, payload.created_at)
256
+ await pipe.execute()
257
+ await self._with_disk_full_recovery(_do)
258
+
259
+ async def complete(self, payload: JobPayload) -> None:
260
+ payload.completed_at = _now_ts()
261
+ await self._transition(payload, "completed")
262
+
263
+ async def fail(self, payload: JobPayload, error: str) -> None:
264
+ payload.failed_at = _now_ts()
265
+ payload.error = error
266
+ await self._transition(payload, "failed")
267
+
268
+ async def release(self, payload: JobPayload, delay: float = 0) -> None:
269
+ old_status = payload.status
270
+ payload.status = "pending"
271
+ payload.updated_at = _now_ts()
272
+
273
+ async def _do():
274
+ pipe = self._redis.pipeline()
275
+ if delay > 0:
276
+ payload.delay_until = _now_ts() + delay
277
+ pipe.hset(self._key("job", payload.id), mapping={"data": payload.to_json()})
278
+ pipe.zadd(self._key("delayed"), {payload.id: payload.delay_until})
279
+ else:
280
+ pipe.hset(self._key("job", payload.id), mapping={"data": payload.to_json()})
281
+ pipe.rpush(self._key("queue", payload.queue), payload.id)
282
+ self._index_status_change(pipe, payload.id, payload.queue, old_status, "pending", payload.created_at)
283
+ await pipe.execute()
284
+ await self._with_disk_full_recovery(_do)
285
+
286
+ async def delete(self, job_id: str) -> None:
287
+ raw = await self._redis.hget(self._key("job", job_id), "data")
288
+
289
+ async def _do():
290
+ pipe = self._redis.pipeline()
291
+ if raw:
292
+ payload = JobPayload.from_json(raw)
293
+ pipe.lrem(self._key("queue", payload.queue), 0, job_id)
294
+ self._index_remove(pipe, job_id, payload.queue, payload.status)
295
+ pipe.zrem(self._key("delayed"), job_id)
296
+ pipe.delete(self._key("job", job_id))
297
+ await pipe.execute()
298
+ await self._with_disk_full_recovery(_do)
299
+
300
+ # ── Query ───────────────────────────────────────────────────
301
+
302
+ async def get_job(self, job_id: str) -> JobPayload | None:
303
+ raw = await self._redis.hget(self._key("job", job_id), "data")
304
+ return JobPayload.from_json(raw) if raw else None
305
+
306
+ async def _ids_from_index(
307
+ self,
308
+ index: str,
309
+ offset: int,
310
+ limit: int,
311
+ created_from: float | None,
312
+ created_to: float | None,
313
+ ) -> list[str]:
314
+ if created_from is None and created_to is None:
315
+ return await self._redis.zrevrange(index, offset, offset + limit - 1)
316
+ max_score: float | str = "+inf" if created_to is None else created_to
317
+ min_score: float | str = "-inf" if created_from is None else created_from
318
+ return await self._redis.zrevrangebyscore(
319
+ index, max_score, min_score, start=offset, num=limit,
320
+ )
321
+
322
+ async def get_jobs(
323
+ self,
324
+ queue: str | None = None,
325
+ status: str | None = None,
326
+ tag: str | None = None,
327
+ batch_id: str | None = None,
328
+ offset: int = 0,
329
+ limit: int = 50,
330
+ created_from: float | None = None,
331
+ created_to: float | None = None,
332
+ ) -> list[JobPayload]:
333
+ index = self._index_key(queue, status)
334
+
335
+ # When tag/batch_id filtering is needed we have to over-fetch and post-filter.
336
+ # Cap the over-fetch to avoid pathological cases.
337
+ needs_post_filter = bool(tag or batch_id)
338
+ fetch_offset = 0 if needs_post_filter else offset
339
+ fetch_limit = max(limit * 10, 200) if needs_post_filter else limit
340
+
341
+ ids = await self._ids_from_index(
342
+ index, fetch_offset, fetch_limit, created_from, created_to,
343
+ )
344
+ if not ids:
345
+ return []
346
+
347
+ pipe = self._redis.pipeline()
348
+ for jid in ids:
349
+ pipe.hget(self._key("job", jid), "data")
350
+ raws = await pipe.execute()
351
+
352
+ results: list[JobPayload] = []
353
+ for raw in raws:
354
+ if not raw:
355
+ continue
356
+ job = JobPayload.from_json(raw)
357
+ if tag and tag not in job.tags:
358
+ continue
359
+ if batch_id and job.batch_id != batch_id:
360
+ continue
361
+ results.append(job)
362
+
363
+ if needs_post_filter:
364
+ results = results[offset : offset + limit]
365
+ return results
366
+
367
+ async def count_jobs(
368
+ self,
369
+ queue: str | None = None,
370
+ status: str | None = None,
371
+ created_from: float | None = None,
372
+ created_to: float | None = None,
373
+ ) -> int:
374
+ index = self._index_key(queue, status)
375
+ if created_from is None and created_to is None:
376
+ return int(await self._redis.zcard(index))
377
+ min_score = "-inf" if created_from is None else created_from
378
+ max_score = "+inf" if created_to is None else created_to
379
+ return int(await self._redis.zcount(index, min_score, max_score))
380
+
381
+ async def size(self, queue: str) -> int:
382
+ return await self._redis.llen(self._key("queue", queue))
383
+
384
+ async def queues(self) -> list[str]:
385
+ members = await self._redis.smembers(self._key("queues"))
386
+ return sorted(members)
387
+
388
+ # ── Metrics ─────────────────────────────────────────────────
389
+
390
+ async def record_metric(self, queue: str, metric: str, value: float) -> None:
391
+ async def _do():
392
+ entry = json.dumps({"metric": metric, "value": value, "time": _now_ts()})
393
+ await self._redis.rpush(self._key("metrics", queue), entry)
394
+ await self._redis.ltrim(self._key("metrics", queue), -10000, -1)
395
+ await self._with_disk_full_recovery(_do)
396
+
397
+ async def get_metrics(self, queue: str | None = None) -> dict[str, Any]:
398
+ """Live status counts via the per-(queue,status) ZSET indexes.
399
+ The metrics event log is bounded (LTRIM 10k) and was the original cause
400
+ of the Overview "Total Jobs" capping out on busy systems."""
401
+ if queue:
402
+ queue_names = [queue]
403
+ else:
404
+ queue_names = await self.queues()
405
+
406
+ if not queue_names:
407
+ return {}
408
+
409
+ statuses = ("pending", "processing", "completed", "failed")
410
+ pipe = self._redis.pipeline()
411
+ for q in queue_names:
412
+ for st in statuses:
413
+ pipe.zcard(self._idx_queue_status(q, st))
414
+ counts = await pipe.execute()
415
+
416
+ result: dict[str, Any] = {}
417
+ idx = 0
418
+ for q in queue_names:
419
+ entry = {}
420
+ for st in statuses:
421
+ entry[st] = int(counts[idx] or 0)
422
+ idx += 1
423
+ result[q] = entry
424
+ return result
425
+
426
+ async def report_supervisor(self, stats: dict[str, Any]) -> None:
427
+ name = str(stats.get("name", "")).strip()
428
+ if not name:
429
+ return
430
+ ts = _now_ts()
431
+ key = self._supervisor_key(name)
432
+ payload = json.dumps(stats)
433
+
434
+ async def _do():
435
+ pipe = self._redis.pipeline()
436
+ pipe.hset(key, mapping={"data": payload, "heartbeat_at": ts})
437
+ pipe.sadd(self._key("supervisors"), name)
438
+ await pipe.execute()
439
+ await self._with_disk_full_recovery(_do)
440
+
441
+ async def get_supervisor_stats(self, stale_after: float = 10.0) -> list[dict[str, Any]]:
442
+ names = await self._redis.smembers(self._key("supervisors"))
443
+ if not names:
444
+ return []
445
+ cutoff = _now_ts() - stale_after
446
+
447
+ ordered = sorted(names)
448
+ pipe = self._redis.pipeline()
449
+ for name in ordered:
450
+ pipe.hmget(self._supervisor_key(name), "data", "heartbeat_at")
451
+ rows = await pipe.execute()
452
+
453
+ out: list[dict[str, Any]] = []
454
+ for row in rows:
455
+ if not row:
456
+ continue
457
+ raw_data, raw_heartbeat = row
458
+ if not raw_data:
459
+ continue
460
+ try:
461
+ heartbeat = float(raw_heartbeat) if raw_heartbeat is not None else 0.0
462
+ except (TypeError, ValueError):
463
+ heartbeat = 0.0
464
+ if heartbeat < cutoff:
465
+ continue
466
+ try:
467
+ data = json.loads(raw_data)
468
+ except (TypeError, ValueError):
469
+ continue
470
+ if not isinstance(data, dict):
471
+ continue
472
+ if not data.get("running", False):
473
+ continue
474
+ out.append(data)
475
+ return out
476
+
477
+ # ── Batch helpers ───────────────────────────────────────────
478
+
479
+ async def store_batch(self, batch_id: str, data: dict[str, Any]) -> None:
480
+ async def _do():
481
+ await self._redis.hset(self._key("batch", batch_id), mapping={"data": json.dumps(data)})
482
+ await self._with_disk_full_recovery(_do)
483
+
484
+ async def get_batch(self, batch_id: str) -> dict[str, Any] | None:
485
+ raw = await self._redis.hget(self._key("batch", batch_id), "data")
486
+ return json.loads(raw) if raw else None
487
+
488
+ async def update_batch(self, batch_id: str, data: dict[str, Any]) -> None:
489
+ async def _do():
490
+ await self._redis.hset(self._key("batch", batch_id), mapping={"data": json.dumps(data)})
491
+ await self._with_disk_full_recovery(_do)
492
+
493
+ # Lua: atomically read the batch JSON, bump one numeric field, write back.
494
+ # KEYS[1] = batch hash key, ARGV[1] = field, ARGV[2] = delta (int as string).
495
+ _BATCH_INCR_LUA = """
496
+ local raw = redis.call('HGET', KEYS[1], 'data')
497
+ if not raw then return nil end
498
+ local data = cjson.decode(raw)
499
+ local delta = tonumber(ARGV[2]) or 0
500
+ data[ARGV[1]] = (tonumber(data[ARGV[1]]) or 0) + delta
501
+ local out = cjson.encode(data)
502
+ redis.call('HSET', KEYS[1], 'data', out)
503
+ return out
504
+ """
505
+
506
+ async def increment_batch_counter(
507
+ self, batch_id: str, field: str, delta: int = 1,
508
+ ) -> dict[str, Any] | None:
509
+ async def _do():
510
+ return await self._redis.eval(
511
+ self._BATCH_INCR_LUA, 1, self._key("batch", batch_id), field, str(delta),
512
+ )
513
+
514
+ raw = await self._with_disk_full_recovery(_do)
515
+ if raw is None:
516
+ return None
517
+ return json.loads(raw)
518
+
519
+ # ── Pruning ─────────────────────────────────────────────────
520
+
521
+ async def prune(
522
+ self,
523
+ status: str | None = None,
524
+ tag: str | None = None,
525
+ older_than_seconds: float | None = None,
526
+ queue: str | None = None,
527
+ ) -> int:
528
+ if not (status or tag or older_than_seconds or queue):
529
+ return 0
530
+
531
+ index = self._index_key(queue, status)
532
+ candidate_ids: list[str] = await self._redis.zrange(index, 0, -1)
533
+ if not candidate_ids:
534
+ return 0
535
+
536
+ pipe = self._redis.pipeline()
537
+ for jid in candidate_ids:
538
+ pipe.hget(self._key("job", jid), "data")
539
+ raws = await pipe.execute()
540
+
541
+ now = _now_ts()
542
+ to_delete: list[JobPayload] = []
543
+ for raw in raws:
544
+ if not raw:
545
+ continue
546
+ job = JobPayload.from_json(raw)
547
+ if tag and tag not in job.tags:
548
+ continue
549
+ if older_than_seconds and (now - job.updated_at) < older_than_seconds:
550
+ continue
551
+ to_delete.append(job)
552
+
553
+ if not to_delete:
554
+ return 0
555
+
556
+ async def _do():
557
+ pipe = self._redis.pipeline()
558
+ for job in to_delete:
559
+ pipe.lrem(self._key("queue", job.queue), 0, job.id)
560
+ pipe.zrem(self._key("delayed"), job.id)
561
+ pipe.delete(self._key("job", job.id))
562
+ self._index_remove(pipe, job.id, job.queue, job.status)
563
+ await pipe.execute()
564
+ await self._with_disk_full_recovery(_do)
565
+ return len(to_delete)
566
+
567
+ async def prune_metrics(self, older_than_seconds: float) -> int:
568
+ cutoff = _now_ts() - older_than_seconds
569
+ removed = 0
570
+ cursor: Any = "0"
571
+ pattern = self._key("metrics", "*")
572
+
573
+ while True:
574
+ cursor, keys = await self._redis.scan(cursor=cursor, match=pattern, count=200)
575
+ for key in keys:
576
+ entries_raw = await self._redis.lrange(key, 0, -1)
577
+ if not entries_raw:
578
+ continue
579
+
580
+ kept: list[str] = []
581
+ for raw in entries_raw:
582
+ try:
583
+ entry = json.loads(raw)
584
+ except (TypeError, ValueError):
585
+ removed += 1
586
+ continue
587
+ if entry.get("time", 0) < cutoff:
588
+ removed += 1
589
+ continue
590
+ kept.append(raw)
591
+
592
+ if len(kept) == len(entries_raw):
593
+ continue
594
+
595
+ async def _rewrite(key=key, kept=kept):
596
+ pipe = self._redis.pipeline()
597
+ pipe.delete(key)
598
+ if kept:
599
+ pipe.rpush(key, *kept)
600
+ await pipe.execute()
601
+ await self._with_disk_full_recovery(_rewrite)
602
+
603
+ if cursor == "0" or cursor == 0:
604
+ break
605
+ return removed
606
+
607
+ async def recent_throughput(
608
+ self, seconds: int = 60, queue: str | None = None,
609
+ ) -> dict[str, int]:
610
+ cutoff = _now_ts() - seconds
611
+ out = {"processing": 0, "completed": 0, "failed": 0}
612
+ queue_names = [queue] if queue else await self.queues()
613
+ for q in queue_names:
614
+ entries_raw = await self._redis.lrange(self._key("metrics", q), 0, -1)
615
+ for raw in entries_raw:
616
+ try:
617
+ e = json.loads(raw)
618
+ except (ValueError, TypeError):
619
+ continue
620
+ if e.get("time", 0) < cutoff:
621
+ continue
622
+ m = e.get("metric")
623
+ if m in out:
624
+ out[m] += 1
625
+ return out
626
+
627
+ async def flush(self, queue: str | None = None) -> None:
628
+ if queue:
629
+ ids = await self._redis.zrange(self._idx_queue(queue), 0, -1)
630
+
631
+ async def _do_queue():
632
+ pipe = self._redis.pipeline()
633
+ pipe.delete(self._key("queue", queue))
634
+ for jid in ids:
635
+ pipe.delete(self._key("job", jid))
636
+ pipe.zrem(self._idx_all(), jid)
637
+ pipe.zrem(self._key("delayed"), jid)
638
+ for st in ("pending", "processing", "completed", "failed"):
639
+ pipe.zrem(self._idx_status(st), jid)
640
+ # Drop all per-queue and per-(queue,status) indexes
641
+ pipe.delete(self._idx_queue(queue))
642
+ for st in ("pending", "processing", "completed", "failed"):
643
+ pipe.delete(self._idx_queue_status(queue, st))
644
+ pipe.srem(self._key("queues"), queue)
645
+ await pipe.execute()
646
+ await self._with_disk_full_recovery(_do_queue)
647
+ else:
648
+ cursor: Any = "0"
649
+ while True:
650
+ cursor, keys = await self._redis.scan(cursor=cursor, match=f"{self._prefix}:*", count=200)
651
+ if keys:
652
+ async def _do_all(keys=keys):
653
+ await self._redis.delete(*keys)
654
+ await self._with_disk_full_recovery(_do_all)
655
+ if cursor == "0" or cursor == 0:
656
+ break