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.
Files changed (27) hide show
  1. {iddqueue-0.13.0rc2 → iddqueue-0.13.0rc4}/PKG-INFO +15 -10
  2. {iddqueue-0.13.0rc2 → iddqueue-0.13.0rc4}/README.md +12 -9
  3. {iddqueue-0.13.0rc2 → iddqueue-0.13.0rc4}/iddqueue/__init__.py +2 -0
  4. {iddqueue-0.13.0rc2 → iddqueue-0.13.0rc4}/iddqueue/broker.py +23 -11
  5. iddqueue-0.13.0rc4/iddqueue/domains.py +70 -0
  6. iddqueue-0.13.0rc4/iddqueue/metrics.py +115 -0
  7. iddqueue-0.13.0rc4/iddqueue/sqlalchemy.py +36 -0
  8. {iddqueue-0.13.0rc2 → iddqueue-0.13.0rc4}/pyproject.toml +3 -2
  9. iddqueue-0.13.0rc2/iddqueue/metrics.py +0 -68
  10. {iddqueue-0.13.0rc2 → iddqueue-0.13.0rc4}/LICENSE +0 -0
  11. {iddqueue-0.13.0rc2 → iddqueue-0.13.0rc4}/iddqueue/cancellation.py +0 -0
  12. {iddqueue-0.13.0rc2 → iddqueue-0.13.0rc4}/iddqueue/cancellation.sql +0 -0
  13. {iddqueue-0.13.0rc2 → iddqueue-0.13.0rc4}/iddqueue/cli.py +0 -0
  14. {iddqueue-0.13.0rc2 → iddqueue-0.13.0rc4}/iddqueue/control.py +0 -0
  15. {iddqueue-0.13.0rc2 → iddqueue-0.13.0rc4}/iddqueue/control.sql +0 -0
  16. {iddqueue-0.13.0rc2 → iddqueue-0.13.0rc4}/iddqueue/coordination.sql +0 -0
  17. {iddqueue-0.13.0rc2 → iddqueue-0.13.0rc4}/iddqueue/deduplication.sql +0 -0
  18. {iddqueue-0.13.0rc2 → iddqueue-0.13.0rc4}/iddqueue/failures.py +0 -0
  19. {iddqueue-0.13.0rc2 → iddqueue-0.13.0rc4}/iddqueue/history.py +0 -0
  20. {iddqueue-0.13.0rc2 → iddqueue-0.13.0rc4}/iddqueue/history.sql +0 -0
  21. {iddqueue-0.13.0rc2 → iddqueue-0.13.0rc4}/iddqueue/rate_limits.py +0 -0
  22. {iddqueue-0.13.0rc2 → iddqueue-0.13.0rc4}/iddqueue/results.py +0 -0
  23. {iddqueue-0.13.0rc2 → iddqueue-0.13.0rc4}/iddqueue/scheduler.py +0 -0
  24. {iddqueue-0.13.0rc2 → iddqueue-0.13.0rc4}/iddqueue/scheduler.sql +0 -0
  25. {iddqueue-0.13.0rc2 → iddqueue-0.13.0rc4}/iddqueue/schema.py +0 -0
  26. {iddqueue-0.13.0rc2 → iddqueue-0.13.0rc4}/iddqueue/schema.sql +0 -0
  27. {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.0rc2
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.0rc2](https://pypi.org/project/iddqueue/0.13.0rc2/)** —
31
- a release candidate for evaluation. [GitHub assets](https://github.com/wa-pis/iddqueue/releases/tag/v0.13.0rc2)
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.0rc2"
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.0rc2.md), [changelog](CHANGELOG.md), [release checks](docs/release.md).
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.0rc2 prerelease. This describes the published version, not every
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.0rc2 / practical benefit |
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 API: commit business data and tasks together |
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 | Ready/scheduled backlog, task age and optional Prometheus collector |
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.0rc2 prerelease is available on PyPI; the legacy Django
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.0rc2](https://pypi.org/project/iddqueue/0.13.0rc2/)** —
10
- a release candidate for evaluation. [GitHub assets](https://github.com/wa-pis/iddqueue/releases/tag/v0.13.0rc2)
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.0rc2"
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.0rc2.md), [changelog](CHANGELOG.md), [release checks](docs/release.md).
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.0rc2 prerelease. This describes the published version, not every
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.0rc2 / practical benefit |
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 API: commit business data and tasks together |
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 | Ready/scheduled backlog, task age and optional Prometheus collector |
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.0rc2 prerelease is available on PyPI; the legacy Django
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
- payload = json.loads(notify.payload)
330
- if payload.get("scan"):
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=payload["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
- AND pg_try_advisory_lock(%s)
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.0rc2"
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