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
+ """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