iddqueue 0.13.0rc2__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.0rc2 → iddqueue-0.13.0rc4}/PKG-INFO +15 -10
- {iddqueue-0.13.0rc2 → iddqueue-0.13.0rc4}/README.md +12 -9
- {iddqueue-0.13.0rc2 → iddqueue-0.13.0rc4}/iddqueue/__init__.py +2 -0
- {iddqueue-0.13.0rc2 → iddqueue-0.13.0rc4}/iddqueue/broker.py +23 -11
- iddqueue-0.13.0rc4/iddqueue/domains.py +70 -0
- iddqueue-0.13.0rc4/iddqueue/metrics.py +115 -0
- iddqueue-0.13.0rc4/iddqueue/sqlalchemy.py +36 -0
- {iddqueue-0.13.0rc2 → iddqueue-0.13.0rc4}/pyproject.toml +3 -2
- iddqueue-0.13.0rc2/iddqueue/metrics.py +0 -68
- {iddqueue-0.13.0rc2 → iddqueue-0.13.0rc4}/LICENSE +0 -0
- {iddqueue-0.13.0rc2 → iddqueue-0.13.0rc4}/iddqueue/cancellation.py +0 -0
- {iddqueue-0.13.0rc2 → iddqueue-0.13.0rc4}/iddqueue/cancellation.sql +0 -0
- {iddqueue-0.13.0rc2 → iddqueue-0.13.0rc4}/iddqueue/cli.py +0 -0
- {iddqueue-0.13.0rc2 → iddqueue-0.13.0rc4}/iddqueue/control.py +0 -0
- {iddqueue-0.13.0rc2 → iddqueue-0.13.0rc4}/iddqueue/control.sql +0 -0
- {iddqueue-0.13.0rc2 → iddqueue-0.13.0rc4}/iddqueue/coordination.sql +0 -0
- {iddqueue-0.13.0rc2 → iddqueue-0.13.0rc4}/iddqueue/deduplication.sql +0 -0
- {iddqueue-0.13.0rc2 → iddqueue-0.13.0rc4}/iddqueue/failures.py +0 -0
- {iddqueue-0.13.0rc2 → iddqueue-0.13.0rc4}/iddqueue/history.py +0 -0
- {iddqueue-0.13.0rc2 → iddqueue-0.13.0rc4}/iddqueue/history.sql +0 -0
- {iddqueue-0.13.0rc2 → iddqueue-0.13.0rc4}/iddqueue/rate_limits.py +0 -0
- {iddqueue-0.13.0rc2 → iddqueue-0.13.0rc4}/iddqueue/results.py +0 -0
- {iddqueue-0.13.0rc2 → iddqueue-0.13.0rc4}/iddqueue/scheduler.py +0 -0
- {iddqueue-0.13.0rc2 → iddqueue-0.13.0rc4}/iddqueue/scheduler.sql +0 -0
- {iddqueue-0.13.0rc2 → iddqueue-0.13.0rc4}/iddqueue/schema.py +0 -0
- {iddqueue-0.13.0rc2 → iddqueue-0.13.0rc4}/iddqueue/schema.sql +0 -0
- {iddqueue-0.13.0rc2 → 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;
|
|
@@ -119,11 +121,13 @@ Close broker-owned pools on shutdown; callers close pools they supply.
|
|
|
119
121
|
|
|
120
122
|
## Guides and examples
|
|
121
123
|
|
|
124
|
+
[Domain actors and one worker bootstrap](docs/domains.md) are included since RC3.
|
|
125
|
+
|
|
122
126
|
- [Documentation index](docs/index.md), [user guide](docs/user-guide.md), [API](docs/api.md).
|
|
123
127
|
- [Detailed recipes](docs/recipes.md): transactions, middleware, controls, metrics and schedules.
|
|
124
128
|
- [FastAPI](docs/fastapi.md): lifespan, async endpoint thread offload and separate worker.
|
|
125
129
|
- [Deployment and retention](docs/deployment-guide.md), [why PostgreSQL](docs/why.md).
|
|
126
|
-
- [RC notes](docs/rc-0.13.
|
|
130
|
+
- [RC notes](docs/rc-0.13.0rc4.md), [changelog](CHANGELOG.md), [release checks](docs/release.md).
|
|
127
131
|
|
|
128
132
|
The broker and result APIs are synchronous. Async actors use Dramatiq's AsyncIO
|
|
129
133
|
middleware; FastAPI async endpoints offload publication to a thread.
|
|
@@ -138,27 +142,28 @@ is unverified. No tested Django integration is currently provided.
|
|
|
138
142
|
## Compared with dramatiq-pg
|
|
139
143
|
|
|
140
144
|
Baseline: [dramatiq-pg 0.12.0](https://pypi.org/project/dramatiq-pg/0.12.0/),
|
|
141
|
-
compared with the published IDDQueue 0.13.
|
|
145
|
+
compared with the published IDDQueue 0.13.0rc4 prerelease. This describes the published version, not every
|
|
142
146
|
future upstream revision. Both projects provide a PostgreSQL Dramatiq broker.
|
|
143
147
|
|
|
144
|
-
| Area | dramatiq-pg 0.12.0 | IDDQueue 0.13.
|
|
148
|
+
| Area | dramatiq-pg 0.12.0 | IDDQueue 0.13.0rc4 / practical benefit |
|
|
145
149
|
| --- | --- | --- |
|
|
146
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 |
|
|
151
|
+
| Actor initialization | Broker configured before actor imports | Opt-in Domain declarations import before DSN; explicit startup registration and domain queues |
|
|
147
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 |
|
|
148
153
|
| Pool lifecycle | Psycopg 2 pools | Lazy owned pools, explicit close, caller-owned pool support |
|
|
149
|
-
| 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 |
|
|
150
155
|
| Coordination | No PostgreSQL rate-limit/barrier backend | PostgreSQL rate limits, durable barriers and standard GroupCallbacks without Redis |
|
|
151
156
|
| Task operations | stats, purge, recover, flush | Failed-task inspection/targeted retry, pause/resume, cooperative cancellation and opt-in attempt history |
|
|
152
157
|
| Publication controls | Single-message enqueue | Deduplication keys/TTL and atomic batches of up to 1000; deduplication is not exactly-once execution |
|
|
153
158
|
| Scheduling | Per-message delay | Also a multi-process fixed-interval scheduler; no cron/calendar expressions |
|
|
154
|
-
| Observability | Basic CLI statistics |
|
|
159
|
+
| Observability | Basic CLI statistics | Queue/domain backlog and age, native error/retry counters, optional Prometheus collector and Grafana kit |
|
|
155
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 |
|
|
156
161
|
| Dramatiq integration | Broker/results implementation | Verified pipelines, groups, AsyncIO and standard middleware; these are Dramatiq features, not a replacement orchestration engine |
|
|
157
162
|
| Compatibility evidence | Historical upstream tests | CI on Python 3.10/3.13/3.14 × PostgreSQL 14/18, functional tests and installed-wheel checks |
|
|
158
163
|
|
|
159
164
|
See [migration from dramatiq-pg](docs/migration.md) for dependency, pool,
|
|
160
165
|
import, CLI and database changes, including backup and rollback. No throughput
|
|
161
|
-
advantage is claimed here. The 0.13.
|
|
166
|
+
advantage is claimed here. The 0.13.0rc4 prerelease is available on PyPI; the legacy Django
|
|
162
167
|
integration is archived and has not been verified with IDDQueue.
|
|
163
168
|
|
|
164
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;
|
|
@@ -98,11 +98,13 @@ Close broker-owned pools on shutdown; callers close pools they supply.
|
|
|
98
98
|
|
|
99
99
|
## Guides and examples
|
|
100
100
|
|
|
101
|
+
[Domain actors and one worker bootstrap](docs/domains.md) are included since RC3.
|
|
102
|
+
|
|
101
103
|
- [Documentation index](docs/index.md), [user guide](docs/user-guide.md), [API](docs/api.md).
|
|
102
104
|
- [Detailed recipes](docs/recipes.md): transactions, middleware, controls, metrics and schedules.
|
|
103
105
|
- [FastAPI](docs/fastapi.md): lifespan, async endpoint thread offload and separate worker.
|
|
104
106
|
- [Deployment and retention](docs/deployment-guide.md), [why PostgreSQL](docs/why.md).
|
|
105
|
-
- [RC notes](docs/rc-0.13.
|
|
107
|
+
- [RC notes](docs/rc-0.13.0rc4.md), [changelog](CHANGELOG.md), [release checks](docs/release.md).
|
|
106
108
|
|
|
107
109
|
The broker and result APIs are synchronous. Async actors use Dramatiq's AsyncIO
|
|
108
110
|
middleware; FastAPI async endpoints offload publication to a thread.
|
|
@@ -117,27 +119,28 @@ is unverified. No tested Django integration is currently provided.
|
|
|
117
119
|
## Compared with dramatiq-pg
|
|
118
120
|
|
|
119
121
|
Baseline: [dramatiq-pg 0.12.0](https://pypi.org/project/dramatiq-pg/0.12.0/),
|
|
120
|
-
compared with the published IDDQueue 0.13.
|
|
122
|
+
compared with the published IDDQueue 0.13.0rc4 prerelease. This describes the published version, not every
|
|
121
123
|
future upstream revision. Both projects provide a PostgreSQL Dramatiq broker.
|
|
122
124
|
|
|
123
|
-
| Area | dramatiq-pg 0.12.0 | IDDQueue 0.13.
|
|
125
|
+
| Area | dramatiq-pg 0.12.0 | IDDQueue 0.13.0rc4 / practical benefit |
|
|
124
126
|
| --- | --- | --- |
|
|
125
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
|
+
| Actor initialization | Broker configured before actor imports | Opt-in Domain declarations import before DSN; explicit startup registration and domain queues |
|
|
126
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 |
|
|
127
130
|
| Pool lifecycle | Psycopg 2 pools | Lazy owned pools, explicit close, caller-owned pool support |
|
|
128
|
-
| 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 |
|
|
129
132
|
| Coordination | No PostgreSQL rate-limit/barrier backend | PostgreSQL rate limits, durable barriers and standard GroupCallbacks without Redis |
|
|
130
133
|
| Task operations | stats, purge, recover, flush | Failed-task inspection/targeted retry, pause/resume, cooperative cancellation and opt-in attempt history |
|
|
131
134
|
| Publication controls | Single-message enqueue | Deduplication keys/TTL and atomic batches of up to 1000; deduplication is not exactly-once execution |
|
|
132
135
|
| Scheduling | Per-message delay | Also a multi-process fixed-interval scheduler; no cron/calendar expressions |
|
|
133
|
-
| Observability | Basic CLI statistics |
|
|
136
|
+
| Observability | Basic CLI statistics | Queue/domain backlog and age, native error/retry counters, optional Prometheus collector and Grafana kit |
|
|
134
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 |
|
|
135
138
|
| Dramatiq integration | Broker/results implementation | Verified pipelines, groups, AsyncIO and standard middleware; these are Dramatiq features, not a replacement orchestration engine |
|
|
136
139
|
| Compatibility evidence | Historical upstream tests | CI on Python 3.10/3.13/3.14 × PostgreSQL 14/18, functional tests and installed-wheel checks |
|
|
137
140
|
|
|
138
141
|
See [migration from dramatiq-pg](docs/migration.md) for dependency, pool,
|
|
139
142
|
import, CLI and database changes, including backup and rollback. No throughput
|
|
140
|
-
advantage is claimed here. The 0.13.
|
|
143
|
+
advantage is claimed here. The 0.13.0rc4 prerelease is available on PyPI; the legacy Django
|
|
141
144
|
integration is archived and has not been verified with IDDQueue.
|
|
142
145
|
|
|
143
146
|
## Development and support
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
from .broker import PostgresBroker
|
|
2
2
|
from .cancellation import ResultCancelled
|
|
3
|
+
from .domains import Domain
|
|
3
4
|
from .rate_limits import PostgresRateLimiterBackend
|
|
4
5
|
from .results import PostgresBackend
|
|
5
6
|
from .schema import generate_coordination_sql, generate_init_sql, generate_upgrade_sql
|
|
6
7
|
|
|
7
8
|
__all__ = [
|
|
9
|
+
"Domain",
|
|
8
10
|
"ResultCancelled",
|
|
9
11
|
"PostgresBackend",
|
|
10
12
|
"PostgresBroker",
|
|
@@ -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,70 @@
|
|
|
1
|
+
"""Framework-independent declarations, bound explicitly before worker startup."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
from dramatiq import Actor
|
|
6
|
+
from dramatiq.broker import Broker
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class _DeclarationBroker(Broker):
|
|
10
|
+
def __init__(self):
|
|
11
|
+
super().__init__(middleware=[])
|
|
12
|
+
|
|
13
|
+
def declare_queue(self, queue_name):
|
|
14
|
+
# Declarations create no transport resources.
|
|
15
|
+
pass
|
|
16
|
+
|
|
17
|
+
def enqueue(self, message, *, delay=None):
|
|
18
|
+
raise RuntimeError("Register the actor's Domain with a broker before sending")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Domain:
|
|
22
|
+
"""One queue and qualified actor names; register once per process."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, name):
|
|
25
|
+
if not isinstance(name, str) or not re.fullmatch(r"[a-zA-Z_][a-zA-Z0-9._-]*", name):
|
|
26
|
+
raise ValueError("domain name must be a valid Dramatiq queue name")
|
|
27
|
+
self.name = name
|
|
28
|
+
self._declarations = _DeclarationBroker()
|
|
29
|
+
self._broker = None
|
|
30
|
+
self._failed = False
|
|
31
|
+
|
|
32
|
+
def actor(self, fn=None, *, actor_name=None, priority=0, **options):
|
|
33
|
+
def decorate(fn):
|
|
34
|
+
if self._broker is not None or self._failed:
|
|
35
|
+
raise RuntimeError("Declare all domain actors before registration")
|
|
36
|
+
if "broker" in options or "queue_name" in options:
|
|
37
|
+
raise ValueError("Domain owns the broker and queue_name")
|
|
38
|
+
name = actor_name if actor_name is not None else fn.__name__
|
|
39
|
+
if not isinstance(name, str) or not name:
|
|
40
|
+
raise ValueError("actor_name must be a nonempty string")
|
|
41
|
+
return Actor(fn, broker=self._declarations, actor_name=f"{self.name}.{name}",
|
|
42
|
+
queue_name=self.name, priority=priority, options=dict(options))
|
|
43
|
+
return decorate if fn is None else decorate(fn)
|
|
44
|
+
|
|
45
|
+
def register(self, broker):
|
|
46
|
+
if self._failed:
|
|
47
|
+
raise RuntimeError("Registration failed; create a new Domain and broker")
|
|
48
|
+
if not isinstance(broker, Broker):
|
|
49
|
+
raise TypeError("register requires a Dramatiq Broker")
|
|
50
|
+
if broker is self._broker:
|
|
51
|
+
return
|
|
52
|
+
if self._broker is not None:
|
|
53
|
+
raise RuntimeError("Domain is already registered with another broker")
|
|
54
|
+
actors = list(self._declarations.actors.values())
|
|
55
|
+
for actor in actors:
|
|
56
|
+
invalid = set(actor.options) - broker.actor_options
|
|
57
|
+
if invalid:
|
|
58
|
+
raise ValueError(f"Undefined actor options for {actor.actor_name}: {sorted(invalid)}")
|
|
59
|
+
if actor.actor_name in broker.actors:
|
|
60
|
+
raise ValueError(f"Actor {actor.actor_name!r} is already registered")
|
|
61
|
+
try:
|
|
62
|
+
for actor in actors:
|
|
63
|
+
actor.broker = broker
|
|
64
|
+
broker.declare_actor(actor)
|
|
65
|
+
except Exception:
|
|
66
|
+
for actor in actors:
|
|
67
|
+
actor.broker = self._declarations
|
|
68
|
+
self._failed = True
|
|
69
|
+
raise
|
|
70
|
+
self._broker = broker
|
|
@@ -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
|