baqueue 0.1.0__tar.gz → 1.0.2__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.
- {baqueue-0.1.0/baqueue.egg-info → baqueue-1.0.2}/PKG-INFO +18 -1
- {baqueue-0.1.0 → baqueue-1.0.2}/README.md +16 -0
- {baqueue-0.1.0 → baqueue-1.0.2}/baqueue/__init__.py +1 -1
- {baqueue-0.1.0 → baqueue-1.0.2}/baqueue/cli.py +31 -0
- {baqueue-0.1.0 → baqueue-1.0.2}/baqueue/config.py +8 -0
- {baqueue-0.1.0 → baqueue-1.0.2}/baqueue/drivers/base.py +44 -0
- {baqueue-0.1.0 → baqueue-1.0.2}/baqueue/drivers/redis_driver.py +199 -24
- {baqueue-0.1.0 → baqueue-1.0.2}/baqueue/pruner.py +18 -9
- {baqueue-0.1.0 → baqueue-1.0.2}/baqueue/queue.py +2 -0
- {baqueue-0.1.0 → baqueue-1.0.2/baqueue.egg-info}/PKG-INFO +18 -1
- {baqueue-0.1.0 → baqueue-1.0.2}/baqueue.egg-info/requires.txt +1 -0
- {baqueue-0.1.0 → baqueue-1.0.2}/pyproject.toml +1 -0
- {baqueue-0.1.0 → baqueue-1.0.2}/LICENSE +0 -0
- {baqueue-0.1.0 → baqueue-1.0.2}/MANIFEST.in +0 -0
- {baqueue-0.1.0 → baqueue-1.0.2}/baqueue/balancer.py +0 -0
- {baqueue-0.1.0 → baqueue-1.0.2}/baqueue/batch.py +0 -0
- {baqueue-0.1.0 → baqueue-1.0.2}/baqueue/dashboard/__init__.py +0 -0
- {baqueue-0.1.0 → baqueue-1.0.2}/baqueue/dashboard/api.py +0 -0
- {baqueue-0.1.0 → baqueue-1.0.2}/baqueue/dashboard/server.py +0 -0
- {baqueue-0.1.0 → baqueue-1.0.2}/baqueue/dashboard/static/app.js +0 -0
- {baqueue-0.1.0 → baqueue-1.0.2}/baqueue/dashboard/static/index.html +0 -0
- {baqueue-0.1.0 → baqueue-1.0.2}/baqueue/dashboard/static/style.css +0 -0
- {baqueue-0.1.0 → baqueue-1.0.2}/baqueue/drivers/__init__.py +0 -0
- {baqueue-0.1.0 → baqueue-1.0.2}/baqueue/drivers/memory_driver.py +0 -0
- {baqueue-0.1.0 → baqueue-1.0.2}/baqueue/drivers/postgres_driver.py +0 -0
- {baqueue-0.1.0 → baqueue-1.0.2}/baqueue/drivers/sqlite_driver.py +0 -0
- {baqueue-0.1.0 → baqueue-1.0.2}/baqueue/events.py +0 -0
- {baqueue-0.1.0 → baqueue-1.0.2}/baqueue/job.py +0 -0
- {baqueue-0.1.0 → baqueue-1.0.2}/baqueue/retry.py +0 -0
- {baqueue-0.1.0 → baqueue-1.0.2}/baqueue/scheduler.py +0 -0
- {baqueue-0.1.0 → baqueue-1.0.2}/baqueue/serializer.py +0 -0
- {baqueue-0.1.0 → baqueue-1.0.2}/baqueue/supervisor.py +0 -0
- {baqueue-0.1.0 → baqueue-1.0.2}/baqueue/worker.py +0 -0
- {baqueue-0.1.0 → baqueue-1.0.2}/baqueue.egg-info/SOURCES.txt +0 -0
- {baqueue-0.1.0 → baqueue-1.0.2}/baqueue.egg-info/dependency_links.txt +0 -0
- {baqueue-0.1.0 → baqueue-1.0.2}/baqueue.egg-info/entry_points.txt +0 -0
- {baqueue-0.1.0 → baqueue-1.0.2}/baqueue.egg-info/top_level.txt +0 -0
- {baqueue-0.1.0 → baqueue-1.0.2}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: baqueue
|
|
3
|
-
Version:
|
|
3
|
+
Version: 1.0.2
|
|
4
4
|
Summary: A powerful Python queue management package inspired by Laravel Horizon
|
|
5
5
|
Author: Basalam, BaQueue Contributors
|
|
6
6
|
License: MIT
|
|
@@ -45,6 +45,7 @@ Provides-Extra: dev
|
|
|
45
45
|
Requires-Dist: baqueue[all]; extra == "dev"
|
|
46
46
|
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
47
47
|
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
|
|
48
|
+
Requires-Dist: fakeredis>=2.21; extra == "dev"
|
|
48
49
|
Requires-Dist: build>=1.0; extra == "dev"
|
|
49
50
|
Requires-Dist: twine>=5.0; extra == "dev"
|
|
50
51
|
Dynamic: license-file
|
|
@@ -274,6 +275,21 @@ await Queue.prune(status="completed", hours=24)
|
|
|
274
275
|
await Queue.prune(tag="batch:newsletter")
|
|
275
276
|
```
|
|
276
277
|
|
|
278
|
+
#### Redis index health
|
|
279
|
+
|
|
280
|
+
The Redis driver keeps secondary indexes (sorted sets) so the dashboard can list and
|
|
281
|
+
count jobs by queue/status efficiently. All deletes go through an index-consistent path
|
|
282
|
+
that removes the job hash *and* every index entry in one atomic step, so the indexes stay
|
|
283
|
+
bounded. If entries are ever orphaned out-of-band (e.g. job hashes deleted directly via
|
|
284
|
+
`redis-cli`), pruning reaps them automatically, and you can force a full repair:
|
|
285
|
+
|
|
286
|
+
```bash
|
|
287
|
+
baqueue reconcile-indexes -d redis --driver-url redis://localhost:6379/0
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
Set `reconcile_on_connect=True` to run that repair once on every startup (off by default
|
|
291
|
+
to keep connect fast on large datasets).
|
|
292
|
+
|
|
277
293
|
### Retry Failed Jobs
|
|
278
294
|
|
|
279
295
|
Bulk-retry failed jobs from the CLI, from Python, or from the dashboard.
|
|
@@ -508,6 +524,7 @@ baqueue schedule Start the job scheduler
|
|
|
508
524
|
baqueue dashboard Launch the monitoring dashboard
|
|
509
525
|
baqueue prune Prune old jobs
|
|
510
526
|
baqueue retry-failed Retry all failed jobs (filter by queue/tag/age)
|
|
527
|
+
baqueue reconcile-indexes Repair Redis secondary indexes (drop stale entries)
|
|
511
528
|
baqueue status Show queue status
|
|
512
529
|
baqueue test Run the test suite
|
|
513
530
|
```
|
|
@@ -223,6 +223,21 @@ await Queue.prune(status="completed", hours=24)
|
|
|
223
223
|
await Queue.prune(tag="batch:newsletter")
|
|
224
224
|
```
|
|
225
225
|
|
|
226
|
+
#### Redis index health
|
|
227
|
+
|
|
228
|
+
The Redis driver keeps secondary indexes (sorted sets) so the dashboard can list and
|
|
229
|
+
count jobs by queue/status efficiently. All deletes go through an index-consistent path
|
|
230
|
+
that removes the job hash *and* every index entry in one atomic step, so the indexes stay
|
|
231
|
+
bounded. If entries are ever orphaned out-of-band (e.g. job hashes deleted directly via
|
|
232
|
+
`redis-cli`), pruning reaps them automatically, and you can force a full repair:
|
|
233
|
+
|
|
234
|
+
```bash
|
|
235
|
+
baqueue reconcile-indexes -d redis --driver-url redis://localhost:6379/0
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
Set `reconcile_on_connect=True` to run that repair once on every startup (off by default
|
|
239
|
+
to keep connect fast on large datasets).
|
|
240
|
+
|
|
226
241
|
### Retry Failed Jobs
|
|
227
242
|
|
|
228
243
|
Bulk-retry failed jobs from the CLI, from Python, or from the dashboard.
|
|
@@ -457,6 +472,7 @@ baqueue schedule Start the job scheduler
|
|
|
457
472
|
baqueue dashboard Launch the monitoring dashboard
|
|
458
473
|
baqueue prune Prune old jobs
|
|
459
474
|
baqueue retry-failed Retry all failed jobs (filter by queue/tag/age)
|
|
475
|
+
baqueue reconcile-indexes Repair Redis secondary indexes (drop stale entries)
|
|
460
476
|
baqueue status Show queue status
|
|
461
477
|
baqueue test Run the test suite
|
|
462
478
|
```
|
|
@@ -365,6 +365,37 @@ async def _run_retry_failed(
|
|
|
365
365
|
await Queue.disconnect()
|
|
366
366
|
|
|
367
367
|
|
|
368
|
+
@cli.command(name="reconcile-indexes")
|
|
369
|
+
@click.option("--batch", default=500, type=int, help="Index entries scanned per batch.")
|
|
370
|
+
@click.option("--driver", "-d", default="redis", help="Driver name (sqlite, memory, redis, postgres).")
|
|
371
|
+
@click.option("--driver-url", default=None, help="Driver connection URL.")
|
|
372
|
+
@click.pass_context
|
|
373
|
+
def reconcile_indexes(
|
|
374
|
+
ctx: click.Context,
|
|
375
|
+
batch: int,
|
|
376
|
+
driver: str,
|
|
377
|
+
driver_url: str | None,
|
|
378
|
+
) -> None:
|
|
379
|
+
"""Repair secondary indexes: remove entries pointing at jobs that no longer exist.
|
|
380
|
+
|
|
381
|
+
Only the Redis driver maintains secondary indexes; this is a no-op elsewhere."""
|
|
382
|
+
_validate_driver(driver)
|
|
383
|
+
config: BaQueueConfig = ctx.obj["config"]
|
|
384
|
+
config.driver = DriverConfig(name=driver, url=driver_url or "")
|
|
385
|
+
|
|
386
|
+
removed = _run_async(_run_reconcile_indexes, config, batch)
|
|
387
|
+
click.echo(f"Removed {removed or 0} stale index entr{'y' if removed == 1 else 'ies'}.")
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
async def _run_reconcile_indexes(config: BaQueueConfig, batch: int) -> int:
|
|
391
|
+
Queue.configure(config)
|
|
392
|
+
await Queue.connect()
|
|
393
|
+
try:
|
|
394
|
+
return await Queue.get_driver().reconcile_indexes(batch=batch)
|
|
395
|
+
finally:
|
|
396
|
+
await Queue.disconnect()
|
|
397
|
+
|
|
398
|
+
|
|
368
399
|
@cli.command()
|
|
369
400
|
@click.option("--driver", "-d", default="sqlite", help="Driver name (sqlite, memory, redis, postgres).")
|
|
370
401
|
@click.option("--driver-url", default=None, help="Driver connection URL.")
|
|
@@ -59,6 +59,14 @@ class BaQueueConfig(BaseModel):
|
|
|
59
59
|
prune_completed_seconds: int = 5 # delete completed jobs ~5s after completion
|
|
60
60
|
prune_other_seconds: int = 86400 # 1 day — applies to failed + cancelled
|
|
61
61
|
prune_metrics_seconds: int = 604800 # 7 days
|
|
62
|
+
# Per-call cap for index-consistent bulk deletes; the pruner loops to drain.
|
|
63
|
+
prune_batch_size: int = 1000
|
|
64
|
+
|
|
65
|
+
# ── Secondary-index reconciliation (Redis) ─────────────────
|
|
66
|
+
# When True, connect() runs a one-shot reconcile pass that removes index
|
|
67
|
+
# entries pointing at jobs that no longer exist. Off by default — run on
|
|
68
|
+
# demand via `baqueue reconcile-indexes` to keep startup fast.
|
|
69
|
+
reconcile_on_connect: bool = False
|
|
62
70
|
|
|
63
71
|
# ── Legacy hour-based overrides (kept for back-compat) ──────
|
|
64
72
|
# When > 0, these take precedence over the seconds fields above for the
|
|
@@ -10,6 +10,10 @@ from baqueue.serializer import JobPayload
|
|
|
10
10
|
|
|
11
11
|
logger = logging.getLogger("baqueue.driver")
|
|
12
12
|
|
|
13
|
+
# Default per-call cap for batched bulk-delete / prune operations. Keeps a single
|
|
14
|
+
# call from blocking the backend on very large datasets; callers loop to drain.
|
|
15
|
+
DEFAULT_PRUNE_BATCH = 1000
|
|
16
|
+
|
|
13
17
|
|
|
14
18
|
class BaseDriver(ABC):
|
|
15
19
|
"""Every BaQueue driver must implement this interface."""
|
|
@@ -18,6 +22,11 @@ class BaseDriver(ABC):
|
|
|
18
22
|
# an emergency cleanup and one retry. Wired from BaQueueConfig in queue.py.
|
|
19
23
|
auto_cleanup_on_disk_full: bool = True
|
|
20
24
|
|
|
25
|
+
# When True, connect() runs a one-shot reconcile_indexes() pass to heal any
|
|
26
|
+
# secondary-index drift accumulated while offline. Off by default so connect
|
|
27
|
+
# stays fast on large datasets. Wired from BaQueueConfig in queue.py.
|
|
28
|
+
reconcile_on_connect: bool = False
|
|
29
|
+
|
|
21
30
|
# Re-entrancy guard so emergency_cleanup() doesn't recurse if its own
|
|
22
31
|
# prune calls also hit disk-full.
|
|
23
32
|
_in_emergency_cleanup: bool = False
|
|
@@ -193,6 +202,41 @@ class BaseDriver(ABC):
|
|
|
193
202
|
"""Delete matching jobs. Returns count of pruned jobs."""
|
|
194
203
|
...
|
|
195
204
|
|
|
205
|
+
async def bulk_delete_jobs(self, job_ids: list[str], *, limit: int | None = None) -> int:
|
|
206
|
+
"""Delete an explicit list of jobs, keeping any secondary indexes consistent.
|
|
207
|
+
|
|
208
|
+
Default implementation deletes one id at a time via ``delete``; drivers with
|
|
209
|
+
secondary indexes (Redis) override this with an atomic, batched version that
|
|
210
|
+
also reaps orphaned index entries. Returns the count of ids processed."""
|
|
211
|
+
if limit is not None:
|
|
212
|
+
job_ids = job_ids[:limit]
|
|
213
|
+
for job_id in job_ids:
|
|
214
|
+
await self.delete(job_id)
|
|
215
|
+
return len(job_ids)
|
|
216
|
+
|
|
217
|
+
async def prune_terminal_jobs(
|
|
218
|
+
self,
|
|
219
|
+
queue: str | None = None,
|
|
220
|
+
status: str | None = None,
|
|
221
|
+
*,
|
|
222
|
+
older_than: float | None = None,
|
|
223
|
+
limit: int = DEFAULT_PRUNE_BATCH,
|
|
224
|
+
) -> int:
|
|
225
|
+
"""Index-consistent bulk delete of terminal jobs, capped at ``limit`` per call.
|
|
226
|
+
|
|
227
|
+
Default implementation delegates to ``prune``; the Redis driver overrides it to
|
|
228
|
+
use its status index as the work source, reap orphaned index entries, and bound
|
|
229
|
+
the per-call cost. Callers loop until a pass returns fewer than ``limit``."""
|
|
230
|
+
return await self.prune(status=status, queue=queue, older_than_seconds=older_than)
|
|
231
|
+
|
|
232
|
+
async def reconcile_indexes(self, batch: int = 500) -> int:
|
|
233
|
+
"""Repair secondary indexes by removing entries whose job no longer exists.
|
|
234
|
+
|
|
235
|
+
No-op for drivers without secondary indexes (memory/sqlite/postgres). The Redis
|
|
236
|
+
driver overrides this to walk its index ZSETs and ZREM orphaned ids. Returns the
|
|
237
|
+
number of stale index entries removed."""
|
|
238
|
+
return 0
|
|
239
|
+
|
|
196
240
|
@abstractmethod
|
|
197
241
|
async def flush(self, queue: str | None = None) -> None:
|
|
198
242
|
"""Remove all jobs (optionally for a specific queue)."""
|
|
@@ -6,11 +6,16 @@ import json
|
|
|
6
6
|
import logging
|
|
7
7
|
from typing import Any
|
|
8
8
|
|
|
9
|
-
from baqueue.drivers.base import BaseDriver
|
|
9
|
+
from baqueue.drivers.base import DEFAULT_PRUNE_BATCH, BaseDriver
|
|
10
10
|
from baqueue.serializer import JobPayload, _now_ts
|
|
11
11
|
|
|
12
12
|
logger = logging.getLogger("baqueue.redis")
|
|
13
13
|
|
|
14
|
+
# Every status a job hash can carry. Used when reaping orphaned index entries:
|
|
15
|
+
# the job hash is gone, so we can't read its status — we ZREM from every global
|
|
16
|
+
# status index to be sure the stale id is cleared.
|
|
17
|
+
_ALL_STATUSES = ("pending", "processing", "completed", "failed", "cancelled")
|
|
18
|
+
|
|
14
19
|
|
|
15
20
|
class RedisDriver(BaseDriver):
|
|
16
21
|
"""Redis-backed driver using sorted sets for indexed pagination.
|
|
@@ -88,6 +93,10 @@ class RedisDriver(BaseDriver):
|
|
|
88
93
|
self._redis = aioredis.from_url(self._url, decode_responses=True, **self._kwargs)
|
|
89
94
|
await self._redis.ping()
|
|
90
95
|
await self._backfill_indexes_if_needed()
|
|
96
|
+
if self.reconcile_on_connect:
|
|
97
|
+
removed = await self.reconcile_indexes()
|
|
98
|
+
if removed:
|
|
99
|
+
logger.info("reconcile_on_connect removed %d stale index entr(ies)", removed)
|
|
91
100
|
|
|
92
101
|
async def disconnect(self) -> None:
|
|
93
102
|
if self._redis:
|
|
@@ -97,7 +106,11 @@ class RedisDriver(BaseDriver):
|
|
|
97
106
|
async def _backfill_indexes_if_needed(self) -> None:
|
|
98
107
|
"""One-time rebuild of secondary ZSETs for upgrades from a version
|
|
99
108
|
that didn't maintain them. Safe to call on every connect — exits fast
|
|
100
|
-
when the global index is non-empty.
|
|
109
|
+
when the global index is non-empty.
|
|
110
|
+
|
|
111
|
+
This is *add-only*: it inserts index entries for existing job hashes. It
|
|
112
|
+
cannot remove drift (index entries whose hash is gone) — that is the job
|
|
113
|
+
of reconcile_indexes(). Together they fully heal the indexes."""
|
|
101
114
|
if await self._redis.exists(self._idx_all()):
|
|
102
115
|
return
|
|
103
116
|
cursor: Any = "0"
|
|
@@ -518,20 +531,41 @@ class RedisDriver(BaseDriver):
|
|
|
518
531
|
|
|
519
532
|
# ── Pruning ─────────────────────────────────────────────────
|
|
520
533
|
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
status
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
534
|
+
def _index_remove_orphan(self, pipe: Any, job_id: str, queue: str | None, status: str | None) -> None:
|
|
535
|
+
"""ZREM a stale id whose job hash is gone. We can't read the job's real
|
|
536
|
+
queue/status, so we clear every index family we can infer from the call:
|
|
537
|
+
always jobs:all + every global status index, plus the queue-scoped families
|
|
538
|
+
when the caller knows the queue/status it was iterating."""
|
|
539
|
+
pipe.zrem(self._idx_all(), job_id)
|
|
540
|
+
for st in _ALL_STATUSES:
|
|
541
|
+
pipe.zrem(self._idx_status(st), job_id)
|
|
542
|
+
if queue:
|
|
543
|
+
pipe.zrem(self._idx_queue(queue), job_id)
|
|
544
|
+
for st in _ALL_STATUSES:
|
|
545
|
+
pipe.zrem(self._idx_queue_status(queue, st), job_id)
|
|
530
546
|
|
|
531
|
-
|
|
532
|
-
|
|
547
|
+
async def _prune_index_batch(
|
|
548
|
+
self,
|
|
549
|
+
index: str,
|
|
550
|
+
queue: str | None,
|
|
551
|
+
status: str | None,
|
|
552
|
+
tag: str | None,
|
|
553
|
+
older_than_seconds: float | None,
|
|
554
|
+
offset: int,
|
|
555
|
+
limit: int,
|
|
556
|
+
) -> tuple[int, int, int]:
|
|
557
|
+
"""Process one window ``[offset, offset+limit)`` of an index in a single
|
|
558
|
+
atomic pass.
|
|
559
|
+
|
|
560
|
+
Live jobs matching the filters are fully deleted (hash + all four index
|
|
561
|
+
families). Orphaned ids (hash already gone) are reaped from the indexes so
|
|
562
|
+
they can never accumulate. Non-matching live jobs are left in place. Returns
|
|
563
|
+
``(removed, scanned, skipped)``: removed = deleted + reaped, scanned = window
|
|
564
|
+
size actually read, skipped = live jobs left in place (so the caller can step
|
|
565
|
+
its offset past them)."""
|
|
566
|
+
candidate_ids: list[str] = await self._redis.zrange(index, offset, offset + limit - 1)
|
|
533
567
|
if not candidate_ids:
|
|
534
|
-
return 0
|
|
568
|
+
return 0, 0, 0
|
|
535
569
|
|
|
536
570
|
pipe = self._redis.pipeline()
|
|
537
571
|
for jid in candidate_ids:
|
|
@@ -540,29 +574,170 @@ class RedisDriver(BaseDriver):
|
|
|
540
574
|
|
|
541
575
|
now = _now_ts()
|
|
542
576
|
to_delete: list[JobPayload] = []
|
|
543
|
-
|
|
577
|
+
orphans: list[str] = []
|
|
578
|
+
skipped = 0
|
|
579
|
+
for jid, raw in zip(candidate_ids, raws):
|
|
544
580
|
if not raw:
|
|
581
|
+
orphans.append(jid)
|
|
545
582
|
continue
|
|
546
583
|
job = JobPayload.from_json(raw)
|
|
547
584
|
if tag and tag not in job.tags:
|
|
585
|
+
skipped += 1
|
|
548
586
|
continue
|
|
549
587
|
if older_than_seconds and (now - job.updated_at) < older_than_seconds:
|
|
588
|
+
skipped += 1
|
|
550
589
|
continue
|
|
551
590
|
to_delete.append(job)
|
|
552
591
|
|
|
553
|
-
if
|
|
592
|
+
if to_delete or orphans:
|
|
593
|
+
async def _do():
|
|
594
|
+
pipe = self._redis.pipeline()
|
|
595
|
+
for job in to_delete:
|
|
596
|
+
pipe.lrem(self._key("queue", job.queue), 0, job.id)
|
|
597
|
+
pipe.zrem(self._key("delayed"), job.id)
|
|
598
|
+
pipe.unlink(self._key("job", job.id))
|
|
599
|
+
self._index_remove(pipe, job.id, job.queue, job.status)
|
|
600
|
+
for jid in orphans:
|
|
601
|
+
self._index_remove_orphan(pipe, jid, queue, status)
|
|
602
|
+
await pipe.execute()
|
|
603
|
+
await self._with_disk_full_recovery(_do)
|
|
604
|
+
|
|
605
|
+
return len(to_delete) + len(orphans), len(candidate_ids), skipped
|
|
606
|
+
|
|
607
|
+
async def _drain_index(
|
|
608
|
+
self,
|
|
609
|
+
index: str,
|
|
610
|
+
queue: str | None,
|
|
611
|
+
status: str | None,
|
|
612
|
+
tag: str | None,
|
|
613
|
+
older_than_seconds: float | None,
|
|
614
|
+
batch: int,
|
|
615
|
+
) -> int:
|
|
616
|
+
"""Page through an index in ``batch``-sized windows, deleting matches and
|
|
617
|
+
reaping orphans, until the whole index has been scanned.
|
|
618
|
+
|
|
619
|
+
Each Redis round-trip handles at most ``batch`` ids, so a huge (possibly
|
|
620
|
+
orphan-laden) index never blocks the server on one giant zrange + delete —
|
|
621
|
+
while every entry is still examined. Entries a filter skips stay in the index,
|
|
622
|
+
so the offset is advanced past them; that is what keeps matches deeper than
|
|
623
|
+
the first window from being missed (re-reading ``zrange(0, batch)`` forever
|
|
624
|
+
would stop early)."""
|
|
625
|
+
batch = max(1, batch)
|
|
626
|
+
offset = 0
|
|
627
|
+
total = 0
|
|
628
|
+
while True:
|
|
629
|
+
removed, scanned, skipped = await self._prune_index_batch(
|
|
630
|
+
index, queue, status, tag, older_than_seconds, offset, batch,
|
|
631
|
+
)
|
|
632
|
+
total += removed
|
|
633
|
+
offset += skipped # kept entries remain; step past them next round
|
|
634
|
+
if scanned < batch:
|
|
635
|
+
break
|
|
636
|
+
return total
|
|
637
|
+
|
|
638
|
+
async def prune(
|
|
639
|
+
self,
|
|
640
|
+
status: str | None = None,
|
|
641
|
+
tag: str | None = None,
|
|
642
|
+
older_than_seconds: float | None = None,
|
|
643
|
+
queue: str | None = None,
|
|
644
|
+
) -> int:
|
|
645
|
+
if not (status or tag or older_than_seconds or queue):
|
|
554
646
|
return 0
|
|
647
|
+
index = self._index_key(queue, status)
|
|
648
|
+
return await self._drain_index(
|
|
649
|
+
index, queue, status, tag, older_than_seconds, DEFAULT_PRUNE_BATCH,
|
|
650
|
+
)
|
|
651
|
+
|
|
652
|
+
async def prune_terminal_jobs(
|
|
653
|
+
self,
|
|
654
|
+
queue: str | None = None,
|
|
655
|
+
status: str | None = None,
|
|
656
|
+
*,
|
|
657
|
+
older_than: float | None = None,
|
|
658
|
+
limit: int = DEFAULT_PRUNE_BATCH,
|
|
659
|
+
) -> int:
|
|
660
|
+
"""Index-consistent bulk delete from a status index, draining fully in
|
|
661
|
+
``limit``-sized batches (each Redis round-trip handles at most ``limit`` ids).
|
|
662
|
+
|
|
663
|
+
Uses the secondary index itself as the work source — no SCAN of every job
|
|
664
|
+
hash — and reaps orphaned index entries in the same pass."""
|
|
665
|
+
index = self._index_key(queue, status)
|
|
666
|
+
return await self._drain_index(index, queue, status, None, older_than, limit)
|
|
667
|
+
|
|
668
|
+
async def bulk_delete_jobs(self, job_ids: list[str], *, limit: int | None = None) -> int:
|
|
669
|
+
"""Delete an explicit list of jobs atomically, keeping all four index
|
|
670
|
+
families consistent. Live jobs are removed precisely (real queue/status from
|
|
671
|
+
the hash); ids whose hash is already gone are reaped from jobs:all and every
|
|
672
|
+
global status index (per-queue orphans are caught by reconcile_indexes)."""
|
|
673
|
+
if limit is not None:
|
|
674
|
+
job_ids = job_ids[:limit]
|
|
675
|
+
if not job_ids:
|
|
676
|
+
return 0
|
|
677
|
+
|
|
678
|
+
pipe = self._redis.pipeline()
|
|
679
|
+
for jid in job_ids:
|
|
680
|
+
pipe.hget(self._key("job", jid), "data")
|
|
681
|
+
raws = await pipe.execute()
|
|
555
682
|
|
|
556
683
|
async def _do():
|
|
557
684
|
pipe = self._redis.pipeline()
|
|
558
|
-
for
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
685
|
+
for jid, raw in zip(job_ids, raws):
|
|
686
|
+
if raw:
|
|
687
|
+
job = JobPayload.from_json(raw)
|
|
688
|
+
pipe.lrem(self._key("queue", job.queue), 0, jid)
|
|
689
|
+
pipe.zrem(self._key("delayed"), jid)
|
|
690
|
+
pipe.unlink(self._key("job", jid))
|
|
691
|
+
self._index_remove(pipe, jid, job.queue, job.status)
|
|
692
|
+
else:
|
|
693
|
+
self._index_remove_orphan(pipe, jid, None, None)
|
|
563
694
|
await pipe.execute()
|
|
564
695
|
await self._with_disk_full_recovery(_do)
|
|
565
|
-
return len(
|
|
696
|
+
return len(job_ids)
|
|
697
|
+
|
|
698
|
+
async def reconcile_indexes(self, batch: int = 500) -> int:
|
|
699
|
+
"""Walk every secondary-index ZSET and ZREM ids whose job hash is gone.
|
|
700
|
+
|
|
701
|
+
Self-healing repair for index drift (e.g. job hashes deleted out-of-band).
|
|
702
|
+
Index keys are discovered by SCAN (every ``baqueue:jobs:*`` key — jobs:all,
|
|
703
|
+
jobs:status:*, jobs:queue:* and jobs:queue:*:status:*) so the repair reaches
|
|
704
|
+
families for queues no longer in the queues set, and never wastes a round-trip
|
|
705
|
+
on an index combination that does not exist. Each index is then walked with
|
|
706
|
+
ZSCAN — never loading a huge set at once — checking hash existence in pipelined
|
|
707
|
+
batches. Returns the number of stale entries removed."""
|
|
708
|
+
# Job hashes are baqueue:job:* (singular); the index ZSETs are baqueue:jobs:*.
|
|
709
|
+
index_keys: list[str] = []
|
|
710
|
+
cursor: Any = "0"
|
|
711
|
+
pattern = self._key("jobs", "*")
|
|
712
|
+
while True:
|
|
713
|
+
cursor, keys = await self._redis.scan(cursor=cursor, match=pattern, count=batch)
|
|
714
|
+
index_keys.extend(keys)
|
|
715
|
+
if cursor == "0" or cursor == 0:
|
|
716
|
+
break
|
|
717
|
+
|
|
718
|
+
removed = 0
|
|
719
|
+
for index in index_keys:
|
|
720
|
+
zcursor: Any = 0
|
|
721
|
+
while True:
|
|
722
|
+
zcursor, members = await self._redis.zscan(index, cursor=zcursor, count=batch)
|
|
723
|
+
ids = [m[0] if isinstance(m, (tuple, list)) else m for m in members]
|
|
724
|
+
if ids:
|
|
725
|
+
pipe = self._redis.pipeline()
|
|
726
|
+
for jid in ids:
|
|
727
|
+
pipe.exists(self._key("job", jid))
|
|
728
|
+
exists_flags = await pipe.execute()
|
|
729
|
+
stale = [jid for jid, ok in zip(ids, exists_flags) if not ok]
|
|
730
|
+
if stale:
|
|
731
|
+
async def _do(index=index, stale=stale):
|
|
732
|
+
pipe = self._redis.pipeline()
|
|
733
|
+
for jid in stale:
|
|
734
|
+
pipe.zrem(index, jid)
|
|
735
|
+
await pipe.execute()
|
|
736
|
+
await self._with_disk_full_recovery(_do)
|
|
737
|
+
removed += len(stale)
|
|
738
|
+
if zcursor == 0 or zcursor == "0":
|
|
739
|
+
break
|
|
740
|
+
return removed
|
|
566
741
|
|
|
567
742
|
async def prune_metrics(self, older_than_seconds: float) -> int:
|
|
568
743
|
cutoff = _now_ts() - older_than_seconds
|
|
@@ -635,11 +810,11 @@ class RedisDriver(BaseDriver):
|
|
|
635
810
|
pipe.delete(self._key("job", jid))
|
|
636
811
|
pipe.zrem(self._idx_all(), jid)
|
|
637
812
|
pipe.zrem(self._key("delayed"), jid)
|
|
638
|
-
for st in
|
|
813
|
+
for st in _ALL_STATUSES:
|
|
639
814
|
pipe.zrem(self._idx_status(st), jid)
|
|
640
815
|
# Drop all per-queue and per-(queue,status) indexes
|
|
641
816
|
pipe.delete(self._idx_queue(queue))
|
|
642
|
-
for st in
|
|
817
|
+
for st in _ALL_STATUSES:
|
|
643
818
|
pipe.delete(self._idx_queue_status(queue, st))
|
|
644
819
|
pipe.srem(self._key("queues"), queue)
|
|
645
820
|
await pipe.execute()
|
|
@@ -57,26 +57,35 @@ class Pruner:
|
|
|
57
57
|
return self.config.prune_metrics_hours * 3600
|
|
58
58
|
return float(self.config.prune_metrics_seconds)
|
|
59
59
|
|
|
60
|
+
async def _prune_terminal(self, status: str, older_than: float) -> int:
|
|
61
|
+
"""Prune a terminal status via the driver's index-consistent bulk delete.
|
|
62
|
+
|
|
63
|
+
The driver drains the whole backlog in capped batches (so a large or
|
|
64
|
+
orphan-laden index never blocks the backend) and reaps orphaned index entries
|
|
65
|
+
in the same pass, returning the total removed."""
|
|
66
|
+
return await self.driver.prune_terminal_jobs(
|
|
67
|
+
status=status,
|
|
68
|
+
older_than=older_than,
|
|
69
|
+
limit=max(1, int(self.config.prune_batch_size)),
|
|
70
|
+
)
|
|
71
|
+
|
|
60
72
|
async def prune_once(self) -> dict[str, int]:
|
|
61
73
|
"""Run a single prune pass based on config."""
|
|
62
74
|
results: dict[str, int] = {}
|
|
63
75
|
|
|
64
76
|
if self.completed_threshold > 0:
|
|
65
|
-
results["completed"] = await self.
|
|
66
|
-
|
|
67
|
-
older_than_seconds=self.completed_threshold,
|
|
77
|
+
results["completed"] = await self._prune_terminal(
|
|
78
|
+
"completed", self.completed_threshold,
|
|
68
79
|
)
|
|
69
80
|
|
|
70
81
|
if self.failed_threshold > 0:
|
|
71
|
-
results["failed"] = await self.
|
|
72
|
-
|
|
73
|
-
older_than_seconds=self.failed_threshold,
|
|
82
|
+
results["failed"] = await self._prune_terminal(
|
|
83
|
+
"failed", self.failed_threshold,
|
|
74
84
|
)
|
|
75
85
|
|
|
76
86
|
if self.cancelled_threshold > 0:
|
|
77
|
-
results["cancelled"] = await self.
|
|
78
|
-
|
|
79
|
-
older_than_seconds=self.cancelled_threshold,
|
|
87
|
+
results["cancelled"] = await self._prune_terminal(
|
|
88
|
+
"cancelled", self.cancelled_threshold,
|
|
80
89
|
)
|
|
81
90
|
|
|
82
91
|
if self.metrics_threshold > 0:
|
|
@@ -30,6 +30,7 @@ class Queue:
|
|
|
30
30
|
cls._config = config or BaQueueConfig()
|
|
31
31
|
if driver is not None:
|
|
32
32
|
driver.auto_cleanup_on_disk_full = cls._config.auto_cleanup_on_disk_full
|
|
33
|
+
driver.reconcile_on_connect = cls._config.reconcile_on_connect
|
|
33
34
|
cls._driver = driver
|
|
34
35
|
cls._events = EventBus.default()
|
|
35
36
|
|
|
@@ -222,4 +223,5 @@ def _create_driver(config: BaQueueConfig) -> BaseDriver:
|
|
|
222
223
|
else:
|
|
223
224
|
raise ValueError(f"Unknown driver: {name}")
|
|
224
225
|
driver.auto_cleanup_on_disk_full = config.auto_cleanup_on_disk_full
|
|
226
|
+
driver.reconcile_on_connect = config.reconcile_on_connect
|
|
225
227
|
return driver
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: baqueue
|
|
3
|
-
Version:
|
|
3
|
+
Version: 1.0.2
|
|
4
4
|
Summary: A powerful Python queue management package inspired by Laravel Horizon
|
|
5
5
|
Author: Basalam, BaQueue Contributors
|
|
6
6
|
License: MIT
|
|
@@ -45,6 +45,7 @@ Provides-Extra: dev
|
|
|
45
45
|
Requires-Dist: baqueue[all]; extra == "dev"
|
|
46
46
|
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
47
47
|
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
|
|
48
|
+
Requires-Dist: fakeredis>=2.21; extra == "dev"
|
|
48
49
|
Requires-Dist: build>=1.0; extra == "dev"
|
|
49
50
|
Requires-Dist: twine>=5.0; extra == "dev"
|
|
50
51
|
Dynamic: license-file
|
|
@@ -274,6 +275,21 @@ await Queue.prune(status="completed", hours=24)
|
|
|
274
275
|
await Queue.prune(tag="batch:newsletter")
|
|
275
276
|
```
|
|
276
277
|
|
|
278
|
+
#### Redis index health
|
|
279
|
+
|
|
280
|
+
The Redis driver keeps secondary indexes (sorted sets) so the dashboard can list and
|
|
281
|
+
count jobs by queue/status efficiently. All deletes go through an index-consistent path
|
|
282
|
+
that removes the job hash *and* every index entry in one atomic step, so the indexes stay
|
|
283
|
+
bounded. If entries are ever orphaned out-of-band (e.g. job hashes deleted directly via
|
|
284
|
+
`redis-cli`), pruning reaps them automatically, and you can force a full repair:
|
|
285
|
+
|
|
286
|
+
```bash
|
|
287
|
+
baqueue reconcile-indexes -d redis --driver-url redis://localhost:6379/0
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
Set `reconcile_on_connect=True` to run that repair once on every startup (off by default
|
|
291
|
+
to keep connect fast on large datasets).
|
|
292
|
+
|
|
277
293
|
### Retry Failed Jobs
|
|
278
294
|
|
|
279
295
|
Bulk-retry failed jobs from the CLI, from Python, or from the dashboard.
|
|
@@ -508,6 +524,7 @@ baqueue schedule Start the job scheduler
|
|
|
508
524
|
baqueue dashboard Launch the monitoring dashboard
|
|
509
525
|
baqueue prune Prune old jobs
|
|
510
526
|
baqueue retry-failed Retry all failed jobs (filter by queue/tag/age)
|
|
527
|
+
baqueue reconcile-indexes Repair Redis secondary indexes (drop stale entries)
|
|
511
528
|
baqueue status Show queue status
|
|
512
529
|
baqueue test Run the test suite
|
|
513
530
|
```
|
|
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
|
|
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
|