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
|
+
"""PostgreSQL driver for BaQueue - reliable transactional queue backend."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from baqueue.drivers.base import BaseDriver
|
|
9
|
+
from baqueue.serializer import JobPayload, _now_ts
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class PostgresDriver(BaseDriver):
|
|
13
|
+
"""PostgreSQL-backed driver using a single jobs table with advisory locks.
|
|
14
|
+
|
|
15
|
+
Creates two tables:
|
|
16
|
+
{prefix}_jobs - all job data
|
|
17
|
+
{prefix}_batches - batch metadata
|
|
18
|
+
{prefix}_metrics - metric entries
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(self, url: str = "", prefix: str = "baqueue", **kwargs: Any):
|
|
22
|
+
self._url = url
|
|
23
|
+
self._prefix = prefix
|
|
24
|
+
self._kwargs = kwargs
|
|
25
|
+
self._pool: Any = None
|
|
26
|
+
|
|
27
|
+
# Postgres sqlstate codes for storage exhaustion. asyncpg surfaces these on
|
|
28
|
+
# the exception's `sqlstate` attribute.
|
|
29
|
+
_STORAGE_SQLSTATES = frozenset({"53100", "53200", "53300", "54000"})
|
|
30
|
+
|
|
31
|
+
def is_storage_full_error(self, exc: BaseException) -> bool:
|
|
32
|
+
sqlstate = getattr(exc, "sqlstate", None) or getattr(exc, "pgcode", None)
|
|
33
|
+
if sqlstate in self._STORAGE_SQLSTATES:
|
|
34
|
+
return True
|
|
35
|
+
msg = str(exc).lower()
|
|
36
|
+
return (
|
|
37
|
+
"disk full" in msg
|
|
38
|
+
or "no space left" in msg
|
|
39
|
+
or "out of memory" in msg
|
|
40
|
+
or "could not extend file" in msg
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def _jobs_table(self) -> str:
|
|
45
|
+
return f"{self._prefix}_jobs"
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def _batches_table(self) -> str:
|
|
49
|
+
return f"{self._prefix}_batches"
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def _metrics_table(self) -> str:
|
|
53
|
+
return f"{self._prefix}_metrics"
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def _supervisors_table(self) -> str:
|
|
57
|
+
return f"{self._prefix}_supervisors"
|
|
58
|
+
|
|
59
|
+
async def connect(self) -> None:
|
|
60
|
+
try:
|
|
61
|
+
import asyncpg
|
|
62
|
+
except ImportError:
|
|
63
|
+
raise ImportError(
|
|
64
|
+
"PostgreSQL driver requires the 'asyncpg' package.\n"
|
|
65
|
+
"Install it with: pip install baqueue[postgres]"
|
|
66
|
+
) from None
|
|
67
|
+
self._pool = await asyncpg.create_pool(self._url, **self._kwargs)
|
|
68
|
+
await self._ensure_tables()
|
|
69
|
+
|
|
70
|
+
async def disconnect(self) -> None:
|
|
71
|
+
if self._pool:
|
|
72
|
+
await self._pool.close()
|
|
73
|
+
self._pool = None
|
|
74
|
+
|
|
75
|
+
async def _ensure_tables(self) -> None:
|
|
76
|
+
async with self._pool.acquire() as conn:
|
|
77
|
+
await conn.execute(f"""
|
|
78
|
+
CREATE TABLE IF NOT EXISTS {self._jobs_table} (
|
|
79
|
+
id TEXT PRIMARY KEY,
|
|
80
|
+
job_class TEXT NOT NULL,
|
|
81
|
+
data JSONB NOT NULL DEFAULT '{{}}',
|
|
82
|
+
queue TEXT NOT NULL DEFAULT 'default',
|
|
83
|
+
status TEXT NOT NULL DEFAULT 'pending',
|
|
84
|
+
attempts INTEGER NOT NULL DEFAULT 0,
|
|
85
|
+
max_attempts INTEGER NOT NULL DEFAULT 3,
|
|
86
|
+
backoff TEXT NOT NULL DEFAULT 'exponential',
|
|
87
|
+
timeout INTEGER NOT NULL DEFAULT 60,
|
|
88
|
+
tags TEXT[] NOT NULL DEFAULT '{{}}',
|
|
89
|
+
batch_id TEXT,
|
|
90
|
+
delay_until DOUBLE PRECISION,
|
|
91
|
+
error TEXT,
|
|
92
|
+
created_at DOUBLE PRECISION NOT NULL,
|
|
93
|
+
updated_at DOUBLE PRECISION NOT NULL,
|
|
94
|
+
started_at DOUBLE PRECISION,
|
|
95
|
+
completed_at DOUBLE PRECISION,
|
|
96
|
+
failed_at DOUBLE PRECISION
|
|
97
|
+
)
|
|
98
|
+
""")
|
|
99
|
+
await conn.execute(f"""
|
|
100
|
+
CREATE INDEX IF NOT EXISTS idx_{self._prefix}_queue_status_created
|
|
101
|
+
ON {self._jobs_table} (queue, status, created_at DESC)
|
|
102
|
+
""")
|
|
103
|
+
await conn.execute(f"""
|
|
104
|
+
CREATE INDEX IF NOT EXISTS idx_{self._prefix}_created_at
|
|
105
|
+
ON {self._jobs_table} (created_at DESC)
|
|
106
|
+
""")
|
|
107
|
+
await conn.execute(f"""
|
|
108
|
+
CREATE INDEX IF NOT EXISTS idx_{self._prefix}_status_created
|
|
109
|
+
ON {self._jobs_table} (status, created_at DESC)
|
|
110
|
+
""")
|
|
111
|
+
await conn.execute(f"""
|
|
112
|
+
CREATE INDEX IF NOT EXISTS idx_{self._prefix}_delay
|
|
113
|
+
ON {self._jobs_table} (delay_until) WHERE delay_until IS NOT NULL
|
|
114
|
+
""")
|
|
115
|
+
await conn.execute(f"""
|
|
116
|
+
CREATE INDEX IF NOT EXISTS idx_{self._prefix}_batch
|
|
117
|
+
ON {self._jobs_table} (batch_id) WHERE batch_id IS NOT NULL
|
|
118
|
+
""")
|
|
119
|
+
await conn.execute(f"DROP INDEX IF EXISTS idx_{self._prefix}_queue_status")
|
|
120
|
+
await conn.execute(f"""
|
|
121
|
+
CREATE TABLE IF NOT EXISTS {self._batches_table} (
|
|
122
|
+
id TEXT PRIMARY KEY,
|
|
123
|
+
data JSONB NOT NULL DEFAULT '{{}}'
|
|
124
|
+
)
|
|
125
|
+
""")
|
|
126
|
+
await conn.execute(f"""
|
|
127
|
+
CREATE TABLE IF NOT EXISTS {self._metrics_table} (
|
|
128
|
+
id SERIAL PRIMARY KEY,
|
|
129
|
+
queue TEXT NOT NULL,
|
|
130
|
+
metric TEXT NOT NULL,
|
|
131
|
+
value DOUBLE PRECISION NOT NULL,
|
|
132
|
+
recorded_at DOUBLE PRECISION NOT NULL
|
|
133
|
+
)
|
|
134
|
+
""")
|
|
135
|
+
await conn.execute(f"""
|
|
136
|
+
CREATE INDEX IF NOT EXISTS idx_{self._prefix}_metrics_queue_metric
|
|
137
|
+
ON {self._metrics_table} (queue, metric)
|
|
138
|
+
""")
|
|
139
|
+
await conn.execute(f"""
|
|
140
|
+
CREATE INDEX IF NOT EXISTS idx_{self._prefix}_metrics_recorded_at
|
|
141
|
+
ON {self._metrics_table} (recorded_at)
|
|
142
|
+
""")
|
|
143
|
+
await conn.execute(f"""
|
|
144
|
+
CREATE TABLE IF NOT EXISTS {self._supervisors_table} (
|
|
145
|
+
name TEXT PRIMARY KEY,
|
|
146
|
+
data JSONB NOT NULL DEFAULT '{{}}',
|
|
147
|
+
heartbeat_at DOUBLE PRECISION NOT NULL
|
|
148
|
+
)
|
|
149
|
+
""")
|
|
150
|
+
await conn.execute(f"""
|
|
151
|
+
CREATE INDEX IF NOT EXISTS idx_{self._prefix}_supervisors_heartbeat
|
|
152
|
+
ON {self._supervisors_table} (heartbeat_at)
|
|
153
|
+
""")
|
|
154
|
+
|
|
155
|
+
def _row_to_payload(self, row: Any) -> JobPayload:
|
|
156
|
+
backoff: str | list[int] = row["backoff"]
|
|
157
|
+
try:
|
|
158
|
+
parsed = json.loads(backoff)
|
|
159
|
+
if isinstance(parsed, list):
|
|
160
|
+
backoff = parsed
|
|
161
|
+
except (json.JSONDecodeError, TypeError):
|
|
162
|
+
pass
|
|
163
|
+
|
|
164
|
+
return JobPayload(
|
|
165
|
+
id=row["id"],
|
|
166
|
+
job_class=row["job_class"],
|
|
167
|
+
data=json.loads(row["data"]) if isinstance(row["data"], str) else row["data"],
|
|
168
|
+
queue=row["queue"],
|
|
169
|
+
status=row["status"],
|
|
170
|
+
attempts=row["attempts"],
|
|
171
|
+
max_attempts=row["max_attempts"],
|
|
172
|
+
backoff=backoff,
|
|
173
|
+
timeout=row["timeout"],
|
|
174
|
+
tags=list(row["tags"]) if row["tags"] else [],
|
|
175
|
+
batch_id=row["batch_id"],
|
|
176
|
+
delay_until=row["delay_until"],
|
|
177
|
+
error=row["error"],
|
|
178
|
+
created_at=row["created_at"],
|
|
179
|
+
updated_at=row["updated_at"],
|
|
180
|
+
started_at=row["started_at"],
|
|
181
|
+
completed_at=row["completed_at"],
|
|
182
|
+
failed_at=row["failed_at"],
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
# ── Push / Pop ──────────────────────────────────────────────
|
|
186
|
+
|
|
187
|
+
async def push(self, payload: JobPayload) -> str:
|
|
188
|
+
payload.status = "pending"
|
|
189
|
+
payload.updated_at = _now_ts()
|
|
190
|
+
backoff_str = json.dumps(payload.backoff) if isinstance(payload.backoff, list) else payload.backoff
|
|
191
|
+
|
|
192
|
+
async def _do():
|
|
193
|
+
async with self._pool.acquire() as conn:
|
|
194
|
+
await conn.execute(
|
|
195
|
+
f"""INSERT INTO {self._jobs_table}
|
|
196
|
+
(id, job_class, data, queue, status, attempts, max_attempts,
|
|
197
|
+
backoff, timeout, tags, batch_id, delay_until, created_at, updated_at)
|
|
198
|
+
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)""",
|
|
199
|
+
payload.id, payload.job_class, json.dumps(payload.data),
|
|
200
|
+
payload.queue, payload.status, payload.attempts, payload.max_attempts,
|
|
201
|
+
backoff_str, payload.timeout, payload.tags,
|
|
202
|
+
payload.batch_id, payload.delay_until,
|
|
203
|
+
payload.created_at, payload.updated_at,
|
|
204
|
+
)
|
|
205
|
+
await self._with_disk_full_recovery(_do)
|
|
206
|
+
return payload.id
|
|
207
|
+
|
|
208
|
+
async def push_many(self, payloads: list[JobPayload]) -> list[str]:
|
|
209
|
+
ids: list[str] = []
|
|
210
|
+
now = _now_ts()
|
|
211
|
+
|
|
212
|
+
async def _do():
|
|
213
|
+
ids.clear()
|
|
214
|
+
async with self._pool.acquire() as conn:
|
|
215
|
+
async with conn.transaction():
|
|
216
|
+
for p in payloads:
|
|
217
|
+
p.status = "pending"
|
|
218
|
+
p.updated_at = now
|
|
219
|
+
backoff_str = json.dumps(p.backoff) if isinstance(p.backoff, list) else p.backoff
|
|
220
|
+
await conn.execute(
|
|
221
|
+
f"""INSERT INTO {self._jobs_table}
|
|
222
|
+
(id, job_class, data, queue, status, attempts, max_attempts,
|
|
223
|
+
backoff, timeout, tags, batch_id, delay_until, created_at, updated_at)
|
|
224
|
+
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)""",
|
|
225
|
+
p.id, p.job_class, json.dumps(p.data),
|
|
226
|
+
p.queue, p.status, p.attempts, p.max_attempts,
|
|
227
|
+
backoff_str, p.timeout, p.tags,
|
|
228
|
+
p.batch_id, p.delay_until,
|
|
229
|
+
p.created_at, p.updated_at,
|
|
230
|
+
)
|
|
231
|
+
ids.append(p.id)
|
|
232
|
+
await self._with_disk_full_recovery(_do)
|
|
233
|
+
return ids
|
|
234
|
+
|
|
235
|
+
async def pop(self, queue: str) -> JobPayload | None:
|
|
236
|
+
now = _now_ts()
|
|
237
|
+
|
|
238
|
+
async def _do():
|
|
239
|
+
async with self._pool.acquire() as conn:
|
|
240
|
+
return await conn.fetchrow(
|
|
241
|
+
f"""UPDATE {self._jobs_table}
|
|
242
|
+
SET status='processing', started_at=$1, updated_at=$1, attempts=attempts+1
|
|
243
|
+
WHERE id = (
|
|
244
|
+
SELECT id FROM {self._jobs_table}
|
|
245
|
+
WHERE queue=$2 AND status='pending'
|
|
246
|
+
AND (delay_until IS NULL OR delay_until <= $1)
|
|
247
|
+
ORDER BY created_at ASC
|
|
248
|
+
FOR UPDATE SKIP LOCKED
|
|
249
|
+
LIMIT 1
|
|
250
|
+
)
|
|
251
|
+
RETURNING *""",
|
|
252
|
+
now, queue,
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
row = await self._with_disk_full_recovery(_do)
|
|
256
|
+
if row:
|
|
257
|
+
return self._row_to_payload(row)
|
|
258
|
+
return None
|
|
259
|
+
|
|
260
|
+
async def pop_delayed(self) -> list[JobPayload]:
|
|
261
|
+
now = _now_ts()
|
|
262
|
+
|
|
263
|
+
async def _do():
|
|
264
|
+
async with self._pool.acquire() as conn:
|
|
265
|
+
return await conn.fetch(
|
|
266
|
+
f"""UPDATE {self._jobs_table}
|
|
267
|
+
SET delay_until=NULL, updated_at=$1
|
|
268
|
+
WHERE status='pending' AND delay_until IS NOT NULL AND delay_until <= $1
|
|
269
|
+
RETURNING *""",
|
|
270
|
+
now,
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
rows = await self._with_disk_full_recovery(_do)
|
|
274
|
+
return [self._row_to_payload(r) for r in rows]
|
|
275
|
+
|
|
276
|
+
# ── Job lifecycle ───────────────────────────────────────────
|
|
277
|
+
|
|
278
|
+
async def complete(self, payload: JobPayload) -> None:
|
|
279
|
+
now = _now_ts()
|
|
280
|
+
|
|
281
|
+
async def _do():
|
|
282
|
+
async with self._pool.acquire() as conn:
|
|
283
|
+
await conn.execute(
|
|
284
|
+
f"UPDATE {self._jobs_table} SET status='completed', completed_at=$1, updated_at=$1 WHERE id=$2",
|
|
285
|
+
now, payload.id,
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
await self._with_disk_full_recovery(_do)
|
|
289
|
+
payload.status = "completed"
|
|
290
|
+
payload.completed_at = now
|
|
291
|
+
|
|
292
|
+
async def fail(self, payload: JobPayload, error: str) -> None:
|
|
293
|
+
now = _now_ts()
|
|
294
|
+
|
|
295
|
+
async def _do():
|
|
296
|
+
async with self._pool.acquire() as conn:
|
|
297
|
+
await conn.execute(
|
|
298
|
+
f"UPDATE {self._jobs_table} SET status='failed', failed_at=$1, updated_at=$1, error=$2 WHERE id=$3",
|
|
299
|
+
now, error, payload.id,
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
await self._with_disk_full_recovery(_do)
|
|
303
|
+
payload.status = "failed"
|
|
304
|
+
payload.failed_at = now
|
|
305
|
+
payload.error = error
|
|
306
|
+
|
|
307
|
+
async def release(self, payload: JobPayload, delay: float = 0) -> None:
|
|
308
|
+
now = _now_ts()
|
|
309
|
+
delay_until = now + delay if delay > 0 else None
|
|
310
|
+
|
|
311
|
+
async def _do():
|
|
312
|
+
async with self._pool.acquire() as conn:
|
|
313
|
+
await conn.execute(
|
|
314
|
+
f"UPDATE {self._jobs_table} SET status='pending', updated_at=$1, delay_until=$2 WHERE id=$3",
|
|
315
|
+
now, delay_until, payload.id,
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
await self._with_disk_full_recovery(_do)
|
|
319
|
+
|
|
320
|
+
async def delete(self, job_id: str) -> None:
|
|
321
|
+
async def _do():
|
|
322
|
+
async with self._pool.acquire() as conn:
|
|
323
|
+
await conn.execute(f"DELETE FROM {self._jobs_table} WHERE id=$1", job_id)
|
|
324
|
+
|
|
325
|
+
await self._with_disk_full_recovery(_do)
|
|
326
|
+
|
|
327
|
+
# ── Query ───────────────────────────────────────────────────
|
|
328
|
+
|
|
329
|
+
async def get_job(self, job_id: str) -> JobPayload | None:
|
|
330
|
+
async with self._pool.acquire() as conn:
|
|
331
|
+
row = await conn.fetchrow(f"SELECT * FROM {self._jobs_table} WHERE id=$1", job_id)
|
|
332
|
+
return self._row_to_payload(row) if row else None
|
|
333
|
+
|
|
334
|
+
def _build_where(
|
|
335
|
+
self,
|
|
336
|
+
queue: str | None = None,
|
|
337
|
+
status: str | None = None,
|
|
338
|
+
tag: str | None = None,
|
|
339
|
+
batch_id: str | None = None,
|
|
340
|
+
created_from: float | None = None,
|
|
341
|
+
created_to: float | None = None,
|
|
342
|
+
start_idx: int = 1,
|
|
343
|
+
) -> tuple[str, list[Any], int]:
|
|
344
|
+
conditions: list[str] = []
|
|
345
|
+
params: list[Any] = []
|
|
346
|
+
idx = start_idx
|
|
347
|
+
|
|
348
|
+
if queue:
|
|
349
|
+
conditions.append(f"queue=${idx}")
|
|
350
|
+
params.append(queue)
|
|
351
|
+
idx += 1
|
|
352
|
+
if status:
|
|
353
|
+
conditions.append(f"status=${idx}")
|
|
354
|
+
params.append(status)
|
|
355
|
+
idx += 1
|
|
356
|
+
if tag:
|
|
357
|
+
conditions.append(f"${idx} = ANY(tags)")
|
|
358
|
+
params.append(tag)
|
|
359
|
+
idx += 1
|
|
360
|
+
if batch_id:
|
|
361
|
+
conditions.append(f"batch_id=${idx}")
|
|
362
|
+
params.append(batch_id)
|
|
363
|
+
idx += 1
|
|
364
|
+
if created_from is not None:
|
|
365
|
+
conditions.append(f"created_at >= ${idx}")
|
|
366
|
+
params.append(created_from)
|
|
367
|
+
idx += 1
|
|
368
|
+
if created_to is not None:
|
|
369
|
+
conditions.append(f"created_at <= ${idx}")
|
|
370
|
+
params.append(created_to)
|
|
371
|
+
idx += 1
|
|
372
|
+
|
|
373
|
+
where = " AND ".join(conditions) if conditions else "TRUE"
|
|
374
|
+
return where, params, idx
|
|
375
|
+
|
|
376
|
+
async def get_jobs(
|
|
377
|
+
self,
|
|
378
|
+
queue: str | None = None,
|
|
379
|
+
status: str | None = None,
|
|
380
|
+
tag: str | None = None,
|
|
381
|
+
batch_id: str | None = None,
|
|
382
|
+
offset: int = 0,
|
|
383
|
+
limit: int = 50,
|
|
384
|
+
created_from: float | None = None,
|
|
385
|
+
created_to: float | None = None,
|
|
386
|
+
) -> list[JobPayload]:
|
|
387
|
+
where, params, idx = self._build_where(
|
|
388
|
+
queue, status, tag, batch_id, created_from, created_to,
|
|
389
|
+
)
|
|
390
|
+
params.extend([limit, offset])
|
|
391
|
+
|
|
392
|
+
async with self._pool.acquire() as conn:
|
|
393
|
+
rows = await conn.fetch(
|
|
394
|
+
f"SELECT * FROM {self._jobs_table} WHERE {where} ORDER BY created_at DESC LIMIT ${idx} OFFSET ${idx+1}",
|
|
395
|
+
*params,
|
|
396
|
+
)
|
|
397
|
+
return [self._row_to_payload(r) for r in rows]
|
|
398
|
+
|
|
399
|
+
async def count_jobs(
|
|
400
|
+
self,
|
|
401
|
+
queue: str | None = None,
|
|
402
|
+
status: str | None = None,
|
|
403
|
+
created_from: float | None = None,
|
|
404
|
+
created_to: float | None = None,
|
|
405
|
+
) -> int:
|
|
406
|
+
where, params, _ = self._build_where(
|
|
407
|
+
queue=queue, status=status,
|
|
408
|
+
created_from=created_from, created_to=created_to,
|
|
409
|
+
)
|
|
410
|
+
async with self._pool.acquire() as conn:
|
|
411
|
+
row = await conn.fetchrow(
|
|
412
|
+
f"SELECT COUNT(*) AS cnt FROM {self._jobs_table} WHERE {where}",
|
|
413
|
+
*params,
|
|
414
|
+
)
|
|
415
|
+
return int(row["cnt"]) if row else 0
|
|
416
|
+
|
|
417
|
+
async def size(self, queue: str) -> int:
|
|
418
|
+
async with self._pool.acquire() as conn:
|
|
419
|
+
row = await conn.fetchrow(
|
|
420
|
+
f"SELECT COUNT(*) as cnt FROM {self._jobs_table} WHERE queue=$1 AND status='pending'",
|
|
421
|
+
queue,
|
|
422
|
+
)
|
|
423
|
+
return row["cnt"] if row else 0
|
|
424
|
+
|
|
425
|
+
async def queues(self) -> list[str]:
|
|
426
|
+
async with self._pool.acquire() as conn:
|
|
427
|
+
rows = await conn.fetch(f"SELECT DISTINCT queue FROM {self._jobs_table} ORDER BY queue")
|
|
428
|
+
return [r["queue"] for r in rows]
|
|
429
|
+
|
|
430
|
+
# ── Metrics ─────────────────────────────────────────────────
|
|
431
|
+
|
|
432
|
+
async def record_metric(self, queue: str, metric: str, value: float) -> None:
|
|
433
|
+
async def _do():
|
|
434
|
+
async with self._pool.acquire() as conn:
|
|
435
|
+
await conn.execute(
|
|
436
|
+
f"INSERT INTO {self._metrics_table} (queue, metric, value, recorded_at) VALUES ($1,$2,$3,$4)",
|
|
437
|
+
queue, metric, value, _now_ts(),
|
|
438
|
+
)
|
|
439
|
+
await self._with_disk_full_recovery(_do)
|
|
440
|
+
|
|
441
|
+
async def get_metrics(self, queue: str | None = None) -> dict[str, Any]:
|
|
442
|
+
"""Live status counts from the jobs table — never from the metrics event log."""
|
|
443
|
+
async with self._pool.acquire() as conn:
|
|
444
|
+
if queue:
|
|
445
|
+
rows = await conn.fetch(
|
|
446
|
+
f"SELECT queue, status, COUNT(*) AS cnt FROM {self._jobs_table} "
|
|
447
|
+
f"WHERE queue=$1 GROUP BY queue, status",
|
|
448
|
+
queue,
|
|
449
|
+
)
|
|
450
|
+
queue_names = [queue]
|
|
451
|
+
else:
|
|
452
|
+
rows = await conn.fetch(
|
|
453
|
+
f"SELECT queue, status, COUNT(*) AS cnt FROM {self._jobs_table} GROUP BY queue, status"
|
|
454
|
+
)
|
|
455
|
+
qrows = await conn.fetch(
|
|
456
|
+
f"SELECT DISTINCT queue FROM {self._jobs_table} ORDER BY queue"
|
|
457
|
+
)
|
|
458
|
+
queue_names = [r["queue"] for r in qrows]
|
|
459
|
+
|
|
460
|
+
result: dict[str, Any] = {
|
|
461
|
+
q: {"pending": 0, "processing": 0, "completed": 0, "failed": 0}
|
|
462
|
+
for q in queue_names
|
|
463
|
+
}
|
|
464
|
+
for r in rows:
|
|
465
|
+
q = r["queue"]
|
|
466
|
+
if q not in result:
|
|
467
|
+
result[q] = {"pending": 0, "processing": 0, "completed": 0, "failed": 0}
|
|
468
|
+
if r["status"] in result[q]:
|
|
469
|
+
result[q][r["status"]] = int(r["cnt"])
|
|
470
|
+
return result
|
|
471
|
+
|
|
472
|
+
async def report_supervisor(self, stats: dict[str, Any]) -> None:
|
|
473
|
+
name = str(stats.get("name", "")).strip()
|
|
474
|
+
if not name:
|
|
475
|
+
return
|
|
476
|
+
now = _now_ts()
|
|
477
|
+
payload = json.dumps(stats)
|
|
478
|
+
|
|
479
|
+
async def _do():
|
|
480
|
+
async with self._pool.acquire() as conn:
|
|
481
|
+
await conn.execute(
|
|
482
|
+
f"""INSERT INTO {self._supervisors_table} (name, data, heartbeat_at)
|
|
483
|
+
VALUES ($1, $2::jsonb, $3)
|
|
484
|
+
ON CONFLICT (name) DO UPDATE SET
|
|
485
|
+
data=EXCLUDED.data,
|
|
486
|
+
heartbeat_at=EXCLUDED.heartbeat_at""",
|
|
487
|
+
name, payload, now,
|
|
488
|
+
)
|
|
489
|
+
|
|
490
|
+
await self._with_disk_full_recovery(_do)
|
|
491
|
+
|
|
492
|
+
async def get_supervisor_stats(self, stale_after: float = 10.0) -> list[dict[str, Any]]:
|
|
493
|
+
cutoff = _now_ts() - stale_after
|
|
494
|
+
async with self._pool.acquire() as conn:
|
|
495
|
+
rows = await conn.fetch(
|
|
496
|
+
f"""SELECT data FROM {self._supervisors_table}
|
|
497
|
+
WHERE heartbeat_at >= $1
|
|
498
|
+
ORDER BY name""",
|
|
499
|
+
cutoff,
|
|
500
|
+
)
|
|
501
|
+
out: list[dict[str, Any]] = []
|
|
502
|
+
for row in rows:
|
|
503
|
+
raw = row["data"]
|
|
504
|
+
data = json.loads(raw) if isinstance(raw, str) else dict(raw)
|
|
505
|
+
if not isinstance(data, dict):
|
|
506
|
+
continue
|
|
507
|
+
if not data.get("running", False):
|
|
508
|
+
continue
|
|
509
|
+
out.append(data)
|
|
510
|
+
return out
|
|
511
|
+
|
|
512
|
+
# ── Batch helpers ───────────────────────────────────────────
|
|
513
|
+
|
|
514
|
+
async def store_batch(self, batch_id: str, data: dict[str, Any]) -> None:
|
|
515
|
+
async def _do():
|
|
516
|
+
async with self._pool.acquire() as conn:
|
|
517
|
+
await conn.execute(
|
|
518
|
+
f"INSERT INTO {self._batches_table} (id, data) VALUES ($1, $2)",
|
|
519
|
+
batch_id, json.dumps(data),
|
|
520
|
+
)
|
|
521
|
+
|
|
522
|
+
await self._with_disk_full_recovery(_do)
|
|
523
|
+
|
|
524
|
+
async def get_batch(self, batch_id: str) -> dict[str, Any] | None:
|
|
525
|
+
async with self._pool.acquire() as conn:
|
|
526
|
+
row = await conn.fetchrow(f"SELECT data FROM {self._batches_table} WHERE id=$1", batch_id)
|
|
527
|
+
if row:
|
|
528
|
+
return json.loads(row["data"]) if isinstance(row["data"], str) else row["data"]
|
|
529
|
+
return None
|
|
530
|
+
|
|
531
|
+
async def update_batch(self, batch_id: str, data: dict[str, Any]) -> None:
|
|
532
|
+
async def _do():
|
|
533
|
+
async with self._pool.acquire() as conn:
|
|
534
|
+
await conn.execute(
|
|
535
|
+
f"UPDATE {self._batches_table} SET data=$1 WHERE id=$2",
|
|
536
|
+
json.dumps(data), batch_id,
|
|
537
|
+
)
|
|
538
|
+
|
|
539
|
+
await self._with_disk_full_recovery(_do)
|
|
540
|
+
|
|
541
|
+
async def increment_batch_counter(
|
|
542
|
+
self, batch_id: str, field: str, delta: int = 1,
|
|
543
|
+
) -> dict[str, Any] | None:
|
|
544
|
+
# jsonb_set with COALESCE so missing fields start at 0. Returns the
|
|
545
|
+
# post-update row in a single statement -> atomic.
|
|
546
|
+
async def _do():
|
|
547
|
+
async with self._pool.acquire() as conn:
|
|
548
|
+
return await conn.fetchrow(
|
|
549
|
+
f"""UPDATE {self._batches_table}
|
|
550
|
+
SET data = jsonb_set(
|
|
551
|
+
data,
|
|
552
|
+
ARRAY[$1],
|
|
553
|
+
to_jsonb(COALESCE((data->>$1)::int, 0) + $2)
|
|
554
|
+
)
|
|
555
|
+
WHERE id = $3
|
|
556
|
+
RETURNING data""",
|
|
557
|
+
field, delta, batch_id,
|
|
558
|
+
)
|
|
559
|
+
|
|
560
|
+
row = await self._with_disk_full_recovery(_do)
|
|
561
|
+
if row is None:
|
|
562
|
+
return None
|
|
563
|
+
raw = row["data"]
|
|
564
|
+
return json.loads(raw) if isinstance(raw, str) else dict(raw)
|
|
565
|
+
|
|
566
|
+
# ── Pruning ─────────────────────────────────────────────────
|
|
567
|
+
|
|
568
|
+
async def prune(
|
|
569
|
+
self,
|
|
570
|
+
status: str | None = None,
|
|
571
|
+
tag: str | None = None,
|
|
572
|
+
older_than_seconds: float | None = None,
|
|
573
|
+
queue: str | None = None,
|
|
574
|
+
) -> int:
|
|
575
|
+
conditions = []
|
|
576
|
+
params: list[Any] = []
|
|
577
|
+
idx = 1
|
|
578
|
+
|
|
579
|
+
if status:
|
|
580
|
+
conditions.append(f"status=${idx}")
|
|
581
|
+
params.append(status)
|
|
582
|
+
idx += 1
|
|
583
|
+
if tag:
|
|
584
|
+
conditions.append(f"${idx} = ANY(tags)")
|
|
585
|
+
params.append(tag)
|
|
586
|
+
idx += 1
|
|
587
|
+
if older_than_seconds:
|
|
588
|
+
cutoff = _now_ts() - older_than_seconds
|
|
589
|
+
conditions.append(f"updated_at < ${idx}")
|
|
590
|
+
params.append(cutoff)
|
|
591
|
+
idx += 1
|
|
592
|
+
if queue:
|
|
593
|
+
conditions.append(f"queue=${idx}")
|
|
594
|
+
params.append(queue)
|
|
595
|
+
idx += 1
|
|
596
|
+
|
|
597
|
+
if not conditions:
|
|
598
|
+
return 0
|
|
599
|
+
|
|
600
|
+
where = " AND ".join(conditions)
|
|
601
|
+
|
|
602
|
+
async def _do():
|
|
603
|
+
async with self._pool.acquire() as conn:
|
|
604
|
+
return await conn.execute(f"DELETE FROM {self._jobs_table} WHERE {where}", *params)
|
|
605
|
+
|
|
606
|
+
result = await self._with_disk_full_recovery(_do)
|
|
607
|
+
return int(result.split()[-1])
|
|
608
|
+
|
|
609
|
+
async def flush(self, queue: str | None = None) -> None:
|
|
610
|
+
async def _do():
|
|
611
|
+
async with self._pool.acquire() as conn:
|
|
612
|
+
if queue:
|
|
613
|
+
await conn.execute(f"DELETE FROM {self._jobs_table} WHERE queue=$1", queue)
|
|
614
|
+
else:
|
|
615
|
+
await conn.execute(
|
|
616
|
+
f"TRUNCATE {self._jobs_table}, {self._batches_table}, {self._metrics_table}, {self._supervisors_table}"
|
|
617
|
+
)
|
|
618
|
+
|
|
619
|
+
await self._with_disk_full_recovery(_do)
|
|
620
|
+
|
|
621
|
+
async def prune_metrics(self, older_than_seconds: float) -> int:
|
|
622
|
+
cutoff = _now_ts() - older_than_seconds
|
|
623
|
+
|
|
624
|
+
async def _do():
|
|
625
|
+
async with self._pool.acquire() as conn:
|
|
626
|
+
return await conn.execute(
|
|
627
|
+
f"DELETE FROM {self._metrics_table} WHERE recorded_at < $1", cutoff,
|
|
628
|
+
)
|
|
629
|
+
|
|
630
|
+
result = await self._with_disk_full_recovery(_do)
|
|
631
|
+
return int(result.split()[-1])
|
|
632
|
+
|
|
633
|
+
async def recent_throughput(
|
|
634
|
+
self, seconds: int = 60, queue: str | None = None,
|
|
635
|
+
) -> dict[str, int]:
|
|
636
|
+
cutoff = _now_ts() - seconds
|
|
637
|
+
async with self._pool.acquire() as conn:
|
|
638
|
+
if queue:
|
|
639
|
+
rows = await conn.fetch(
|
|
640
|
+
f"""SELECT metric, COUNT(*) AS cnt FROM {self._metrics_table}
|
|
641
|
+
WHERE recorded_at > $1 AND queue = $2
|
|
642
|
+
GROUP BY metric""",
|
|
643
|
+
cutoff, queue,
|
|
644
|
+
)
|
|
645
|
+
else:
|
|
646
|
+
rows = await conn.fetch(
|
|
647
|
+
f"""SELECT metric, COUNT(*) AS cnt FROM {self._metrics_table}
|
|
648
|
+
WHERE recorded_at > $1
|
|
649
|
+
GROUP BY metric""",
|
|
650
|
+
cutoff,
|
|
651
|
+
)
|
|
652
|
+
out = {"processing": 0, "completed": 0, "failed": 0}
|
|
653
|
+
for r in rows:
|
|
654
|
+
if r["metric"] in out:
|
|
655
|
+
out[r["metric"]] = int(r["cnt"])
|
|
656
|
+
return out
|