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,51 @@
1
+ from dramatiq.results import ResultFailure
2
+ from psycopg import sql
3
+
4
+ from .utils import notification_channel, transaction
5
+
6
+
7
+ class ResultCancelled(ResultFailure):
8
+ """The task was cancelled before execution; retained until queue purge."""
9
+
10
+
11
+ def notify_cancelled(cursor, message_id, schema, prefix):
12
+ cursor.execute("SELECT pg_notify(%s, %s)", (
13
+ notification_channel(str(message_id), "results", schema=schema, prefix=prefix),
14
+ str(message_id),
15
+ ))
16
+
17
+
18
+ def cancel(pool, message_id, *, schema="dramatiq", prefix=""):
19
+ table = sql.Identifier(schema, prefix + "queue")
20
+ with transaction(pool) as cursor:
21
+ cursor.execute(sql.SQL(
22
+ "SELECT state::text, started FROM {} WHERE message_id = %s FOR UPDATE"
23
+ ).format(table), (message_id,))
24
+ row = cursor.fetchone()
25
+ if row is None:
26
+ return {"status": "missing", "state": None}
27
+ state, started = row
28
+ if state == "cancelled":
29
+ return {"status": "cancelled", "state": state}
30
+ if state in ("done", "rejected"):
31
+ return {"status": "terminal", "state": state}
32
+ if started:
33
+ cursor.execute(sql.SQL(
34
+ "UPDATE {} SET cancel_requested = TRUE WHERE message_id = %s"
35
+ ).format(table), (message_id,))
36
+ return {"status": "requested", "state": state}
37
+ cursor.execute(sql.SQL(
38
+ "UPDATE {} SET state = 'cancelled', cancel_requested = TRUE, "
39
+ "result = NULL, result_ttl = NULL WHERE message_id = %s"
40
+ ).format(table), (message_id,))
41
+ notify_cancelled(cursor, message_id, schema, prefix)
42
+ return {"status": "cancelled", "state": "cancelled"}
43
+
44
+
45
+ def cancellation_status(pool, message_id, *, schema="dramatiq", prefix=""):
46
+ with transaction(pool) as cursor:
47
+ cursor.execute(sql.SQL(
48
+ "SELECT state::text, cancel_requested FROM {} WHERE message_id = %s"
49
+ ).format(sql.Identifier(schema, prefix + "queue")), (message_id,))
50
+ row = cursor.fetchone()
51
+ return {"state": row[0], "requested": row[1]} if row else {"state": None, "requested": False}
@@ -0,0 +1,6 @@
1
+ \set schema 'dramatiq'
2
+ \set state 'state'
3
+ \set queue 'queue'
4
+ ALTER TYPE :"schema".:"state" ADD VALUE IF NOT EXISTS 'cancelled';
5
+ ALTER TABLE :"schema".:"queue" ADD COLUMN IF NOT EXISTS started BOOLEAN NOT NULL DEFAULT FALSE;
6
+ ALTER TABLE :"schema".:"queue" ADD COLUMN IF NOT EXISTS cancel_requested BOOLEAN NOT NULL DEFAULT FALSE;
iddqueue/cli.py ADDED
@@ -0,0 +1,474 @@
1
+ import argparse
2
+ import bdb
3
+ import importlib.metadata
4
+ import json
5
+ import logging
6
+ import os
7
+ import pdb
8
+ import signal
9
+ import sys
10
+ import threading
11
+ from datetime import datetime
12
+ from textwrap import dedent
13
+ from uuid import UUID
14
+
15
+ from dramatiq import Message
16
+ from dramatiq.cli import LOGFORMAT, VERBOSITY
17
+ from dramatiq.common import q_name
18
+ from psycopg import sql
19
+ from psycopg.types.json import Jsonb
20
+
21
+ from .broker import QUERIES as BROKER_QUERIES
22
+ from .broker import PostgresBroker, message_lock, purge
23
+ from .cancellation import cancel, cancellation_status
24
+ from .control import is_paused, set_paused
25
+ from .history import list_attempts, purge_attempts
26
+ from .metrics import queue_statistics
27
+ from .scheduler import PostgresScheduler
28
+ from .schema import generate_init_sql, generate_upgrade_sql
29
+ from .utils import QueryManager, make_pool, transaction
30
+
31
+ logger = logging.getLogger(__name__)
32
+
33
+
34
+ # Function copied from distutils (now removed from Python), original code
35
+ # under MIT.
36
+ def strtobool(val: str) -> bool:
37
+ """Convert a string representation of truth to true (1) or false (0).
38
+
39
+ True values are 'y', 'yes', 't', 'true', 'on', and '1'; false values
40
+ are 'n', 'no', 'f', 'false', 'off', and '0'. Raises ValueError if
41
+ 'val' is anything else.
42
+ """
43
+ val = val.lower()
44
+ if val in ("y", "yes", "t", "true", "on", "1"):
45
+ return True
46
+ elif val in ("n", "no", "f", "false", "off", "0"):
47
+ return False
48
+ else:
49
+ raise ValueError(f"invalid truth value {val!r}")
50
+
51
+
52
+ def entrypoint():
53
+ debug = strtobool(os.environ.get("DEBUG", "n"))
54
+ level = logging.DEBUG if debug else logging.INFO
55
+ logging.basicConfig(level=level, format=LOGFORMAT)
56
+
57
+ try:
58
+ exit(main())
59
+ except (bdb.BdbQuit, KeyboardInterrupt):
60
+ logger.info("Interrupted.")
61
+ except Exception:
62
+ logger.exception("Unhandled error:")
63
+ if debug:
64
+ pdb.post_mortem(sys.exc_info()[2])
65
+ else:
66
+ logger.error("Report this error to the IDDQueue project maintainer.")
67
+ exit(1)
68
+
69
+
70
+ def main():
71
+ parser = make_argument_parser()
72
+ args = parser.parse_args()
73
+
74
+ logging.getLogger().setLevel(VERBOSITY.get(args.verbose, logging.INFO))
75
+
76
+ if not hasattr(args, "command"):
77
+ logger.error("Missing command. See --help for usage.")
78
+ return 1
79
+
80
+ args.pool = make_pool(args.url, maxconn=1)
81
+ try:
82
+ try:
83
+ with transaction(args.pool) as curs:
84
+ curs.execute("SELECT 1")
85
+ except Exception as e:
86
+ logger.error("Failed to connect: %s.", e)
87
+ return 1
88
+
89
+ kw = dict(schema=args.schemaname, prefix=args.prefix)
90
+ BROKER_QUERIES.build_queries(**kw)
91
+ QUERIES.build_queries(**kw)
92
+ return args.command(args)
93
+ finally:
94
+ args.pool.close()
95
+
96
+
97
+ def make_argument_parser():
98
+ version = importlib.metadata.version("iddqueue")
99
+ parser = argparse.ArgumentParser(
100
+ prog="iddqueue",
101
+ description="Maintainance utility for task-queue in Postgres.",
102
+ formatter_class=argparse.RawDescriptionHelpFormatter,
103
+ )
104
+
105
+ parser.add_argument("--version", action="version", version=version)
106
+ parser.add_argument(
107
+ "--verbose",
108
+ "-v",
109
+ default=0,
110
+ action="count",
111
+ help="turn on verbose log output",
112
+ )
113
+ parser.add_argument(
114
+ "-d",
115
+ "--dsn",
116
+ "--connstring",
117
+ action="store",
118
+ dest="url",
119
+ default="",
120
+ metavar="CONNSTRING",
121
+ help="Postgres connection string.",
122
+ )
123
+ parser.add_argument(
124
+ "--schemaname",
125
+ action="store",
126
+ dest="schemaname",
127
+ default="dramatiq",
128
+ metavar="SCHEMA",
129
+ help=(
130
+ 'Alternative database schema for Dramatiq-pg DDL. Default is "%(default)s".'
131
+ ),
132
+ )
133
+ parser.add_argument(
134
+ "--prefix",
135
+ action="store",
136
+ dest="prefix",
137
+ default="",
138
+ metavar="PREFIX",
139
+ help='Prefix for table name for message. Default is "%(default)s".',
140
+ )
141
+
142
+ subparsers = parser.add_subparsers()
143
+
144
+ subparser = subparsers.add_parser("flush")
145
+ subparser.set_defaults(command=flush_command)
146
+
147
+ subparser = subparsers.add_parser("init")
148
+ subparser.set_defaults(command=init_command)
149
+
150
+ subparser = subparsers.add_parser("upgrade")
151
+ subparser.set_defaults(command=upgrade_command)
152
+
153
+ subparser = subparsers.add_parser("purge")
154
+ subparser.set_defaults(command=purge_command)
155
+ subparser.add_argument(
156
+ "--maxage",
157
+ dest="purge_maxage",
158
+ default="30 days",
159
+ help=dedent(
160
+ """\
161
+ Max age of done/rejected message to keep in queue. Format is Postgres
162
+ interval. Default is %(default)r.
163
+ """
164
+ ),
165
+ )
166
+
167
+ subparser = subparsers.add_parser("recover")
168
+ subparser.set_defaults(command=recover_command)
169
+ subparser.add_argument(
170
+ "--minage",
171
+ dest="recover_minage",
172
+ default="1 min",
173
+ help=dedent(
174
+ """\
175
+ Max age of consumed message to requere. Format is Postgres
176
+ interval. Default is %(default)r.
177
+ """
178
+ ),
179
+ )
180
+
181
+ subparser = subparsers.add_parser("stats")
182
+ subparser.set_defaults(command=stats_command)
183
+ subparser.add_argument("--queue")
184
+ subparser.add_argument("--json", action="store_true")
185
+
186
+ for name in ("pause", "resume", "queue-status"):
187
+ control = subparsers.add_parser(name)
188
+ control.add_argument("queue")
189
+ control.set_defaults(command=control_command, control_operation=name)
190
+
191
+ for name in ("cancel", "cancel-status"):
192
+ cancellation = subparsers.add_parser(name)
193
+ cancellation.add_argument("message_id", type=UUID)
194
+ cancellation.set_defaults(command=cancellation_command, cancellation_operation=name)
195
+
196
+ failed = subparsers.add_parser("failed")
197
+ operations = failed.add_subparsers()
198
+ listing = operations.add_parser("list")
199
+ listing.set_defaults(command=failed_list_command)
200
+ listing.add_argument("--queue")
201
+ listing.add_argument("--actor")
202
+ listing.add_argument("--limit", type=page_size, default=50)
203
+ listing.add_argument("--after", type=UUID)
204
+ show = operations.add_parser("show")
205
+ show.set_defaults(command=failed_show_command)
206
+ show.add_argument("message_id", type=UUID)
207
+ show.add_argument("--payload", action="store_true")
208
+ retry = subparsers.add_parser("retry")
209
+ retry.set_defaults(command=retry_command)
210
+ retry.add_argument("message_id", type=UUID)
211
+
212
+ history = subparsers.add_parser("history")
213
+ operations = history.add_subparsers()
214
+ listing = operations.add_parser("list")
215
+ listing.set_defaults(command=history_list_command)
216
+ listing.add_argument("message_id", type=UUID)
217
+ listing.add_argument("--limit", type=page_size, default=50)
218
+ listing.add_argument("--after", type=UUID)
219
+ retention = operations.add_parser("purge")
220
+ retention.set_defaults(command=history_purge_command)
221
+ retention.add_argument("--maxage", default="30 days")
222
+
223
+ schedules = subparsers.add_parser("schedule").add_subparsers()
224
+ create = schedules.add_parser("create")
225
+ create.set_defaults(command=schedule_command, schedule_operation="create")
226
+ create.add_argument("name")
227
+ create.add_argument("actor")
228
+ create.add_argument("--queue", default="default")
229
+ create.add_argument("--interval-ms", type=positive_ms, required=True)
230
+ create.add_argument("--start-at", type=datetime.fromisoformat)
231
+ create.add_argument("--args", type=json.loads, default=[])
232
+ create.add_argument("--kwargs", type=json.loads, default={})
233
+ create.add_argument("--options", type=json.loads, default={})
234
+ listing = schedules.add_parser("list")
235
+ listing.set_defaults(command=schedule_command, schedule_operation="list")
236
+ disable = schedules.add_parser("disable")
237
+ disable.set_defaults(command=schedule_command, schedule_operation="disable")
238
+ disable.add_argument("name")
239
+ runner = subparsers.add_parser("scheduler")
240
+ runner.set_defaults(command=scheduler_command)
241
+ runner.add_argument("--poll-ms", type=positive_ms, default=1000)
242
+ runner.add_argument("--once", action="store_true")
243
+
244
+ return parser
245
+
246
+
247
+ def positive_ms(value):
248
+ value = int(value)
249
+ if value <= 0:
250
+ raise argparse.ArgumentTypeError("milliseconds must be positive")
251
+ return value
252
+
253
+
254
+ def _scheduler(args):
255
+ return PostgresScheduler(PostgresBroker(pool=args.pool, schema=args.schemaname,
256
+ prefix=args.prefix, results=False, middleware=[]))
257
+
258
+
259
+ def schedule_command(args):
260
+ scheduler = _scheduler(args)
261
+ if args.schedule_operation == "create":
262
+ if not isinstance(args.args, list) or not isinstance(args.kwargs, dict) or not isinstance(args.options, dict):
263
+ raise ValueError("args must be a JSON array; kwargs/options must be JSON objects")
264
+ message = Message(args.queue, args.actor, tuple(args.args), args.kwargs, args.options)
265
+ print(json.dumps(dict(schedule_id=scheduler.create(
266
+ args.name, message, interval_ms=args.interval_ms, start_at=args.start_at))))
267
+ elif args.schedule_operation == "list":
268
+ print(json.dumps(scheduler.list()))
269
+ else:
270
+ found = scheduler.disable(args.name)
271
+ print(json.dumps(dict(name=args.name, disabled=found)))
272
+ return 0 if found else 1
273
+
274
+
275
+ def scheduler_command(args):
276
+ scheduler = _scheduler(args)
277
+ if args.once:
278
+ print(json.dumps(dict(published=len(scheduler.tick()))))
279
+ return
280
+ stopped = threading.Event()
281
+ previous = {sig: signal.signal(sig, lambda *_: stopped.set()) for sig in (signal.SIGTERM, signal.SIGINT)}
282
+ try:
283
+ while not stopped.is_set():
284
+ count = len(scheduler.tick())
285
+ if count:
286
+ logger.info("Scheduler published %d occurrences.", count)
287
+ stopped.wait(args.poll_ms / 1000)
288
+ finally:
289
+ for sig, handler in previous.items():
290
+ signal.signal(sig, handler)
291
+
292
+
293
+ def history_list_command(args):
294
+ print(json.dumps(list_attempts(args.pool, args.message_id, schema=args.schemaname,
295
+ prefix=args.prefix, limit=args.limit, after=args.after)))
296
+
297
+
298
+ def history_purge_command(args):
299
+ print(json.dumps(dict(deleted=purge_attempts(args.pool, args.maxage,
300
+ schema=args.schemaname, prefix=args.prefix))))
301
+
302
+
303
+ def flush_command(args):
304
+ with transaction(args.pool) as curs:
305
+ curs.execute(QUERIES.FLUSH)
306
+ flushed = curs.rowcount
307
+ logger.info("Flushed %d messages.", flushed)
308
+
309
+
310
+ def purge_command(args):
311
+ with transaction(args.pool) as curs:
312
+ deleted = purge(curs, args.purge_maxage)
313
+ logger.info("Deleted %d messages.", deleted)
314
+
315
+
316
+ def recover_command(args):
317
+ with transaction(args.pool) as curs:
318
+ curs.execute(QUERIES.RECOVER, (args.recover_minage,))
319
+ recovered = curs.rowcount
320
+ logger.info("Recovered %s messages.", recovered)
321
+
322
+
323
+ def init_command(args):
324
+ with transaction(args.pool) as curs:
325
+ curs.execute(generate_init_sql(args.schemaname, args.prefix))
326
+ logger.info("Initialized database.")
327
+
328
+
329
+ def upgrade_command(args):
330
+ with transaction(args.pool) as curs:
331
+ curs.execute(generate_upgrade_sql(args.schemaname, args.prefix))
332
+ logger.info("Upgraded database.")
333
+
334
+
335
+ def cancellation_command(args):
336
+ operation = cancel if args.cancellation_operation == "cancel" else cancellation_status
337
+ output = operation(args.pool, args.message_id, schema=args.schemaname, prefix=args.prefix)
338
+ print(json.dumps(output))
339
+ if output["state"] is None:
340
+ return 1
341
+
342
+
343
+ def control_command(args):
344
+ if args.control_operation != "queue-status":
345
+ set_paused(args.pool, args.queue, args.control_operation == "pause",
346
+ schema=args.schemaname, prefix=args.prefix)
347
+ print(json.dumps({"queue": q_name(args.queue),
348
+ "paused": is_paused(args.pool, args.queue,
349
+ schema=args.schemaname, prefix=args.prefix)}))
350
+
351
+
352
+ def stats_command(args):
353
+ if args.json or args.queue is not None:
354
+ snapshots = queue_statistics(args.pool, schema=args.schemaname, prefix=args.prefix, queue=args.queue)
355
+ print(json.dumps(snapshots))
356
+ return
357
+ with transaction(args.pool) as curs:
358
+ curs.execute(QUERIES.STATS)
359
+ stats = dict(curs.fetchall())
360
+
361
+ for state in "queued", "consumed", "done", "rejected", "cancelled":
362
+ print(f"{state}: {stats.get(state, 0)}")
363
+
364
+
365
+ def page_size(value):
366
+ value = int(value)
367
+ if not 1 <= value <= 1000:
368
+ raise argparse.ArgumentTypeError("limit must be between 1 and 1000")
369
+ return value
370
+
371
+
372
+ def _table(args):
373
+ return sql.Identifier(args.schemaname, args.prefix + "queue")
374
+
375
+
376
+ def _summary(row):
377
+ message_id, queue, state, actor, options = row
378
+ options = options or {}
379
+ failure = options.get("pg_failure")
380
+ return dict(message_id=str(message_id), queue=queue, state=state,
381
+ actor=actor, attempts=(failure or {}).get("attempt"),
382
+ retries=options.get("retries", 0), error=failure)
383
+
384
+
385
+ def failed_list_command(args):
386
+ with transaction(args.pool) as curs:
387
+ curs.execute(sql.SQL(
388
+ "SELECT message_id, queue_name, state::text, message->>'actor_name', "
389
+ "message->'options' FROM {} WHERE state = 'rejected' "
390
+ "AND (%s::text IS NULL OR queue_name = %s) "
391
+ "AND (%s::text IS NULL OR message->>'actor_name' = %s) "
392
+ "AND (%s::uuid IS NULL OR message_id > %s) ORDER BY message_id LIMIT %s"
393
+ ).format(_table(args)), (args.queue, args.queue, args.actor, args.actor,
394
+ args.after, args.after, args.limit + 1))
395
+ rows = curs.fetchall()
396
+ page = rows[:args.limit]
397
+ print(json.dumps(dict(items=[_summary(row) for row in page],
398
+ next_after=str(page[-1][0]) if len(rows) > args.limit else None)))
399
+
400
+
401
+ def failed_show_command(args):
402
+ with transaction(args.pool) as curs:
403
+ curs.execute(sql.SQL(
404
+ "SELECT message_id, queue_name, state::text, message->>'actor_name', "
405
+ "message->'options', message FROM {} WHERE message_id = %s AND state = 'rejected'"
406
+ ).format(_table(args)), (args.message_id,))
407
+ row = curs.fetchone()
408
+ if row is None:
409
+ logger.error("Rejected message not found: %s", args.message_id)
410
+ return 1
411
+ output = _summary(row[:5])
412
+ if args.payload:
413
+ output["message"] = row[5]
414
+ print(json.dumps(output))
415
+
416
+
417
+ def retry_command(args):
418
+ with transaction(args.pool) as curs:
419
+ curs.execute(sql.SQL(
420
+ "SELECT message FROM {} WHERE message_id = %s AND state = 'rejected' FOR UPDATE"
421
+ ).format(_table(args)), (args.message_id,))
422
+ row = curs.fetchone()
423
+ if row is None:
424
+ logger.error("Retry refused: message is missing or not rejected")
425
+ return 1
426
+ payload = row[0]
427
+ message = Message(**payload)
428
+ curs.execute("SELECT pg_try_advisory_xact_lock(%s)", (message_lock(message, schema=args.schemaname, prefix=args.prefix),))
429
+ if not curs.fetchone()[0]:
430
+ logger.error("Retry refused: worker still holds the message lock")
431
+ return 1
432
+ for key in ("retries", "traceback", "requeue_timestamp", "eta", "pg_failure"):
433
+ payload["options"].pop(key, None)
434
+ payload["queue_name"] = q_name(payload["queue_name"])
435
+ curs.execute(sql.SQL(
436
+ "UPDATE {} SET state = 'queued', message = %s, queue_name = %s, "
437
+ "mtime = clock_timestamp(), result = NULL, result_ttl = NULL "
438
+ "WHERE message_id = %s AND state = 'rejected'"
439
+ ).format(_table(args)), (Jsonb(payload), payload["queue_name"], args.message_id))
440
+ curs.execute("SELECT pg_notify(%s, %s)",
441
+ (BROKER_QUERIES.channel(payload["queue_name"], "enqueue"),
442
+ json.dumps({"message_id": str(args.message_id)})))
443
+ print(json.dumps(dict(message_id=str(args.message_id), state="queued")))
444
+
445
+
446
+ QUERIES = QueryManager(
447
+ dict(
448
+ RECOVER=dedent(
449
+ """\
450
+ UPDATE {schema}.{tablename}
451
+ SET state = 'queued', started = FALSE, mtime = clock_timestamp()
452
+ WHERE state = 'consumed'
453
+ AND mtime < NOW() - %s::interval;
454
+ """
455
+ ),
456
+ STATS=dedent(
457
+ """\
458
+ SELECT "state", count(1)
459
+ FROM {schema}.{tablename}
460
+ GROUP BY "state";
461
+ """
462
+ ),
463
+ FLUSH=dedent(
464
+ """\
465
+ DELETE FROM {schema}.{tablename}
466
+ WHERE "state" IN ('queued', 'consumed');
467
+ """
468
+ ),
469
+ )
470
+ )
471
+
472
+
473
+ if "__main__" == __name__:
474
+ entrypoint()
iddqueue/control.py ADDED
@@ -0,0 +1,87 @@
1
+ import logging
2
+
3
+ from dramatiq.common import dq_name, q_name
4
+ from dramatiq.middleware import Middleware, SkipMessage
5
+ from psycopg import sql
6
+
7
+ from .cancellation import notify_cancelled
8
+ from .utils import notification_channel, transaction
9
+
10
+
11
+ def table(schema, prefix):
12
+ return sql.Identifier(schema, prefix + "queue_control")
13
+
14
+
15
+ def allow_start(cursor, queue, schema, prefix):
16
+ control = table(schema, prefix)
17
+ queue = q_name(queue)
18
+ cursor.execute(sql.SQL(
19
+ "INSERT INTO {} (queue_name) VALUES (%s) ON CONFLICT DO NOTHING"
20
+ ).format(control), (queue,))
21
+ # Shared locks allow concurrent starts; pause waits for these gates to commit.
22
+ cursor.execute(sql.SQL(
23
+ "SELECT paused FROM {} WHERE queue_name = %s FOR SHARE"
24
+ ).format(control), (queue,))
25
+ return not cursor.fetchone()[0]
26
+
27
+
28
+ def set_paused(pool, queue, paused, *, schema="dramatiq", prefix=""):
29
+ queue = q_name(queue)
30
+ with transaction(pool) as cursor:
31
+ cursor.execute(sql.SQL("""
32
+ INSERT INTO {} (queue_name, paused) VALUES (%s, %s)
33
+ ON CONFLICT (queue_name) DO UPDATE SET paused = EXCLUDED.paused
34
+ """).format(table(schema, prefix)), (queue, paused))
35
+ if not paused:
36
+ for name in (queue, dq_name(queue)):
37
+ cursor.execute("SELECT pg_notify(%s, %s)", (
38
+ notification_channel(name, "enqueue", schema=schema, prefix=prefix),
39
+ '{"scan":true}',
40
+ ))
41
+
42
+
43
+ def is_paused(pool, queue, *, schema="dramatiq", prefix=""):
44
+ with transaction(pool) as cursor:
45
+ cursor.execute(sql.SQL(
46
+ "SELECT paused FROM {} WHERE queue_name = %s"
47
+ ).format(table(schema, prefix)), (q_name(queue),))
48
+ row = cursor.fetchone()
49
+ return bool(row and row[0])
50
+
51
+
52
+ class QueueControl(Middleware):
53
+ def before_process_message(self, broker, message):
54
+ try:
55
+ with transaction(broker.pool) as cursor:
56
+ permitted = allow_start(cursor, message.queue_name,
57
+ broker.queries.schema, broker.queries.prefix)
58
+ if permitted:
59
+ table_name = sql.Identifier(broker.queries.schema, broker.queries.prefix + "queue")
60
+ cursor.execute(sql.SQL(
61
+ "SELECT state::text, cancel_requested FROM {} "
62
+ "WHERE message_id = %s FOR UPDATE"
63
+ ).format(table_name), (message.message_id,))
64
+ row = cursor.fetchone()
65
+ if row is None or row[0] == "cancelled" or row[1]:
66
+ if row and row[0] in ("queued", "consumed"):
67
+ cursor.execute(sql.SQL(
68
+ "UPDATE {} SET state = 'cancelled' WHERE message_id = %s"
69
+ ).format(table_name), (message.message_id,))
70
+ notify_cancelled(cursor, message.message_id,
71
+ broker.queries.schema, broker.queries.prefix)
72
+ message._pg_cancelled = True
73
+ else:
74
+ cursor.execute(sql.SQL(
75
+ "UPDATE {} SET started = TRUE WHERE message_id = %s"
76
+ ).format(table_name), (message.message_id,))
77
+ except SkipMessage:
78
+ raise
79
+ except Exception:
80
+ # Dramatiq logs ordinary hook errors and continues: fail closed.
81
+ logging.getLogger(__name__).exception("Queue start gate failed")
82
+ permitted = False
83
+ if getattr(message, "_pg_cancelled", False):
84
+ raise SkipMessage("Task cancelled")
85
+ if not permitted:
86
+ message._pg_paused = True
87
+ raise SkipMessage("Queue paused")
iddqueue/control.sql ADDED
@@ -0,0 +1,6 @@
1
+ \set schema 'dramatiq'
2
+ \set queue_control 'queue_control'
3
+ CREATE TABLE IF NOT EXISTS :"schema".:"queue_control" (
4
+ queue_name TEXT PRIMARY KEY,
5
+ paused BOOLEAN NOT NULL DEFAULT FALSE
6
+ );
@@ -0,0 +1,11 @@
1
+ \set ON_ERROR_STOP on
2
+ \set schema 'dramatiq'
3
+ \set coordination 'coordination'
4
+
5
+ CREATE SCHEMA IF NOT EXISTS :"schema";
6
+ CREATE TABLE IF NOT EXISTS :"schema".:"coordination" (
7
+ key TEXT PRIMARY KEY,
8
+ value BIGINT NOT NULL DEFAULT 0,
9
+ expires_at TIMESTAMPTZ NOT NULL DEFAULT '-infinity',
10
+ event_expires_at TIMESTAMPTZ NOT NULL DEFAULT '-infinity'
11
+ );
@@ -0,0 +1,12 @@
1
+ \set schema 'dramatiq'
2
+ \set deduplication 'deduplication'
3
+ CREATE TABLE IF NOT EXISTS :"schema".:"deduplication" (
4
+ queue_name TEXT NOT NULL,
5
+ key TEXT NOT NULL,
6
+ message_id UUID NOT NULL,
7
+ message JSONB NOT NULL,
8
+ expires_at TIMESTAMPTZ NOT NULL,
9
+ PRIMARY KEY (queue_name, key)
10
+ );
11
+ CREATE INDEX IF NOT EXISTS :"deduplication_expiry"
12
+ ON :"schema".:"deduplication" (expires_at);
iddqueue/failures.py ADDED
@@ -0,0 +1,18 @@
1
+ """Last-attempt diagnostics stored with the existing message JSONB."""
2
+
3
+ from datetime import datetime, timezone
4
+
5
+ from dramatiq.middleware import Middleware
6
+
7
+
8
+ class FailureMetadata(Middleware):
9
+ def after_process_message(self, broker, message, *, result=None, exception=None):
10
+ if exception is None:
11
+ message.options.pop("pg_failure", None)
12
+ return
13
+ message.options["pg_failure"] = {
14
+ "type": type(exception).__name__,
15
+ "text": str(exception)[:2000],
16
+ "time": datetime.now(timezone.utc).isoformat(),
17
+ "attempt": message.options.get("retries", 0) + 1,
18
+ }