iddqueue 0.13.0rc3__tar.gz → 0.13.0rc4__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.
- {iddqueue-0.13.0rc3 → iddqueue-0.13.0rc4}/PKG-INFO +12 -10
- {iddqueue-0.13.0rc3 → iddqueue-0.13.0rc4}/README.md +9 -9
- {iddqueue-0.13.0rc3 → iddqueue-0.13.0rc4}/iddqueue/broker.py +23 -11
- iddqueue-0.13.0rc4/iddqueue/metrics.py +115 -0
- iddqueue-0.13.0rc4/iddqueue/sqlalchemy.py +36 -0
- {iddqueue-0.13.0rc3 → iddqueue-0.13.0rc4}/pyproject.toml +3 -2
- iddqueue-0.13.0rc3/iddqueue/metrics.py +0 -68
- {iddqueue-0.13.0rc3 → iddqueue-0.13.0rc4}/LICENSE +0 -0
- {iddqueue-0.13.0rc3 → iddqueue-0.13.0rc4}/iddqueue/__init__.py +0 -0
- {iddqueue-0.13.0rc3 → iddqueue-0.13.0rc4}/iddqueue/cancellation.py +0 -0
- {iddqueue-0.13.0rc3 → iddqueue-0.13.0rc4}/iddqueue/cancellation.sql +0 -0
- {iddqueue-0.13.0rc3 → iddqueue-0.13.0rc4}/iddqueue/cli.py +0 -0
- {iddqueue-0.13.0rc3 → iddqueue-0.13.0rc4}/iddqueue/control.py +0 -0
- {iddqueue-0.13.0rc3 → iddqueue-0.13.0rc4}/iddqueue/control.sql +0 -0
- {iddqueue-0.13.0rc3 → iddqueue-0.13.0rc4}/iddqueue/coordination.sql +0 -0
- {iddqueue-0.13.0rc3 → iddqueue-0.13.0rc4}/iddqueue/deduplication.sql +0 -0
- {iddqueue-0.13.0rc3 → iddqueue-0.13.0rc4}/iddqueue/domains.py +0 -0
- {iddqueue-0.13.0rc3 → iddqueue-0.13.0rc4}/iddqueue/failures.py +0 -0
- {iddqueue-0.13.0rc3 → iddqueue-0.13.0rc4}/iddqueue/history.py +0 -0
- {iddqueue-0.13.0rc3 → iddqueue-0.13.0rc4}/iddqueue/history.sql +0 -0
- {iddqueue-0.13.0rc3 → iddqueue-0.13.0rc4}/iddqueue/rate_limits.py +0 -0
- {iddqueue-0.13.0rc3 → iddqueue-0.13.0rc4}/iddqueue/results.py +0 -0
- {iddqueue-0.13.0rc3 → iddqueue-0.13.0rc4}/iddqueue/scheduler.py +0 -0
- {iddqueue-0.13.0rc3 → iddqueue-0.13.0rc4}/iddqueue/scheduler.sql +0 -0
- {iddqueue-0.13.0rc3 → iddqueue-0.13.0rc4}/iddqueue/schema.py +0 -0
- {iddqueue-0.13.0rc3 → iddqueue-0.13.0rc4}/iddqueue/schema.sql +0 -0
- {iddqueue-0.13.0rc3 → iddqueue-0.13.0rc4}/iddqueue/utils.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: iddqueue
|
|
3
|
-
Version: 0.13.
|
|
3
|
+
Version: 0.13.0rc4
|
|
4
4
|
Summary: Postgres Broker for Dramatiq Task Queue
|
|
5
5
|
Keywords: postgres,task queue,dramatiq
|
|
6
6
|
Author: Étienne BERSAC
|
|
@@ -12,11 +12,13 @@ Requires-Dist: psycopg-pool>=3.3.3,<4
|
|
|
12
12
|
Requires-Dist: tenacity>=9,<10
|
|
13
13
|
Requires-Dist: psycopg[binary]>=3.3.6,<4 ; extra == 'binary'
|
|
14
14
|
Requires-Dist: dramatiq[prometheus]>=2.2.1,<3 ; extra == 'monitoring'
|
|
15
|
+
Requires-Dist: sqlalchemy>=2.0,<3 ; extra == 'sqlalchemy'
|
|
15
16
|
Requires-Python: >=3.10, <4
|
|
16
17
|
Project-URL: Repository, https://github.com/wa-pis/iddqueue
|
|
17
18
|
Project-URL: Issues, https://github.com/wa-pis/iddqueue/issues
|
|
18
19
|
Provides-Extra: binary
|
|
19
20
|
Provides-Extra: monitoring
|
|
21
|
+
Provides-Extra: sqlalchemy
|
|
20
22
|
Description-Content-Type: text/markdown
|
|
21
23
|
|
|
22
24
|
# IDDQueue
|
|
@@ -27,8 +29,8 @@ A PostgreSQL broker and Results backend for [Dramatiq](https://dramatiq.io/),
|
|
|
27
29
|
built on synchronous Psycopg 3. Tasks, results and coordination live in PostgreSQL;
|
|
28
30
|
no Redis or ORM is required by IDDQueue.
|
|
29
31
|
|
|
30
|
-
**Current release: [0.13.
|
|
31
|
-
a release candidate for evaluation. [GitHub assets](https://github.com/wa-pis/iddqueue/releases/tag/v0.13.
|
|
32
|
+
**Current release: [0.13.0rc4](https://pypi.org/project/iddqueue/0.13.0rc4/)** —
|
|
33
|
+
a release candidate for evaluation. [GitHub assets](https://github.com/wa-pis/iddqueue/releases/tag/v0.13.0rc4)
|
|
32
34
|
include the wheel, sdist and SHA256 checksums.
|
|
33
35
|
|
|
34
36
|
IDDQueue is a fork of [DALIBO's dramatiq-pg](https://gitlab.com/dalibo/dramatiq-pg)
|
|
@@ -56,7 +58,7 @@ See [compatibility and API stability](SUPPORT.md).
|
|
|
56
58
|
|
|
57
59
|
```sh
|
|
58
60
|
uv venv
|
|
59
|
-
uv pip install "iddqueue[binary]==0.13.
|
|
61
|
+
uv pip install "iddqueue[binary]==0.13.0rc4"
|
|
60
62
|
```
|
|
61
63
|
|
|
62
64
|
The `binary` extra supplies libpq. Base installs require system libpq;
|
|
@@ -125,7 +127,7 @@ Close broker-owned pools on shutdown; callers close pools they supply.
|
|
|
125
127
|
- [Detailed recipes](docs/recipes.md): transactions, middleware, controls, metrics and schedules.
|
|
126
128
|
- [FastAPI](docs/fastapi.md): lifespan, async endpoint thread offload and separate worker.
|
|
127
129
|
- [Deployment and retention](docs/deployment-guide.md), [why PostgreSQL](docs/why.md).
|
|
128
|
-
- [RC notes](docs/rc-0.13.
|
|
130
|
+
- [RC notes](docs/rc-0.13.0rc4.md), [changelog](CHANGELOG.md), [release checks](docs/release.md).
|
|
129
131
|
|
|
130
132
|
The broker and result APIs are synchronous. Async actors use Dramatiq's AsyncIO
|
|
131
133
|
middleware; FastAPI async endpoints offload publication to a thread.
|
|
@@ -140,28 +142,28 @@ is unverified. No tested Django integration is currently provided.
|
|
|
140
142
|
## Compared with dramatiq-pg
|
|
141
143
|
|
|
142
144
|
Baseline: [dramatiq-pg 0.12.0](https://pypi.org/project/dramatiq-pg/0.12.0/),
|
|
143
|
-
compared with the published IDDQueue 0.13.
|
|
145
|
+
compared with the published IDDQueue 0.13.0rc4 prerelease. This describes the published version, not every
|
|
144
146
|
future upstream revision. Both projects provide a PostgreSQL Dramatiq broker.
|
|
145
147
|
|
|
146
|
-
| Area | dramatiq-pg 0.12.0 | IDDQueue 0.13.
|
|
148
|
+
| Area | dramatiq-pg 0.12.0 | IDDQueue 0.13.0rc4 / practical benefit |
|
|
147
149
|
| --- | --- | --- |
|
|
148
150
|
| Core storage and delivery | JSONB tasks/results, delayed tasks, LISTEN/NOTIFY, advisory locks, recovery and maintenance CLI | Preserved; at-least-once delivery still requires idempotent actors |
|
|
149
151
|
| Actor initialization | Broker configured before actor imports | Opt-in Domain declarations import before DSN; explicit startup registration and domain queues |
|
|
150
152
|
| Runtime | Python >=3.6,<4; Dramatiq >=1.5,<2; Psycopg 2 | Python >=3.10,<4; Dramatiq >=2.2.1,<3; synchronous Psycopg 3 and psycopg-pool |
|
|
151
153
|
| Pool lifecycle | Psycopg 2 pools | Lazy owned pools, explicit close, caller-owned pool support |
|
|
152
|
-
| Transactional publication | Regular enqueue | Caller-owned transaction
|
|
154
|
+
| Transactional publication | Regular enqueue | Caller-owned Psycopg 3 or optional sync SQLAlchemy transaction: commit business data and tasks together |
|
|
153
155
|
| Coordination | No PostgreSQL rate-limit/barrier backend | PostgreSQL rate limits, durable barriers and standard GroupCallbacks without Redis |
|
|
154
156
|
| Task operations | stats, purge, recover, flush | Failed-task inspection/targeted retry, pause/resume, cooperative cancellation and opt-in attempt history |
|
|
155
157
|
| Publication controls | Single-message enqueue | Deduplication keys/TTL and atomic batches of up to 1000; deduplication is not exactly-once execution |
|
|
156
158
|
| Scheduling | Per-message delay | Also a multi-process fixed-interval scheduler; no cron/calendar expressions |
|
|
157
|
-
| Observability | Basic CLI statistics |
|
|
159
|
+
| Observability | Basic CLI statistics | Queue/domain backlog and age, native error/retry counters, optional Prometheus collector and Grafana kit |
|
|
158
160
|
| Storage namespaces | Configurable schema/table prefix; shared channel/lock domains | Instance-specific queries and schema/prefix-specific channels/locks; storage separation is not access control |
|
|
159
161
|
| Dramatiq integration | Broker/results implementation | Verified pipelines, groups, AsyncIO and standard middleware; these are Dramatiq features, not a replacement orchestration engine |
|
|
160
162
|
| Compatibility evidence | Historical upstream tests | CI on Python 3.10/3.13/3.14 × PostgreSQL 14/18, functional tests and installed-wheel checks |
|
|
161
163
|
|
|
162
164
|
See [migration from dramatiq-pg](docs/migration.md) for dependency, pool,
|
|
163
165
|
import, CLI and database changes, including backup and rollback. No throughput
|
|
164
|
-
advantage is claimed here. The 0.13.
|
|
166
|
+
advantage is claimed here. The 0.13.0rc4 prerelease is available on PyPI; the legacy Django
|
|
165
167
|
integration is archived and has not been verified with IDDQueue.
|
|
166
168
|
|
|
167
169
|
## Development and support
|
|
@@ -6,8 +6,8 @@ A PostgreSQL broker and Results backend for [Dramatiq](https://dramatiq.io/),
|
|
|
6
6
|
built on synchronous Psycopg 3. Tasks, results and coordination live in PostgreSQL;
|
|
7
7
|
no Redis or ORM is required by IDDQueue.
|
|
8
8
|
|
|
9
|
-
**Current release: [0.13.
|
|
10
|
-
a release candidate for evaluation. [GitHub assets](https://github.com/wa-pis/iddqueue/releases/tag/v0.13.
|
|
9
|
+
**Current release: [0.13.0rc4](https://pypi.org/project/iddqueue/0.13.0rc4/)** —
|
|
10
|
+
a release candidate for evaluation. [GitHub assets](https://github.com/wa-pis/iddqueue/releases/tag/v0.13.0rc4)
|
|
11
11
|
include the wheel, sdist and SHA256 checksums.
|
|
12
12
|
|
|
13
13
|
IDDQueue is a fork of [DALIBO's dramatiq-pg](https://gitlab.com/dalibo/dramatiq-pg)
|
|
@@ -35,7 +35,7 @@ See [compatibility and API stability](SUPPORT.md).
|
|
|
35
35
|
|
|
36
36
|
```sh
|
|
37
37
|
uv venv
|
|
38
|
-
uv pip install "iddqueue[binary]==0.13.
|
|
38
|
+
uv pip install "iddqueue[binary]==0.13.0rc4"
|
|
39
39
|
```
|
|
40
40
|
|
|
41
41
|
The `binary` extra supplies libpq. Base installs require system libpq;
|
|
@@ -104,7 +104,7 @@ Close broker-owned pools on shutdown; callers close pools they supply.
|
|
|
104
104
|
- [Detailed recipes](docs/recipes.md): transactions, middleware, controls, metrics and schedules.
|
|
105
105
|
- [FastAPI](docs/fastapi.md): lifespan, async endpoint thread offload and separate worker.
|
|
106
106
|
- [Deployment and retention](docs/deployment-guide.md), [why PostgreSQL](docs/why.md).
|
|
107
|
-
- [RC notes](docs/rc-0.13.
|
|
107
|
+
- [RC notes](docs/rc-0.13.0rc4.md), [changelog](CHANGELOG.md), [release checks](docs/release.md).
|
|
108
108
|
|
|
109
109
|
The broker and result APIs are synchronous. Async actors use Dramatiq's AsyncIO
|
|
110
110
|
middleware; FastAPI async endpoints offload publication to a thread.
|
|
@@ -119,28 +119,28 @@ is unverified. No tested Django integration is currently provided.
|
|
|
119
119
|
## Compared with dramatiq-pg
|
|
120
120
|
|
|
121
121
|
Baseline: [dramatiq-pg 0.12.0](https://pypi.org/project/dramatiq-pg/0.12.0/),
|
|
122
|
-
compared with the published IDDQueue 0.13.
|
|
122
|
+
compared with the published IDDQueue 0.13.0rc4 prerelease. This describes the published version, not every
|
|
123
123
|
future upstream revision. Both projects provide a PostgreSQL Dramatiq broker.
|
|
124
124
|
|
|
125
|
-
| Area | dramatiq-pg 0.12.0 | IDDQueue 0.13.
|
|
125
|
+
| Area | dramatiq-pg 0.12.0 | IDDQueue 0.13.0rc4 / practical benefit |
|
|
126
126
|
| --- | --- | --- |
|
|
127
127
|
| Core storage and delivery | JSONB tasks/results, delayed tasks, LISTEN/NOTIFY, advisory locks, recovery and maintenance CLI | Preserved; at-least-once delivery still requires idempotent actors |
|
|
128
128
|
| Actor initialization | Broker configured before actor imports | Opt-in Domain declarations import before DSN; explicit startup registration and domain queues |
|
|
129
129
|
| Runtime | Python >=3.6,<4; Dramatiq >=1.5,<2; Psycopg 2 | Python >=3.10,<4; Dramatiq >=2.2.1,<3; synchronous Psycopg 3 and psycopg-pool |
|
|
130
130
|
| Pool lifecycle | Psycopg 2 pools | Lazy owned pools, explicit close, caller-owned pool support |
|
|
131
|
-
| Transactional publication | Regular enqueue | Caller-owned transaction
|
|
131
|
+
| Transactional publication | Regular enqueue | Caller-owned Psycopg 3 or optional sync SQLAlchemy transaction: commit business data and tasks together |
|
|
132
132
|
| Coordination | No PostgreSQL rate-limit/barrier backend | PostgreSQL rate limits, durable barriers and standard GroupCallbacks without Redis |
|
|
133
133
|
| Task operations | stats, purge, recover, flush | Failed-task inspection/targeted retry, pause/resume, cooperative cancellation and opt-in attempt history |
|
|
134
134
|
| Publication controls | Single-message enqueue | Deduplication keys/TTL and atomic batches of up to 1000; deduplication is not exactly-once execution |
|
|
135
135
|
| Scheduling | Per-message delay | Also a multi-process fixed-interval scheduler; no cron/calendar expressions |
|
|
136
|
-
| Observability | Basic CLI statistics |
|
|
136
|
+
| Observability | Basic CLI statistics | Queue/domain backlog and age, native error/retry counters, optional Prometheus collector and Grafana kit |
|
|
137
137
|
| Storage namespaces | Configurable schema/table prefix; shared channel/lock domains | Instance-specific queries and schema/prefix-specific channels/locks; storage separation is not access control |
|
|
138
138
|
| Dramatiq integration | Broker/results implementation | Verified pipelines, groups, AsyncIO and standard middleware; these are Dramatiq features, not a replacement orchestration engine |
|
|
139
139
|
| Compatibility evidence | Historical upstream tests | CI on Python 3.10/3.13/3.14 × PostgreSQL 14/18, functional tests and installed-wheel checks |
|
|
140
140
|
|
|
141
141
|
See [migration from dramatiq-pg](docs/migration.md) for dependency, pool,
|
|
142
142
|
import, CLI and database changes, including backup and rollback. No throughput
|
|
143
|
-
advantage is claimed here. The 0.13.
|
|
143
|
+
advantage is claimed here. The 0.13.0rc4 prerelease is available on PyPI; the legacy Django
|
|
144
144
|
integration is archived and has not been verified with IDDQueue.
|
|
145
145
|
|
|
146
146
|
## Development and support
|
|
@@ -6,6 +6,7 @@ from itertools import islice
|
|
|
6
6
|
from queue import Empty, Queue
|
|
7
7
|
from random import randint
|
|
8
8
|
from textwrap import dedent
|
|
9
|
+
from uuid import UUID
|
|
9
10
|
|
|
10
11
|
from dramatiq.broker import Broker, Consumer, MessageProxy
|
|
11
12
|
from dramatiq.common import compute_backoff, current_millis, dq_name, q_name
|
|
@@ -326,15 +327,25 @@ class PostgresConsumer(Consumer):
|
|
|
326
327
|
# If we have some notifies, loop to find one todo.
|
|
327
328
|
while self.notifies:
|
|
328
329
|
notify = self.notifies.pop(0)
|
|
329
|
-
|
|
330
|
-
|
|
330
|
+
try:
|
|
331
|
+
payload = json.loads(notify.payload)
|
|
332
|
+
if not isinstance(payload, dict):
|
|
333
|
+
continue
|
|
334
|
+
if payload.get("scan") is not True:
|
|
335
|
+
message_id = payload.get("message_id")
|
|
336
|
+
if not isinstance(message_id, str):
|
|
337
|
+
continue
|
|
338
|
+
message_id = str(UUID(message_id))
|
|
339
|
+
except (ValueError, RecursionError):
|
|
340
|
+
continue
|
|
341
|
+
if payload.get("scan") is True:
|
|
331
342
|
self.notifies += self.fetch_pending_notifies()
|
|
332
343
|
continue
|
|
333
344
|
# Legacy full payloads are hints too; claim returns durable data.
|
|
334
|
-
message = Message(self.queue_name, "", (), {}, {}, message_id=
|
|
345
|
+
message = Message(self.queue_name, "", (), {}, {}, message_id=message_id)
|
|
335
346
|
claimed = self.consume_one(message)
|
|
336
347
|
if claimed:
|
|
337
|
-
self.in_processing.add(claimed.message_id)
|
|
348
|
+
self.in_processing.add(str(UUID(str(claimed.message_id))))
|
|
338
349
|
return MessageProxy(claimed)
|
|
339
350
|
else:
|
|
340
351
|
logger.debug(
|
|
@@ -353,13 +364,13 @@ class PostgresConsumer(Consumer):
|
|
|
353
364
|
# This function is executed in worker thread!
|
|
354
365
|
if getattr(message, "_pg_cancelled", False):
|
|
355
366
|
self.unlock_q.put_nowait(message)
|
|
356
|
-
self.in_processing.remove(message.message_id)
|
|
367
|
+
self.in_processing.remove(str(UUID(str(message.message_id))))
|
|
357
368
|
return
|
|
358
369
|
if getattr(message, "_pg_paused", False):
|
|
359
370
|
with transaction(self.pool) as curs:
|
|
360
371
|
curs.execute(self.queries.DEFER_PAUSED, (message.message_id, message.queue_name))
|
|
361
372
|
self.unlock_q.put_nowait(message)
|
|
362
|
-
self.in_processing.remove(message.message_id)
|
|
373
|
+
self.in_processing.remove(str(UUID(str(message.message_id))))
|
|
363
374
|
return
|
|
364
375
|
|
|
365
376
|
with transaction(self.pool) as curs:
|
|
@@ -380,7 +391,7 @@ class PostgresConsumer(Consumer):
|
|
|
380
391
|
),
|
|
381
392
|
)
|
|
382
393
|
self.unlock_q.put_nowait(message)
|
|
383
|
-
self.in_processing.remove(message.message_id)
|
|
394
|
+
self.in_processing.remove(str(UUID(str(message.message_id))))
|
|
384
395
|
|
|
385
396
|
@raise_connection_error
|
|
386
397
|
def auto_purge(self):
|
|
@@ -442,7 +453,7 @@ class PostgresConsumer(Consumer):
|
|
|
442
453
|
|
|
443
454
|
@raise_connection_error
|
|
444
455
|
def consume_one(self, message):
|
|
445
|
-
if message.message_id in self.in_processing:
|
|
456
|
+
if str(UUID(str(message.message_id))) in self.in_processing:
|
|
446
457
|
logger.debug("%s already consumed by self.", message.message_id)
|
|
447
458
|
return
|
|
448
459
|
|
|
@@ -487,7 +498,7 @@ class PostgresConsumer(Consumer):
|
|
|
487
498
|
),
|
|
488
499
|
)
|
|
489
500
|
self.unlock_q.put_nowait(message)
|
|
490
|
-
self.in_processing.remove(message.message_id)
|
|
501
|
+
self.in_processing.remove(str(UUID(str(message.message_id))))
|
|
491
502
|
|
|
492
503
|
@raise_connection_error
|
|
493
504
|
def fetch_pending_notifies(self):
|
|
@@ -552,7 +563,7 @@ _max_positive_int = 2**63
|
|
|
552
563
|
def message_lock(message, *, schema="dramatiq", prefix=""):
|
|
553
564
|
# create sha256 hash from input and create a 64 bit int from it, using
|
|
554
565
|
# 16 hex char. any 16 char range is ok. it takes the center ones
|
|
555
|
-
global_id = message.queue_name + str(message.message_id)
|
|
566
|
+
global_id = message.queue_name + str(UUID(str(message.message_id)))
|
|
556
567
|
if schema != "dramatiq" or prefix:
|
|
557
568
|
global_id = storage_namespace(schema, prefix) + global_id
|
|
558
569
|
hex = sha256(global_id.encode("utf-8")).hexdigest()
|
|
@@ -592,7 +603,8 @@ QUERIES = QueryManager(
|
|
|
592
603
|
mtime = NOW()
|
|
593
604
|
WHERE message_id = %s AND queue_name = %s
|
|
594
605
|
AND state IN ('queued', 'consumed')
|
|
595
|
-
|
|
606
|
+
-- Uncorrelated subquery acquires the session lock once per statement.
|
|
607
|
+
AND (SELECT pg_try_advisory_lock(%s))
|
|
596
608
|
RETURNING message::text;
|
|
597
609
|
"""
|
|
598
610
|
),
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Queue snapshots; Prometheus is imported only when collecting metrics."""
|
|
2
|
+
|
|
3
|
+
from psycopg import sql
|
|
4
|
+
|
|
5
|
+
from .utils import transaction
|
|
6
|
+
|
|
7
|
+
STATES = ("queued", "consumed", "done", "rejected", "cancelled")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def statistics_query(schema="dramatiq", prefix=""):
|
|
11
|
+
return sql.SQL("""
|
|
12
|
+
SELECT queue_name,
|
|
13
|
+
count(*) FILTER (WHERE state = 'queued'),
|
|
14
|
+
count(*) FILTER (WHERE state = 'consumed'),
|
|
15
|
+
count(*) FILTER (WHERE state = 'done'),
|
|
16
|
+
count(*) FILTER (WHERE state = 'rejected'),
|
|
17
|
+
count(*) FILTER (WHERE state = 'cancelled'),
|
|
18
|
+
count(*) FILTER (WHERE state = 'queued' AND ready_at <= now()),
|
|
19
|
+
count(*) FILTER (WHERE state IN ('queued', 'consumed') AND ready_at > now()),
|
|
20
|
+
coalesce(greatest(0, extract(epoch FROM now() - min(ready_at)
|
|
21
|
+
FILTER (WHERE state = 'queued' AND ready_at <= now()))), 0)
|
|
22
|
+
FROM (
|
|
23
|
+
SELECT *, greatest(mtime,
|
|
24
|
+
to_timestamp(coalesce((message->'options'->>'eta')::double precision / 1000, 0))) AS ready_at
|
|
25
|
+
FROM {} WHERE (%s::text IS NULL OR queue_name = %s)
|
|
26
|
+
AND (%s::text[] IS NULL OR queue_name = ANY(%s::text[]))
|
|
27
|
+
) AS messages GROUP BY queue_name ORDER BY queue_name
|
|
28
|
+
""").format(sql.Identifier(schema, prefix + "queue"))
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _queue_statistics(pool, *, schema="dramatiq", prefix="", queue=None, queues=None):
|
|
32
|
+
with transaction(pool) as cursor:
|
|
33
|
+
cursor.execute(statistics_query(schema, prefix), (queue, queue, queues, queues))
|
|
34
|
+
rows = cursor.fetchall()
|
|
35
|
+
snapshots = [dict(queue=row[0], counts=dict(zip(STATES, row[1:6])),
|
|
36
|
+
ready=row[6], scheduled=row[7], oldest_ready_seconds=float(row[8]))
|
|
37
|
+
for row in rows]
|
|
38
|
+
if not snapshots and queue is not None:
|
|
39
|
+
snapshots.append(dict(queue=queue, counts=dict.fromkeys(STATES, 0),
|
|
40
|
+
ready=0, scheduled=0, oldest_ready_seconds=0.0))
|
|
41
|
+
return snapshots
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def queue_statistics(pool, *, schema="dramatiq", prefix="", queue=None):
|
|
45
|
+
return _queue_statistics(pool, schema=schema, prefix=prefix, queue=queue)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def domain_statistics(pool, *, domains=None, schema="dramatiq", prefix=""):
|
|
49
|
+
if domains is not None:
|
|
50
|
+
if isinstance(domains, str):
|
|
51
|
+
raise TypeError("domains must be an iterable of domain names, not a string")
|
|
52
|
+
domains = sorted(set(domains))
|
|
53
|
+
if any(not isinstance(name, str) or not name or name.endswith(".DQ") for name in domains):
|
|
54
|
+
raise ValueError("domains must contain nonempty names without the reserved .DQ suffix")
|
|
55
|
+
queues = None if domains is None else [queue for name in domains for queue in (name, name + ".DQ")]
|
|
56
|
+
snapshots = {}
|
|
57
|
+
for row in _queue_statistics(pool, schema=schema, prefix=prefix, queues=queues):
|
|
58
|
+
domain = row["queue"][:-3] if row["queue"].endswith(".DQ") else row["queue"]
|
|
59
|
+
result = snapshots.setdefault(domain, dict(domain=domain, counts=dict.fromkeys(STATES, 0),
|
|
60
|
+
ready=0, scheduled=0, oldest_ready_seconds=0.0))
|
|
61
|
+
for state in STATES:
|
|
62
|
+
result["counts"][state] += row["counts"][state]
|
|
63
|
+
result["ready"] += row["ready"]
|
|
64
|
+
result["scheduled"] += row["scheduled"]
|
|
65
|
+
result["oldest_ready_seconds"] = max(result["oldest_ready_seconds"], row["oldest_ready_seconds"])
|
|
66
|
+
for domain in domains or ():
|
|
67
|
+
snapshots.setdefault(domain, dict(domain=domain, counts=dict.fromkeys(STATES, 0),
|
|
68
|
+
ready=0, scheduled=0, oldest_ready_seconds=0.0))
|
|
69
|
+
return [snapshots[name] for name in sorted(snapshots)]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class PostgresQueueCollector:
|
|
73
|
+
"""Register in an exporter process; the caller owns the pool."""
|
|
74
|
+
|
|
75
|
+
metric_prefix = "iddqueue_queue"
|
|
76
|
+
label = "queue"
|
|
77
|
+
snapshot = staticmethod(queue_statistics)
|
|
78
|
+
|
|
79
|
+
def __init__(self, pool, *, schema="dramatiq", prefix="", queue=None):
|
|
80
|
+
self.pool = pool
|
|
81
|
+
self.options = dict(schema=schema, prefix=prefix, queue=queue)
|
|
82
|
+
|
|
83
|
+
def describe(self):
|
|
84
|
+
# Avoid database access during registry registration.
|
|
85
|
+
return iter(())
|
|
86
|
+
|
|
87
|
+
def collect(self):
|
|
88
|
+
from prometheus_client.core import GaugeMetricFamily
|
|
89
|
+
|
|
90
|
+
counts = GaugeMetricFamily(f"{self.metric_prefix}_messages", "Stored messages by state", labels=[self.label, "state"])
|
|
91
|
+
ready = GaugeMetricFamily(f"{self.metric_prefix}_ready", "Ready queued messages", labels=[self.label])
|
|
92
|
+
scheduled = GaugeMetricFamily(f"{self.metric_prefix}_scheduled", "Future delayed messages, including prefetched", labels=[self.label])
|
|
93
|
+
age = GaugeMetricFamily(f"{self.metric_prefix}_oldest_ready_seconds", "Age of oldest ready queued message", labels=[self.label])
|
|
94
|
+
for snapshot in self.snapshot(self.pool, **self.options):
|
|
95
|
+
queue = snapshot[self.label]
|
|
96
|
+
for state, count in snapshot["counts"].items():
|
|
97
|
+
counts.add_metric([queue, state], count)
|
|
98
|
+
ready.add_metric([queue], snapshot["ready"])
|
|
99
|
+
scheduled.add_metric([queue], snapshot["scheduled"])
|
|
100
|
+
age.add_metric([queue], snapshot["oldest_ready_seconds"])
|
|
101
|
+
yield from (counts, ready, scheduled, age)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class PostgresDomainCollector(PostgresQueueCollector):
|
|
105
|
+
"""Domain gauges; caller owns the pool, actor imports are not required."""
|
|
106
|
+
|
|
107
|
+
metric_prefix = "iddqueue_domain"
|
|
108
|
+
label = "domain"
|
|
109
|
+
snapshot = staticmethod(domain_statistics)
|
|
110
|
+
|
|
111
|
+
def __init__(self, pool, *, schema="dramatiq", prefix="", domains=None):
|
|
112
|
+
self.pool = pool
|
|
113
|
+
self.options = dict(schema=schema, prefix=prefix, domains=None if domains is None else tuple(domains))
|
|
114
|
+
if isinstance(domains, str):
|
|
115
|
+
raise TypeError("domains must be an iterable of domain names, not a string")
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Optional synchronous SQLAlchemy input for caller-owned transactions."""
|
|
2
|
+
|
|
3
|
+
import psycopg
|
|
4
|
+
from psycopg.pq import TransactionStatus
|
|
5
|
+
from sqlalchemy.engine import Connection
|
|
6
|
+
from sqlalchemy.orm import Session
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def enqueue_sqlalchemy(broker, message, *, connection, delay=None,
|
|
10
|
+
deduplication_key=None, deduplication_ttl=None):
|
|
11
|
+
"""Publish in an already active SQLAlchemy/Psycopg 3 transaction.
|
|
12
|
+
|
|
13
|
+
Does not flush, commit, rollback, close or retry the caller's transaction.
|
|
14
|
+
Multi-bind sessions should pass the intended Connection explicitly.
|
|
15
|
+
"""
|
|
16
|
+
if isinstance(connection, Session):
|
|
17
|
+
if not connection.is_active or not connection.in_transaction():
|
|
18
|
+
raise ValueError("Session requires an active transaction")
|
|
19
|
+
if connection.bind is None:
|
|
20
|
+
raise ValueError("Pass an explicit Connection for a multi-bind Session")
|
|
21
|
+
connection = connection.connection()
|
|
22
|
+
if not isinstance(connection, Connection):
|
|
23
|
+
raise TypeError("Expected a synchronous SQLAlchemy Connection or Session")
|
|
24
|
+
if connection.closed or connection.invalidated or not connection.in_transaction():
|
|
25
|
+
raise ValueError("Connection requires an active transaction")
|
|
26
|
+
dialect = connection.engine.dialect
|
|
27
|
+
if dialect.name != "postgresql" or dialect.driver != "psycopg" or dialect.is_async:
|
|
28
|
+
raise ValueError("Only synchronous postgresql+psycopg is supported")
|
|
29
|
+
driver = connection.connection.driver_connection
|
|
30
|
+
if not isinstance(driver, psycopg.Connection):
|
|
31
|
+
raise TypeError("Expected a synchronous Psycopg 3 driver connection")
|
|
32
|
+
if driver.info.transaction_status != TransactionStatus.INTRANS:
|
|
33
|
+
raise ValueError("Execute SQL or flush explicitly to start the database transaction")
|
|
34
|
+
return broker.enqueue_in_transaction(message, connection=driver, delay=delay,
|
|
35
|
+
deduplication_key=deduplication_key,
|
|
36
|
+
deduplication_ttl=deduplication_ttl)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "iddqueue"
|
|
3
|
-
version = "0.13.
|
|
3
|
+
version = "0.13.0rc4"
|
|
4
4
|
description = "Postgres Broker for Dramatiq Task Queue"
|
|
5
5
|
authors = [{name = "Étienne BERSAC"}]
|
|
6
6
|
license = "PostgreSQL"
|
|
@@ -21,6 +21,7 @@ Issues = "https://github.com/wa-pis/iddqueue/issues"
|
|
|
21
21
|
|
|
22
22
|
[project.optional-dependencies]
|
|
23
23
|
binary = ["psycopg[binary]>=3.3.6,<4"]
|
|
24
|
+
sqlalchemy = ["sqlalchemy>=2.0,<3"]
|
|
24
25
|
monitoring = ["dramatiq[prometheus]>=2.2.1,<3"]
|
|
25
26
|
|
|
26
27
|
[project.scripts]
|
|
@@ -30,7 +31,7 @@ iddqueue = "iddqueue.cli:entrypoint"
|
|
|
30
31
|
dev = [
|
|
31
32
|
"pytest>=8", "pytest-timeout>=2", "pytest-mock>=3",
|
|
32
33
|
"psycopg[binary]>=3.3.6,<4",
|
|
33
|
-
"docutils>=0.21", "pygments>=2.20", "ruff>=0.9",
|
|
34
|
+
"sqlalchemy[asyncio]>=2.0,<3", "docutils>=0.21", "pygments>=2.20", "ruff>=0.9",
|
|
34
35
|
]
|
|
35
36
|
|
|
36
37
|
fastapi-example = ["fastapi>=0.115,<1", "httpx>=0.28,<1", "uvicorn>=0.34,<1"]
|
|
@@ -1,68 +0,0 @@
|
|
|
1
|
-
"""Queue snapshots; Prometheus is imported only when collecting metrics."""
|
|
2
|
-
|
|
3
|
-
from psycopg import sql
|
|
4
|
-
|
|
5
|
-
from .utils import transaction
|
|
6
|
-
|
|
7
|
-
STATES = ("queued", "consumed", "done", "rejected", "cancelled")
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
def statistics_query(schema="dramatiq", prefix=""):
|
|
11
|
-
return sql.SQL("""
|
|
12
|
-
SELECT queue_name,
|
|
13
|
-
count(*) FILTER (WHERE state = 'queued'),
|
|
14
|
-
count(*) FILTER (WHERE state = 'consumed'),
|
|
15
|
-
count(*) FILTER (WHERE state = 'done'),
|
|
16
|
-
count(*) FILTER (WHERE state = 'rejected'),
|
|
17
|
-
count(*) FILTER (WHERE state = 'cancelled'),
|
|
18
|
-
count(*) FILTER (WHERE state = 'queued' AND ready_at <= now()),
|
|
19
|
-
count(*) FILTER (WHERE state IN ('queued', 'consumed') AND ready_at > now()),
|
|
20
|
-
coalesce(greatest(0, extract(epoch FROM now() - min(ready_at)
|
|
21
|
-
FILTER (WHERE state = 'queued' AND ready_at <= now()))), 0)
|
|
22
|
-
FROM (
|
|
23
|
-
SELECT *, greatest(mtime,
|
|
24
|
-
to_timestamp(coalesce((message->'options'->>'eta')::double precision / 1000, 0))) AS ready_at
|
|
25
|
-
FROM {} WHERE (%s::text IS NULL OR queue_name = %s)
|
|
26
|
-
) AS messages GROUP BY queue_name ORDER BY queue_name
|
|
27
|
-
""").format(sql.Identifier(schema, prefix + "queue"))
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
def queue_statistics(pool, *, schema="dramatiq", prefix="", queue=None):
|
|
31
|
-
with transaction(pool) as cursor:
|
|
32
|
-
cursor.execute(statistics_query(schema, prefix), (queue, queue))
|
|
33
|
-
rows = cursor.fetchall()
|
|
34
|
-
snapshots = [dict(queue=row[0], counts=dict(zip(STATES, row[1:6])),
|
|
35
|
-
ready=row[6], scheduled=row[7], oldest_ready_seconds=float(row[8]))
|
|
36
|
-
for row in rows]
|
|
37
|
-
if not snapshots and queue is not None:
|
|
38
|
-
snapshots.append(dict(queue=queue, counts=dict.fromkeys(STATES, 0),
|
|
39
|
-
ready=0, scheduled=0, oldest_ready_seconds=0.0))
|
|
40
|
-
return snapshots
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
class PostgresQueueCollector:
|
|
44
|
-
"""Register in an exporter process; the caller owns the pool."""
|
|
45
|
-
|
|
46
|
-
def __init__(self, pool, *, schema="dramatiq", prefix="", queue=None):
|
|
47
|
-
self.pool = pool
|
|
48
|
-
self.options = dict(schema=schema, prefix=prefix, queue=queue)
|
|
49
|
-
|
|
50
|
-
def describe(self):
|
|
51
|
-
# Avoid database access during registry registration.
|
|
52
|
-
return iter(())
|
|
53
|
-
|
|
54
|
-
def collect(self):
|
|
55
|
-
from prometheus_client.core import GaugeMetricFamily
|
|
56
|
-
|
|
57
|
-
counts = GaugeMetricFamily("iddqueue_queue_messages", "Stored messages by state", labels=["queue", "state"])
|
|
58
|
-
ready = GaugeMetricFamily("iddqueue_queue_ready", "Ready queued messages", labels=["queue"])
|
|
59
|
-
scheduled = GaugeMetricFamily("iddqueue_queue_scheduled", "Future delayed messages, including prefetched", labels=["queue"])
|
|
60
|
-
age = GaugeMetricFamily("iddqueue_queue_oldest_ready_seconds", "Age of oldest ready queued message", labels=["queue"])
|
|
61
|
-
for snapshot in queue_statistics(self.pool, **self.options):
|
|
62
|
-
queue = snapshot["queue"]
|
|
63
|
-
for state, count in snapshot["counts"].items():
|
|
64
|
-
counts.add_metric([queue, state], count)
|
|
65
|
-
ready.add_metric([queue], snapshot["ready"])
|
|
66
|
-
scheduled.add_metric([queue], snapshot["scheduled"])
|
|
67
|
-
age.add_metric([queue], snapshot["oldest_ready_seconds"])
|
|
68
|
-
yield from (counts, ready, scheduled, age)
|
|
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
|