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.
Files changed (27) hide show
  1. {iddqueue-0.13.0rc3 → iddqueue-0.13.0rc4}/PKG-INFO +12 -10
  2. {iddqueue-0.13.0rc3 → iddqueue-0.13.0rc4}/README.md +9 -9
  3. {iddqueue-0.13.0rc3 → iddqueue-0.13.0rc4}/iddqueue/broker.py +23 -11
  4. iddqueue-0.13.0rc4/iddqueue/metrics.py +115 -0
  5. iddqueue-0.13.0rc4/iddqueue/sqlalchemy.py +36 -0
  6. {iddqueue-0.13.0rc3 → iddqueue-0.13.0rc4}/pyproject.toml +3 -2
  7. iddqueue-0.13.0rc3/iddqueue/metrics.py +0 -68
  8. {iddqueue-0.13.0rc3 → iddqueue-0.13.0rc4}/LICENSE +0 -0
  9. {iddqueue-0.13.0rc3 → iddqueue-0.13.0rc4}/iddqueue/__init__.py +0 -0
  10. {iddqueue-0.13.0rc3 → iddqueue-0.13.0rc4}/iddqueue/cancellation.py +0 -0
  11. {iddqueue-0.13.0rc3 → iddqueue-0.13.0rc4}/iddqueue/cancellation.sql +0 -0
  12. {iddqueue-0.13.0rc3 → iddqueue-0.13.0rc4}/iddqueue/cli.py +0 -0
  13. {iddqueue-0.13.0rc3 → iddqueue-0.13.0rc4}/iddqueue/control.py +0 -0
  14. {iddqueue-0.13.0rc3 → iddqueue-0.13.0rc4}/iddqueue/control.sql +0 -0
  15. {iddqueue-0.13.0rc3 → iddqueue-0.13.0rc4}/iddqueue/coordination.sql +0 -0
  16. {iddqueue-0.13.0rc3 → iddqueue-0.13.0rc4}/iddqueue/deduplication.sql +0 -0
  17. {iddqueue-0.13.0rc3 → iddqueue-0.13.0rc4}/iddqueue/domains.py +0 -0
  18. {iddqueue-0.13.0rc3 → iddqueue-0.13.0rc4}/iddqueue/failures.py +0 -0
  19. {iddqueue-0.13.0rc3 → iddqueue-0.13.0rc4}/iddqueue/history.py +0 -0
  20. {iddqueue-0.13.0rc3 → iddqueue-0.13.0rc4}/iddqueue/history.sql +0 -0
  21. {iddqueue-0.13.0rc3 → iddqueue-0.13.0rc4}/iddqueue/rate_limits.py +0 -0
  22. {iddqueue-0.13.0rc3 → iddqueue-0.13.0rc4}/iddqueue/results.py +0 -0
  23. {iddqueue-0.13.0rc3 → iddqueue-0.13.0rc4}/iddqueue/scheduler.py +0 -0
  24. {iddqueue-0.13.0rc3 → iddqueue-0.13.0rc4}/iddqueue/scheduler.sql +0 -0
  25. {iddqueue-0.13.0rc3 → iddqueue-0.13.0rc4}/iddqueue/schema.py +0 -0
  26. {iddqueue-0.13.0rc3 → iddqueue-0.13.0rc4}/iddqueue/schema.sql +0 -0
  27. {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.0rc3
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.0rc3](https://pypi.org/project/iddqueue/0.13.0rc3/)** —
31
- a release candidate for evaluation. [GitHub assets](https://github.com/wa-pis/iddqueue/releases/tag/v0.13.0rc3)
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.0rc3"
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.0rc3.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).
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.0rc3 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
144
146
  future upstream revision. Both projects provide a PostgreSQL Dramatiq broker.
145
147
 
146
- | Area | dramatiq-pg 0.12.0 | IDDQueue 0.13.0rc3 / practical benefit |
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 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 |
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 | 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 |
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.0rc3 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
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.0rc3](https://pypi.org/project/iddqueue/0.13.0rc3/)** —
10
- a release candidate for evaluation. [GitHub assets](https://github.com/wa-pis/iddqueue/releases/tag/v0.13.0rc3)
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.0rc3"
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.0rc3.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).
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.0rc3 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
123
123
  future upstream revision. Both projects provide a PostgreSQL Dramatiq broker.
124
124
 
125
- | Area | dramatiq-pg 0.12.0 | IDDQueue 0.13.0rc3 / practical benefit |
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 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 |
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 | 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 |
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.0rc3 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
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
- 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,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.0rc3"
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