iddqueue 0.13.0rc1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,730 @@
1
+ Metadata-Version: 2.4
2
+ Name: iddqueue
3
+ Version: 0.13.0rc1
4
+ Summary: Postgres Broker for Dramatiq Task Queue
5
+ Keywords: postgres,task queue,dramatiq
6
+ Author: Étienne BERSAC
7
+ License-Expression: PostgreSQL
8
+ License-File: LICENSE
9
+ Requires-Dist: dramatiq>=2.2.1,<3
10
+ Requires-Dist: psycopg>=3.3.6,<4
11
+ Requires-Dist: psycopg-pool>=3.3.3,<4
12
+ Requires-Dist: tenacity>=9,<10
13
+ Requires-Dist: psycopg[binary]>=3.3.6,<4 ; extra == 'binary'
14
+ Requires-Dist: dramatiq[prometheus]>=2.2.1,<3 ; extra == 'monitoring'
15
+ Requires-Python: >=3.10, <4
16
+ Project-URL: Repository, https://github.com/wa-pis/iddqueue
17
+ Project-URL: Issues, https://github.com/wa-pis/iddqueue/issues
18
+ Provides-Extra: binary
19
+ Provides-Extra: monitoring
20
+ Description-Content-Type: text/markdown
21
+
22
+ # IDDQueue
23
+
24
+ [Dramatiq](https://dramatiq.io/) is a simple task queue implementation for
25
+ Python3. iddqueue provides a Postgres-based implementation of a dramatiq
26
+ broker.
27
+
28
+ IDDQueue is a fork of [DALIBO’s dramatiq-pg](https://gitlab.com/dalibo/dramatiq-pg)
29
+ ([original package on PyPI](https://pypi.org/project/dramatiq-pg/)).
30
+ It preserves the PostgreSQL license and original contributor credits.
31
+
32
+
33
+ See the [FastAPI integration guide](docs/fastapi.md) for lifespan, async endpoints
34
+ and a separate worker example.
35
+
36
+ ## Features
37
+
38
+ - PostgreSQL storage: one queue/results table plus optional feature tables, no ORM.
39
+ - Stores message payload and results as native JSONb.
40
+ - Uses LISTEN/NOTIFY for wakeups, plus startup/idle recovery scans.
41
+ - Implements delayed task.
42
+ - Reliable thanks to Postgres MVCC.
43
+ - Self-healing: automatic purge of old messages. Automatic recovery after
44
+ crash.
45
+ - Utility CLI for maintainance: flush, purge, stats, etc.
46
+
47
+ Note that dramatiq assumes tasks are idempotent. This broker makes the same
48
+ assumptions for recovering after a crash.
49
+
50
+
51
+ Renaming changes the distribution, Python imports and CLI to `iddqueue`.
52
+ Update application imports and deployment commands. Prometheus metrics now use
53
+ the `iddqueue_queue_` prefix; update dashboards. Renaming alone does not rename PostgreSQL tables. A full upstream upgrade
54
+ also changes storage and, for non-default namespaces, channels and lock domains;
55
+ follow the migration guide.
56
+
57
+ Task notifications contain only message IDs; authorized consumers fetch payloads
58
+ from SQL. See [notification upgrade guidance](docs/migration.md#security-and-limits)
59
+ when replacing an earlier revision; update all publishers and workers.
60
+
61
+ ## Compared with dramatiq-pg
62
+
63
+ Baseline: [dramatiq-pg 0.12.0](https://pypi.org/project/dramatiq-pg/0.12.0/),
64
+ compared with IDDQueue 0.13.0. This describes the published version, not every
65
+ future upstream revision. Both projects provide a PostgreSQL Dramatiq broker.
66
+
67
+ | Area | dramatiq-pg 0.12.0 | IDDQueue 0.13.0 / practical benefit |
68
+ | --- | --- | --- |
69
+ | 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 |
70
+ | 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 |
71
+ | Pool lifecycle | Psycopg 2 pools | Lazy owned pools, explicit close, caller-owned pool support |
72
+ | Transactional publication | Regular enqueue | Caller-owned transaction API: commit business data and tasks together |
73
+ | Coordination | No PostgreSQL rate-limit/barrier backend | PostgreSQL rate limits, durable barriers and standard GroupCallbacks without Redis |
74
+ | Task operations | stats, purge, recover, flush | Failed-task inspection/targeted retry, pause/resume, cooperative cancellation and opt-in attempt history |
75
+ | Publication controls | Single-message enqueue | Deduplication keys/TTL and atomic batches of up to 1000; deduplication is not exactly-once execution |
76
+ | Scheduling | Per-message delay | Also a multi-process fixed-interval scheduler; no cron/calendar expressions |
77
+ | Observability | Basic CLI statistics | Ready/scheduled backlog, task age and optional Prometheus collector |
78
+ | 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 |
79
+ | Dramatiq integration | Broker/results implementation | Verified pipelines, groups, AsyncIO and standard middleware; these are Dramatiq features, not a replacement orchestration engine |
80
+ | Compatibility evidence | Historical upstream tests | CI on Python 3.10/3.13/3.14 × PostgreSQL 14/18, functional tests and installed-wheel checks |
81
+
82
+ See [migration from dramatiq-pg](docs/migration.md) for dependency, pool,
83
+ import, CLI and database changes, including backup and rollback. No throughput
84
+ advantage is claimed here. PyPI publication is pending; the upstream Django
85
+ integration has not been verified with IDDQueue.
86
+
87
+ ## Installation
88
+
89
+ - Install the locally built wheel (PyPI publication is pending):
90
+ ``` console
91
+ $ pip install "dist/iddqueue-0.13.0-py3-none-any.whl[binary]"
92
+ ```
93
+ Requires Python 3.10+, Dramatiq 2.2.1+ and Psycopg 3.3.6+.
94
+ - Init database schema with `init` command.
95
+ ``` console
96
+ $ iddqueue init
97
+ ```
98
+ For existing storage, stop participants and run `iddqueue upgrade`; raw `schema.sql` alone does not install all feature storage.
99
+ - Before importing actors, define global broker with a connection
100
+ pool:
101
+ ``` python
102
+ import dramatiq
103
+ from iddqueue import PostgresBroker
104
+
105
+ dramatiq.set_broker(PostgresBroker(url="postgresql://localhost/postgres"))
106
+
107
+ @dramatiq.actor
108
+ def myactor():
109
+ ...
110
+ ```
111
+
112
+ Now declare/import actors and manage worker just like any [dramatiq
113
+ setup](https://dramatiq.io/guide.html). See the local [example](example.py)
114
+ and [documentation](docs/index.rst).
115
+
116
+ The CLI tool `iddqueue` manages queues and failed tasks. See `--help`.
117
+
118
+ ## Integration
119
+
120
+ The upstream [django-dramatiq-pg](https://github.com/uptick/django-dramatiq-pg/)
121
+ integration by Curtis Maloney targets the original package. Compatibility with
122
+ IDDQueue has not been verified.
123
+
124
+ ## Support
125
+
126
+ Compatibility and API stability: [SUPPORT](SUPPORT.md).
127
+ Contribution steps: [CONTRIBUTING](CONTRIBUTING.md). Release checks: [guide](docs/release.md).
128
+ User changes: [CHANGELOG](CHANGELOG.md).
129
+
130
+ Report issues in [wa-pis/iddqueue](https://github.com/wa-pis/iddqueue/issues).
131
+ The repository is currently private; access is required.
132
+ IDDQueue is available under the PostgreSQL licence.
133
+
134
+
135
+ ## Credit
136
+
137
+ Thanks to all contributors :
138
+
139
+ - Andy Freeland
140
+ - Curtis Maloney, Django support.
141
+ - Federico Caselli, bugfixes.
142
+ - Giuseppe Papallo, bugfixes.
143
+ - Rafal Kwasny, improvements.
144
+
145
+
146
+ The upstream logo was created by [Damien CAZEILS](http://www.damiencazeils.com/).
147
+
148
+
149
+ ## Development
150
+
151
+ ```console
152
+ uv sync --locked --extra binary --extra monitoring
153
+ uv run --locked --extra binary --extra monitoring iddqueue init
154
+ uv run --locked --extra binary --extra monitoring python tests/pypsql < tests/func/schema.sql
155
+ uv run --locked --extra binary --extra monitoring pytest tests/unit tests/func
156
+ ```
157
+
158
+ Configure the test database with `PGHOST`, `PGPORT`, `PGUSER`, `PGPASSWORD`
159
+ and `PGDATABASE`. Tests terminate database connections and restart workers;
160
+ use a dedicated test database. `docker-compose.yml` provides PostgreSQL 18.
161
+
162
+ Version 0.13 uses Psycopg 3 pools; Psycopg 2 pools are no longer supported.
163
+ Broker-created pools open on first use and default to zero idle connections.
164
+ Call `broker.close()` on shutdown. If you supply a pool, close it yourself
165
+ and create it separately in each worker process.
166
+
167
+
168
+ ## Transactional publishing
169
+
170
+ Use `enqueue_in_transaction` to publish a task atomically with application
171
+ changes in the same PostgreSQL database:
172
+
173
+ ```python
174
+ import psycopg
175
+
176
+ with psycopg.connect(dsn) as connection:
177
+ with connection.transaction():
178
+ connection.execute("UPDATE orders SET status = %s WHERE id = %s",
179
+ ("confirmed", order_id))
180
+ broker.enqueue_in_transaction(send_receipt.message(order_id),
181
+ connection=connection)
182
+ ```
183
+
184
+ The connection must already have an active transaction. The broker does not
185
+ commit, roll back, close, or retry that transaction. PostgreSQL makes the task
186
+ and its notification visible on commit; rollback cancels both. `delay` is in
187
+ milliseconds, measured from enqueue time. Enqueue middleware hooks run around
188
+ the SQL operation: `after_enqueue` does not mean the outer transaction has
189
+ committed. Workers still provide at-least-once delivery.
190
+
191
+ ### PostgreSQL limiters and group callbacks
192
+
193
+ Existing installations must add the coordination table before enabling this
194
+ backend. Fresh `iddqueue init` installations include it:
195
+
196
+ ```python
197
+ import psycopg
198
+ from iddqueue import generate_coordination_sql
199
+
200
+ with psycopg.connect("postgresql://localhost/app") as connection:
201
+ connection.execute(generate_coordination_sql(schema="dramatiq", prefix=""))
202
+ ```
203
+
204
+ The upgrade is idempotent and preserves queue data. To reverse it, first stop
205
+ users of the coordination backend, then drop only `dramatiq.coordination`
206
+ (adjust schema and prefix when customized).
207
+
208
+ ```python
209
+ from dramatiq.middleware import GroupCallbacks
210
+ from dramatiq.rate_limits import ConcurrentRateLimiter
211
+ from iddqueue import PostgresRateLimiterBackend
212
+
213
+ limits = PostgresRateLimiterBackend(pool=broker.pool)
214
+ broker.add_middleware(GroupCallbacks(limits, barrier_ttl=900_000))
215
+
216
+ with ConcurrentRateLimiter(limits, "external-api", limit=5).acquire():
217
+ call_external_api()
218
+ ```
219
+
220
+ The backend also supports `BucketRateLimiter`, `WindowRateLimiter` and `Barrier`.
221
+ Durations are milliseconds, except Dramatiq's sliding `window` in seconds.
222
+ Use unique UUID barrier keys. Events survive missed notifications until their
223
+ TTL expires. `wait` holds one pool connection; size the pool for simultaneous
224
+ waiters and publishers. Schedule `limits.purge()` periodically to remove
225
+ expired coordination rows. `close()` closes only a pool owned by the backend.
226
+
227
+ Choose a TTL longer than the longest operation: an expired concurrency slot
228
+ can be acquired while its original task is still running. Standard
229
+ `GroupCallbacks` counts successful deliveries; repeated deliveries can contribute
230
+ again and callbacks are not guaranteed exactly once. A group with failed tasks
231
+ may never reach its barrier, and expired group state cannot reconstruct progress.
232
+
233
+ ### Standard Dramatiq composition and middleware
234
+
235
+ The broker supports the standard `dramatiq.pipeline` and `dramatiq.group` APIs.
236
+ Enable result storage on each actor whose result you want to retrieve:
237
+
238
+ ```python
239
+ import dramatiq
240
+
241
+ @dramatiq.actor(store_results=True)
242
+ def multiply(value, factor=2):
243
+ return value * factor
244
+
245
+ chain = dramatiq.pipeline([multiply.message(3), multiply.message(4)])
246
+ chain.run()
247
+ assert chain.get_result(block=True) == 24
248
+
249
+ batch = dramatiq.group([multiply.message(3), multiply.message(4)])
250
+ batch.run()
251
+ assert list(batch.get_results(block=True)) == [6, 8]
252
+ assert batch.completed_count == 2
253
+ ```
254
+
255
+ Pipelines append a predecessor's result to the next actor's positional arguments.
256
+ A failed step does not enqueue its successor. `get_results` returns group results
257
+ in submission order; tasks may finish in another order. `completed_count` reads
258
+ stored results, so actors without result storage are not counted and expired
259
+ results no longer count. Terminal failures raise `ResultFailure`.
260
+
261
+ For coroutine actors, add Dramatiq's standard `AsyncIO` middleware before
262
+ declaring the actors, in the module loaded by every worker:
263
+
264
+ ```python
265
+ import asyncio
266
+ from dramatiq.middleware import AsyncIO
267
+
268
+ broker.add_middleware(AsyncIO())
269
+
270
+ @dramatiq.actor(store_results=True)
271
+ async def async_task(value):
272
+ await asyncio.sleep(0.01)
273
+ return value
274
+ ```
275
+
276
+ The PostgreSQL broker remains synchronous. Use async clients or
277
+ `asyncio.to_thread` for blocking I/O inside coroutine actors.
278
+
279
+ The default `Retries` middleware supports `on_retry_exhausted` (singular):
280
+
281
+ ```python
282
+ @dramatiq.actor
283
+ def report_failure(message, retry_metadata):
284
+ # message is a serialized Dramatiq message; metadata has
285
+ # retries and max_retries. Make any external action idempotent.
286
+ print(message["message_id"], retry_metadata)
287
+
288
+ @dramatiq.actor(max_retries=3, on_retry_exhausted="report_failure")
289
+ def unreliable_task():
290
+ raise RuntimeError("unavailable")
291
+ ```
292
+
293
+
294
+ `Actor.send_with_options(delay=timedelta(seconds=1))` accepts a `timedelta`;
295
+ Dramatiq converts it to milliseconds before calling the broker. Direct broker
296
+ methods use millisecond delays. A delay specifies the earliest execution time,
297
+ not an exact schedule.
298
+
299
+ Smaller actor `priority` numbers run first among messages already prefetched by
300
+ a worker. PostgreSQL does not provide global priority ordering across workers
301
+ or all queued messages. Ordinary groups need only Results; completion callbacks
302
+ additionally require `GroupCallbacks` and the PostgreSQL coordination table
303
+ shown above. Pipelines, retries and callbacks retain at-least-once delivery.
304
+
305
+ ### Inspect and retry failed tasks
306
+
307
+ The broker records the last exception type, up to 2,000 characters of its text,
308
+ UTC timestamp and attempt number in `message.options.pg_failure`. The standard
309
+ Retries metadata remains available. Intermediate retries carry this metadata;
310
+ a successful attempt removes the last error. Existing rejected rows may have
311
+ no diagnostic metadata; no database migration is required.
312
+
313
+ ```sh
314
+ iddqueue failed list --queue default --actor send_receipt --limit 50
315
+ iddqueue failed list --queue default --after MESSAGE_ID
316
+ iddqueue failed show MESSAGE_ID
317
+ iddqueue failed show MESSAGE_ID --payload
318
+ iddqueue retry MESSAGE_ID
319
+ ```
320
+
321
+ These commands produce JSON. `failed list` returns `items` and `next_after`;
322
+ pass that cursor as `--after` with the same filters to continue. The default
323
+ page size is 50, with a maximum of 1,000. Pages use UUID order; concurrently
324
+ rejected tasks may require a fresh scan. `show` returns a nonzero exit code for
325
+ missing or non-rejected messages. Arguments, full options and traceback are
326
+ excluded unless `--payload` is explicitly requested. Exception text itself can
327
+ contain application values.
328
+
329
+ `retry` accepts only a rejected task whose worker has released its message lock.
330
+ It atomically preserves ID, actor and arguments, clears the old result and
331
+ retry-cycle fields (`retries`, `traceback`, `requeue_timestamp`, `eta`,
332
+ `pg_failure`), and publishes immediately to the normal queue. Other options,
333
+ including retry policy, remain unchanged. Refused retries return a nonzero exit
334
+ code; if the worker is still finishing rejection, retry after it releases the
335
+ lock. Concurrent retry requests allow only one transition to queued.
336
+
337
+ Use `--schemaname` and `--prefix` before the command for customized tables.
338
+ Diagnostic metadata is saved with queue state; tasks lost before acknowledgment
339
+ may not have it. Purge removes rejected tasks according to existing retention.
340
+ Only the latest error is retained. Retrying requires idempotent actors; it does
341
+ not reverse prior side effects or reset barriers and downstream pipelines.
342
+
343
+ ### PostgreSQL queue metrics
344
+
345
+ `iddqueue stats` keeps its original state totals. Use `stats --json` for
346
+ per-queue snapshots, or `stats --queue default` for one queue (including zeros
347
+ when empty). Each snapshot includes all five stored-state counts, `ready`,
348
+ `scheduled` and `oldest_ready_seconds`.
349
+
350
+ Ready backlog includes only queued messages whose ETA has passed. Scheduled
351
+ messages include future ETAs in queued or consumed state, since Dramatiq can
352
+ prefetch delayed messages. Consumed means claimed/prefetched, not necessarily
353
+ executing. Delayed queue names remain separate (for example `default.DQ`).
354
+ Age starts at the later of the current enqueue time and ETA. Re-enqueue/recovery
355
+ resets enqueue time; original message timestamps do not measure the current
356
+ attempt. Existing queued rows use their existing `mtime`; no migration is needed.
357
+
358
+ Install `iddqueue[monitoring]` to enable Prometheus. For standard processing,
359
+ retry and duration metrics, add middleware in the worker's actor module:
360
+
361
+ ```python
362
+ from dramatiq.middleware.prometheus import Prometheus
363
+
364
+ broker.add_middleware(Prometheus())
365
+ ```
366
+
367
+ Its default endpoint is port 9191; Dramatiq supports `dramatiq_prom_host`,
368
+ `dramatiq_prom_port` and `dramatiq_prom_db`. The test example enables it only
369
+ when `EXAMPLE_PROMETHEUS=1` is set.
370
+
371
+ Register the PostgreSQL collector in a separate exporter process. Its pool
372
+ belongs to that process; keep it separate from worker multiprocessing state:
373
+
374
+ ```python
375
+ from threading import Event
376
+ from prometheus_client import CollectorRegistry, start_http_server
377
+ from iddqueue.metrics import PostgresQueueCollector
378
+ from iddqueue.utils import make_pool
379
+
380
+ pool = make_pool("postgresql://localhost/app")
381
+ registry = CollectorRegistry()
382
+ registry.register(PostgresQueueCollector(pool))
383
+ start_http_server(9192, addr="127.0.0.1", registry=registry)
384
+ try:
385
+ Event().wait()
386
+ finally:
387
+ pool.close()
388
+ ```
389
+
390
+ SQL metrics are `iddqueue_queue_messages` (queue/state),
391
+ `iddqueue_queue_ready`, `iddqueue_queue_scheduled` and
392
+ `iddqueue_queue_oldest_ready_seconds` (queue only). No message IDs or actor
393
+ arguments become labels. A collector may select one `queue`, `schema` or `prefix`.
394
+ Removed queues disappear from an unfiltered scrape; an explicitly selected
395
+ empty queue returns zero. Scrape errors propagate instead of returning false
396
+ zero backlog. The main broker does not import or require Prometheus.
397
+
398
+ Each scrape performs an aggregate over stored rows, including retained done
399
+ and rejected messages. Start with a 30–60 second scrape interval and measure on
400
+ your workload. A local PostgreSQL 14 test with 10,000 rows and 1 KB payloads took
401
+ about 2.85 ms for all queues and 1.08 ms for one queue using a sequential scan;
402
+ these are sample measurements, not production guarantees. Retention, payload
403
+ size and queue count affect cost; no additional index was justified by that test.
404
+
405
+ ### Application storage isolation
406
+
407
+ Use a distinct `(schema, prefix)` pair for each application sharing one database:
408
+
409
+ ```python
410
+ from iddqueue import PostgresBroker
411
+
412
+ first = PostgresBroker(schema="first_app", prefix="jobs_")
413
+ second = PostgresBroker(schema="second_app", prefix="jobs_")
414
+ ```
415
+
416
+ Initialize each pair with `generate_init_sql(schema, prefix)` or the CLI's
417
+ `--schemaname` and `--prefix`. SQL, enqueue/ack/result notifications and message
418
+ locks use that same storage area. Identical queue names and UUIDs can be processed
419
+ independently in different areas, including large payloads fetched by ID.
420
+ Coordination backends and metrics collectors must use matching schema/prefix.
421
+ For shared pools, configure each backend explicitly with the same pair.
422
+
423
+ Results remain keyed by the message UUID. Dramatiq's logical `namespace` option
424
+ does not change SQL storage. `use_namespace_prefix_keys=True` raises `ValueError`;
425
+ use schema/prefix rather than a string key format for application isolation.
426
+ Different queue names alone do not isolate results with identical UUIDs.
427
+
428
+ The default area (`dramatiq`, empty prefix) retains existing short channel names
429
+ and message locks. Other areas use stable hashed channels bounded to 63 bytes;
430
+ long default queue names also use bounded channels. No schema migration is needed.
431
+
432
+ Before upgrading non-default areas (or long default queue names), stop producers,
433
+ drain or gracefully stop every worker using that area, and stop result waiters.
434
+ Upgrade all participants together, then restart workers, result readers and
435
+ producers with matching configurations. Do not mix old and new versions: their
436
+ channels and advisory locks differ. Persisted queued tasks and results remain in
437
+ the same tables; restarted workers recover queued tasks from those tables.
438
+ Rollback follows the same stop-and-restart sequence. This is application storage
439
+ separation, not PostgreSQL permissions; use database roles for access control.
440
+
441
+
442
+ ### License and attribution
443
+
444
+ This project is a fork of [DALIBO's dramatiq-pg](https://gitlab.com/dalibo/dramatiq-pg),
445
+ originally credited to Étienne BERSAC and other upstream contributors.
446
+ The original `Copyright (c) 2019, DALIBO` and full [LICENSE](LICENSE) are preserved
447
+ in source, wheel and sdist. Package metadata identifies the PostgreSQL License.
448
+
449
+ The PostgreSQL License permits use, modification and distribution for any
450
+ purpose, including commercial use, provided the copyright notice and full
451
+ license text accompany distributed copies. See the
452
+ [official license text](https://www.postgresql.org/about/licence/).
453
+ Dependencies retain their own licenses. These permissions do not guarantee
454
+ that every possible legal claim is excluded. Publication and project naming
455
+ remain separate decisions.
456
+
457
+ After building, run `python scripts/check_license.py dist/*.whl dist/*.tar.gz`
458
+ to verify the preserved upstream text and license metadata in both formats.
459
+
460
+ ### Deduplicated publishing
461
+
462
+ Upgrade existing storage with `iddqueue upgrade` (or
463
+ `generate_upgrade_sql(schema, prefix)`) before using deduplication.
464
+ Fresh `init` includes the table. Stop workers during schema upgrades.
465
+
466
+ Call the broker explicitly; these keywords are broker API parameters, not
467
+ Dramatiq actor options:
468
+
469
+ ```python
470
+ message = broker.enqueue(
471
+ send_receipt.message(order_id),
472
+ deduplication_key=f"receipt:{order_id}",
473
+ deduplication_ttl=60_000,
474
+ )
475
+ ```
476
+
477
+ The TTL is a positive integer in milliseconds measured by PostgreSQL from the
478
+ key claim. The key belongs to the logical queue (normal and delayed share it)
479
+ within the configured schema/prefix. Concurrent calls return the original
480
+ message, including its ID, arguments and delay; a duplicate does not overwrite
481
+ the task, emit another notification or run enqueue hooks again. Use the returned
482
+ message when requesting Results. After TTL expires, the key can publish again.
483
+
484
+ The same parameters work with `enqueue_in_transaction(..., connection=conn)`.
485
+ A savepoint makes the key and task atomic when the caller catches an error.
486
+ Outer rollback removes both; notifications become visible only after commit.
487
+ As with ordinary transactional enqueue, hooks describe the SQL operation, not
488
+ the final outer commit. Retry middleware continues to use ordinary enqueue.
489
+
490
+ Queue purge does not release a live key: duplicates still return the original
491
+ message even if its row/result has been removed. Deduplication retains the
492
+ original message payload until the key is replaced or its expired row is
493
+ removed. It prevents duplicate publication; workers retain at-least-once
494
+ delivery, and actor side effects must remain idempotent. For workloads with
495
+ many unique keys, remove expired deduplication rows periodically with SQL;
496
+ only delete rows whose `expires_at <= clock_timestamp()`.
497
+
498
+
499
+ ### Pause and resume queues
500
+
501
+ Run `iddqueue upgrade` on existing storage first. Enable queue control in every
502
+ worker broker for that storage area:
503
+
504
+ ```python
505
+ broker = PostgresBroker(queue_control=True)
506
+ broker.pause_queue("emails")
507
+ broker.resume_queue("emails")
508
+ assert not broker.queue_is_paused("emails")
509
+ ```
510
+
511
+ ```sh
512
+ iddqueue pause emails
513
+ iddqueue queue-status emails
514
+ iddqueue resume emails
515
+ ```
516
+
517
+ CLI commands return JSON; schema/prefix flags precede the command.
518
+ Control is opt-in to preserve operation against databases without the new table.
519
+ Every participating worker must enable it; a worker without queue control
520
+ ignores pause. Stop all participants for the upgrade and restart them with
521
+ matching configuration. The example enables it with `EXAMPLE_QUEUE_CONTROL=1`.
522
+
523
+ Pause persists across worker restarts. Publishing continues; both the normal
524
+ and delayed queue stop starting actors, and prefetched tasks return to queued.
525
+ Their ETA and retry budget remain intact; pause does not produce a Results
526
+ value or trigger terminal skip callbacks. Already authorized actors finish
527
+ normally. The start boundary is the commit of the SQL permission gate directly
528
+ before actor hooks; pause serializes with this gate, not the Python function's
529
+ first instruction. Gate database failures defer execution instead of allowing it.
530
+
531
+ Resume is idempotent and notifies both queue listeners to scan durable rows.
532
+ It also handles a resume racing with acknowledgment of a deferred message.
533
+ Pausing one logical queue affects neither other queues nor other schema/prefix
534
+ areas. Queue control middleware must remain first in the middleware list;
535
+ place additional middleware after it.
536
+
537
+
538
+ ### Cancel tasks
539
+
540
+ Run `iddqueue upgrade` with workers stopped before upgrading this version:
541
+ the queue gains `started`/`cancel_requested` columns and a `cancelled` enum
542
+ value. Restart all participants together; enable `queue_control=True` on every
543
+ worker to protect prefetched tasks and serialize cancellation with actor start.
544
+
545
+ ```python
546
+ outcome = broker.cancel(message.message_id)
547
+ status = broker.cancellation_status(message.message_id)
548
+ ```
549
+
550
+ ```sh
551
+ iddqueue cancel MESSAGE_ID
552
+ iddqueue cancel-status MESSAGE_ID
553
+ ```
554
+
555
+ Cancel returns JSON-compatible `status` and `state`: `cancelled` for a task
556
+ cancelled before its start permission, `requested` for an already started task,
557
+ `terminal` for done/rejected, or `missing` (CLI exits nonzero).
558
+ Repeated cancellation is idempotent. Cancel-status includes `state` and
559
+ `requested`. Existing completed Results remain intact.
560
+
561
+ Queued, delayed and prefetched cancellations never call the actor. Results
562
+ raises `iddqueue.ResultCancelled` (a `ResultFailure` subclass), including for
563
+ a waiter already blocked when cancellation commits. No retry/ack/nack/recover
564
+ operation may revive a cancelled row. Queue statistics expose the additional
565
+ `cancelled` state; purge uses the same retention policy as done/rejected.
566
+ After the cancellation row is purged, Results follows ordinary missing semantics.
567
+
568
+ Running actors are not interrupted. They may check the request between chunks,
569
+ using standard `CurrentMessage` middleware:
570
+
571
+ ```python
572
+ from dramatiq.middleware import CurrentMessage
573
+
574
+ broker.add_middleware(CurrentMessage())
575
+
576
+ @dramatiq.actor(store_results=True)
577
+ def work():
578
+ message = CurrentMessage.get_current_message()
579
+ for chunk in chunks():
580
+ if broker.cancellation_requested(message.message_id):
581
+ return {"stopped": True}
582
+ process(chunk)
583
+ ```
584
+
585
+ An actor that returns after cooperative cleanup completes normally with its
586
+ returned result. If a requested task retries, the next start gate cancels that
587
+ attempt. Do not reset cancellation by reusing its UUID; publish a new message
588
+ for a fresh execution. Deduplication may return a cancelled original until its
589
+ key TTL expires. At-least-once side effects still require idempotency.
590
+
591
+ ### Standard middleware compatibility
592
+
593
+ IDDQueue uses Dramatiq's standard middleware implementations. AgeLimit,
594
+ TimeLimit, ShutdownNotifications and Callbacks are included in Dramatiq's
595
+ default middleware list; CurrentMessage is opt-in.
596
+
597
+ - Set actor/message `max_age` in milliseconds to reject expired messages.
598
+ Age is measured from the original message timestamp, including retries.
599
+ The actor is skipped, the row becomes rejected, its lock is released, and
600
+ stored Results report the skip as a failure.
601
+ - Set `time_limit` in milliseconds to interrupt a CPU-bound actor on supported
602
+ CPython. The exception follows the ordinary retry budget and Results path.
603
+ This is not a hard wall-clock deadline: interrupts wait for Python/GIL
604
+ execution and cannot cancel blocking system calls.
605
+ - Set `notify_shutdown=True` to receive `Shutdown` during worker shutdown.
606
+ Catch it for cleanup. Returning completes normally; raising it follows
607
+ ordinary failure/retry handling. Cleanup must remain idempotent.
608
+ - `on_success` sends `(original_message_dict, result)` to a callback actor.
609
+ `on_failure` sends `(original_message_dict, {"type": ..., "message": ...})`
610
+ on **each failed attempt**, including attempts that will retry.
611
+ Use `on_retry_exhausted` for a callback specifically on exhausted retries.
612
+ Callback side effects are subject to at-least-once delivery.
613
+ - Add `CurrentMessage()` to access the current message ID/options from an
614
+ actor. Its context is cleared after processing, including actor failures;
615
+ calls outside actor processing return `None`.
616
+
617
+ The integration suite checks actual PostgreSQL queue states, Results and
618
+ released advisory locks. Shutdown coverage runs the normal Dramatiq CLI in
619
+ separate processes and sends SIGTERM; it verifies actor cleanup and completion.
620
+
621
+ ### Attempt history
622
+
623
+ Run `iddqueue upgrade` in each schema/prefix before enabling
624
+ `PostgresBroker(attempt_history=True)`. History is disabled by default.
625
+ Each actor execution gets a separate UUID, including retries of the same
626
+ message. Records contain actor/queue names, PostgreSQL start/finish times,
627
+ `successful` or `failed`, duration, and error type/text (at most 2000 characters).
628
+ Arguments, options and results are never stored in history. Exception text can
629
+ still contain application data.
630
+
631
+ ```sh
632
+ iddqueue history list MESSAGE_UUID --limit 50
633
+ iddqueue history list MESSAGE_UUID --limit 50 --after ATTEMPT_UUID
634
+ iddqueue history purge --maxage '30 days'
635
+ # Global --schemaname / --prefix select the storage namespace.
636
+ ```
637
+
638
+ Listing uses UUID cursor order, not chronological order; timestamps identify
639
+ execution order. `next_after` is null on the last page. An attempt without a
640
+ finish record is reported as `incomplete`: it may still be running, or the worker
641
+ may have died. A later execution creates a new record and preserves that entry.
642
+ Skips before actor execution (including pause/cancel/age expiry) create no record.
643
+ History is diagnostic middleware, not an atomic audit of actor side effects:
644
+ Dramatiq logs middleware database failures, and an unavailable database can leave
645
+ gaps or incomplete entries.
646
+
647
+ Retention is explicit: schedule `history purge` yourself with a positive
648
+ PostgreSQL interval. It deletes attempts by start time, including old incomplete
649
+ entries, independently of queued messages and Results. There is no automatic
650
+ cleanup thread. Enabling history adds two database transactions per execution.
651
+
652
+ ### Atomic batch publishing
653
+
654
+ ```python
655
+ messages = [first_actor.message(), second_actor.message()]
656
+ options = [{}, {"delay": 1000, "deduplication_key": "second", "deduplication_ttl": 60000}]
657
+ returned = broker.enqueue_many(messages, options=options)
658
+
659
+ with connection.transaction():
660
+ # Business writes and the whole batch commit or roll back together.
661
+ returned = broker.enqueue_many_in_transaction(
662
+ messages, connection=connection, options=options)
663
+ ```
664
+
665
+ The returned list follows input order; a deduplicated entry returns the original
666
+ message. `options` is optional, with one dict per message; dict keys are the same
667
+ `delay`, `deduplication_key`, `deduplication_ttl` keywords as `enqueue`. A batch is
668
+ limited to 1000 messages; an empty batch performs no SQL. The external form
669
+ requires an active transaction even for an empty batch and uses a savepoint,
670
+ so a caught batch error leaves earlier caller writes intact. Neither batch API
671
+ automatically retries a connection failure; the caller must handle an uncertain
672
+ commit with idempotency/deduplication.
673
+
674
+ Without deduplication, Psycopg `executemany` pipelines the existing per-message
675
+ SQL in one transaction. Deduplicated/mixed batches reuse the normal key claim
676
+ path in one transaction. All database writes and notifications roll back on any
677
+ error; notifications become visible only after the outer commit. Enqueue hooks
678
+ run once per published message; duplicates run none. Before hooks precede the
679
+ writes, after hooks run after successful writes (and after commit for the owned
680
+ transaction). External hooks describe the SQL operation and may run before the
681
+ caller subsequently rolls back. Python hook side effects cannot be rolled back.
682
+
683
+ ### PostgreSQL interval scheduler
684
+
685
+ Upgrade each namespace before use: `iddqueue upgrade`. Schedules persist a
686
+ Dramatiq message template and a positive fixed interval in milliseconds. Workers
687
+ must register the actor; the scheduler does not import or execute actor code.
688
+
689
+ ```sh
690
+ iddqueue schedule create reports generate_report --queue reports --interval-ms 60000 \
691
+ --kwargs '{"account": 42}'
692
+ iddqueue schedule list
693
+ iddqueue scheduler --poll-ms 1000
694
+ iddqueue scheduler --once
695
+ iddqueue schedule disable reports
696
+ ```
697
+
698
+ `--args` accepts a JSON array; `--kwargs` and `--options` accept JSON objects.
699
+ `--start-at` accepts an ISO timestamp with a timezone. By default the first run
700
+ is immediately due according to PostgreSQL. Times are stored as timestamptz and
701
+ listed in UTC. Names are unique within `--schemaname`/`--prefix`; create does not
702
+ overwrite a schedule. The Python API is `PostgresScheduler(broker)` from
703
+ `iddqueue.scheduler`, with `create(name, message, interval_ms=..., start_at=...)`,
704
+ `list()`, `disable(name)` and `tick(limit=100)`.
705
+
706
+ Multiple foreground scheduler processes can share the namespace: row locks with
707
+ `SKIP LOCKED` select due schedules. Publishing and advancing `next_run` commit in
708
+ one transaction. A crash before commit leaves the occurrence due; a crash after
709
+ commit leaves its message queued and its next run advanced. Each occurrence uses
710
+ a deterministic message UUID and the reserved dedup key prefix
711
+ `iddqueue:schedule:` (seven-day TTL). Expiry allows key reuse; it does not delete dedup rows. Delivery by workers is still at least once.
712
+
713
+ Missed intervals coalesce into one task, then advance to the next future point
714
+ on the original interval grid using PostgreSQL time. There is no cron/calendar
715
+ syntax or replay of every missed run. A paused destination still receives queued
716
+ occurrences; resume lets workers process them. Disable waits for an in-flight
717
+ scheduler transaction and prevents future publications while preserving queued
718
+ tasks. SIGTERM/SIGINT lets the current tick finish, then exits and closes the CLI
719
+ pool. Database errors exit the foreground process; a service manager may restart
720
+ it. Polling and actor enqueue hooks may add latency to short intervals.
721
+
722
+
723
+ ### Consumer notification consistency
724
+
725
+ NOTIFY is a wakeup hint, including legacy full-message payloads. Workers claim
726
+ only their own queue and read the authoritative actor payload atomically from
727
+ PostgreSQL. Stale notifications for deleted, terminal, or moved messages are
728
+ skipped. ACK/NACK session locks are drained before the next consumer claim or
729
+ prefetch wait, even with a continuous backlog; retries retain their unlock
730
+ wakeup. This preserves at-least-once delivery, not exactly-once side effects.