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.
- baqueue/__init__.py +19 -0
- baqueue/balancer.py +108 -0
- baqueue/batch.py +159 -0
- baqueue/cli.py +459 -0
- baqueue/config.py +79 -0
- baqueue/dashboard/__init__.py +1 -0
- baqueue/dashboard/api.py +193 -0
- baqueue/dashboard/server.py +263 -0
- baqueue/dashboard/static/app.js +450 -0
- baqueue/dashboard/static/index.html +580 -0
- baqueue/dashboard/static/style.css +1415 -0
- baqueue/drivers/__init__.py +1 -0
- baqueue/drivers/base.py +212 -0
- baqueue/drivers/memory_driver.py +318 -0
- baqueue/drivers/postgres_driver.py +656 -0
- baqueue/drivers/redis_driver.py +656 -0
- baqueue/drivers/sqlite_driver.py +706 -0
- baqueue/events.py +64 -0
- baqueue/job.py +128 -0
- baqueue/pruner.py +128 -0
- baqueue/queue.py +225 -0
- baqueue/retry.py +55 -0
- baqueue/scheduler.py +101 -0
- baqueue/serializer.py +124 -0
- baqueue/supervisor.py +206 -0
- baqueue/worker.py +165 -0
- baqueue-0.1.0.dist-info/METADATA +609 -0
- baqueue-0.1.0.dist-info/RECORD +32 -0
- baqueue-0.1.0.dist-info/WHEEL +5 -0
- baqueue-0.1.0.dist-info/entry_points.txt +2 -0
- baqueue-0.1.0.dist-info/licenses/LICENSE +21 -0
- baqueue-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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
|