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.
iddqueue/__init__.py ADDED
@@ -0,0 +1,15 @@
1
+ from .broker import PostgresBroker
2
+ from .cancellation import ResultCancelled
3
+ from .rate_limits import PostgresRateLimiterBackend
4
+ from .results import PostgresBackend
5
+ from .schema import generate_coordination_sql, generate_init_sql, generate_upgrade_sql
6
+
7
+ __all__ = [
8
+ "ResultCancelled",
9
+ "PostgresBackend",
10
+ "PostgresBroker",
11
+ "generate_init_sql",
12
+ "generate_upgrade_sql",
13
+ "generate_coordination_sql",
14
+ "PostgresRateLimiterBackend",
15
+ ]
iddqueue/broker.py ADDED
@@ -0,0 +1,667 @@
1
+ import json
2
+ import logging
3
+ import time
4
+ from hashlib import sha256
5
+ from itertools import islice
6
+ from queue import Empty, Queue
7
+ from random import randint
8
+ from textwrap import dedent
9
+
10
+ from dramatiq.broker import Broker, Consumer, MessageProxy
11
+ from dramatiq.common import compute_backoff, current_millis, dq_name, q_name
12
+ from dramatiq.errors import BrokerConnectionError
13
+ from dramatiq.message import Message
14
+ from dramatiq.results import Results
15
+ from psycopg import Notify, sql
16
+ from psycopg.pq import TransactionStatus
17
+ from psycopg.types.json import Jsonb
18
+
19
+ from .cancellation import cancel, cancellation_status
20
+ from .control import QueueControl, allow_start, is_paused, set_paused
21
+ from .failures import FailureMetadata
22
+ from .history import AttemptHistory
23
+ from .results import PostgresBackend
24
+ from .utils import (
25
+ QueryManager,
26
+ check_conn,
27
+ getconn,
28
+ make_pool,
29
+ raise_connection_error,
30
+ retry_pg,
31
+ storage_namespace,
32
+ tidy4json,
33
+ transaction,
34
+ wait_for_notifies,
35
+ )
36
+
37
+ logger = logging.getLogger(__name__)
38
+
39
+
40
+ def purge(curs, max_age="30 days"):
41
+ # Delete old messages. Returns deleted messages.
42
+
43
+ curs.execute(QUERIES.PURGE, (max_age,))
44
+ return curs.rowcount
45
+
46
+
47
+ class PostgresBroker(Broker):
48
+ def __init__(
49
+ self, *, pool=None, url="", results=True, schema=None, prefix=None, queue_control=False, attempt_history=False, **kw
50
+ ):
51
+ super().__init__(**kw)
52
+ if pool is not None and url:
53
+ raise ValueError("You can't set both pool and URL!")
54
+
55
+ if pool is None:
56
+ self.pool = make_pool(url)
57
+ else:
58
+ # Receive a pool object to have an I/O less __init__.
59
+ self.pool = pool
60
+ self._owns_pool = pool is None
61
+ self.backend = None
62
+ if results:
63
+ self.backend = PostgresBackend(pool=self.pool, schema=schema, prefix=prefix)
64
+ self.add_middleware(Results(backend=self.backend))
65
+
66
+ self.add_middleware(FailureMetadata())
67
+ self.queries = QueryManager(QUERIES.queries, schema or "dramatiq", prefix or "")
68
+ if attempt_history:
69
+ self.add_middleware(AttemptHistory())
70
+ self.queue_control = queue_control
71
+ if queue_control:
72
+ self.add_middleware(QueueControl(), before=type(self.middleware[0]))
73
+
74
+ def emit_after(self, signal, *args, **kwargs):
75
+ # A deferred actor is not a terminal skip (Results must not store None).
76
+ if signal == "skip_message" and (getattr(args[0], "_pg_paused", False) or getattr(args[0], "_pg_cancelled", False)):
77
+ return
78
+ return super().emit_after(signal, *args, **kwargs)
79
+
80
+ def cancel(self, message_id):
81
+ return cancel(self.pool, message_id, schema=self.queries.schema, prefix=self.queries.prefix)
82
+
83
+ def cancellation_status(self, message_id):
84
+ return cancellation_status(self.pool, message_id, schema=self.queries.schema, prefix=self.queries.prefix)
85
+
86
+ def cancellation_requested(self, message_id):
87
+ return self.cancellation_status(message_id)["requested"]
88
+
89
+ def pause_queue(self, queue):
90
+ set_paused(self.pool, queue, True, schema=self.queries.schema, prefix=self.queries.prefix)
91
+
92
+ def resume_queue(self, queue):
93
+ set_paused(self.pool, queue, False, schema=self.queries.schema, prefix=self.queries.prefix)
94
+
95
+ def queue_is_paused(self, queue):
96
+ return is_paused(self.pool, queue, schema=self.queries.schema, prefix=self.queries.prefix)
97
+
98
+
99
+ def close(self):
100
+ if self._owns_pool:
101
+ self.pool.close()
102
+
103
+ def consume(self, queue_name, prefetch=1, timeout=30000):
104
+ return PostgresConsumer(
105
+ pool=self.pool,
106
+ queue_name=queue_name,
107
+ prefetch=prefetch,
108
+ timeout=timeout,
109
+ queries=self.queries,
110
+ queue_control=self.queue_control,
111
+ )
112
+
113
+ def declare_queue(self, queue_name):
114
+ if queue_name not in self.queues:
115
+ self.emit_before("declare_queue", queue_name)
116
+ self.queues[queue_name] = True
117
+ # Actually do nothing in Postgres since all queues are stored in
118
+ # the same table.
119
+ self.emit_after("declare_queue", queue_name)
120
+
121
+ delayed_name = dq_name(queue_name)
122
+ self.delay_queues.add(delayed_name)
123
+ self.emit_after("declare_delay_queue", delayed_name)
124
+
125
+ @retry_pg
126
+ def enqueue(self, message, *, delay=None, deduplication_key=None, deduplication_ttl=None):
127
+ if deduplication_key is not None or deduplication_ttl is not None:
128
+ with transaction(self.pool) as curs:
129
+ returned, published = self._enqueue_deduplicated(
130
+ curs, message, delay, deduplication_key, deduplication_ttl
131
+ )
132
+ if published:
133
+ self.emit_after("enqueue", returned, delay)
134
+ return returned
135
+ message = self._prepare_enqueue(message, delay)
136
+ with transaction(self.pool) as curs:
137
+ self._write_enqueue(curs, message)
138
+ self.emit_after("enqueue", message, delay)
139
+ return message
140
+
141
+ def enqueue_in_transaction(self, message, *, connection, delay=None, deduplication_key=None, deduplication_ttl=None):
142
+ """Enqueue using the caller's active Psycopg transaction.
143
+
144
+ The caller owns commit, rollback and the connection. Enqueue hooks
145
+ describe the SQL operation, not the eventual transaction commit.
146
+ Errors propagate without retrying the caller's transaction.
147
+ """
148
+ if connection.info.transaction_status != TransactionStatus.INTRANS:
149
+ raise ValueError("enqueue_in_transaction requires an active transaction")
150
+ if deduplication_key is not None or deduplication_ttl is not None:
151
+ with transaction(connection) as curs:
152
+ returned, published = self._enqueue_deduplicated(
153
+ curs, message, delay, deduplication_key, deduplication_ttl
154
+ )
155
+ if published:
156
+ self.emit_after("enqueue", returned, delay)
157
+ return returned
158
+ message = self._prepare_enqueue(message, delay)
159
+ with connection.cursor() as curs:
160
+ self._write_enqueue(curs, message)
161
+ self.emit_after("enqueue", message, delay)
162
+ return message
163
+
164
+ def enqueue_many(self, messages, *, options=None):
165
+ """Atomically enqueue up to 1000 messages with aligned enqueue options."""
166
+ entries = self._batch_entries(messages, options)
167
+ if not entries:
168
+ return []
169
+ with transaction(self.pool) as curs:
170
+ returned, published = self._write_batch(curs, entries)
171
+ for message, delay in published:
172
+ self.emit_after("enqueue", message, delay)
173
+ return returned
174
+
175
+ def enqueue_many_in_transaction(self, messages, *, connection, options=None):
176
+ """Use a savepoint; never commit or retry the caller's transaction."""
177
+ if connection.info.transaction_status != TransactionStatus.INTRANS:
178
+ raise ValueError("enqueue_many_in_transaction requires an active transaction")
179
+ entries = self._batch_entries(messages, options)
180
+ if not entries:
181
+ return []
182
+ with transaction(connection) as curs:
183
+ returned, published = self._write_batch(curs, entries)
184
+ for message, delay in published:
185
+ self.emit_after("enqueue", message, delay)
186
+ return returned
187
+
188
+ @staticmethod
189
+ def _batch_entries(messages, options):
190
+ messages = list(islice(messages, 1001))
191
+ if len(messages) > 1000:
192
+ raise ValueError("batch limit is 1000 messages")
193
+ options = [{} for _ in messages] if options is None else list(islice(options, 1001))
194
+ if len(options) != len(messages):
195
+ raise ValueError("options must match messages")
196
+ allowed = {"delay", "deduplication_key", "deduplication_ttl"}
197
+ for option in options:
198
+ if not isinstance(option, dict) or option.keys() - allowed:
199
+ raise ValueError("invalid enqueue options")
200
+ return list(zip(messages, options))
201
+
202
+ def _write_batch(self, curs, entries):
203
+ returned, published = [], []
204
+ if all(o.get("deduplication_key") is None and o.get("deduplication_ttl") is None
205
+ for _, o in entries):
206
+ for message, option in entries:
207
+ delay = option.get("delay")
208
+ message = self._prepare_enqueue(message, delay)
209
+ returned.append(message)
210
+ published.append((message, delay))
211
+ # Psycopg executemany pipelines the existing per-message SQL.
212
+ curs.executemany(self.queries.ENQUEUE, [self._enqueue_params(m) for m in returned])
213
+ else:
214
+ for message, option in entries:
215
+ delay = option.get("delay")
216
+ if option.get("deduplication_key") is not None or option.get("deduplication_ttl") is not None:
217
+ message, inserted = self._enqueue_deduplicated(
218
+ curs, message, delay, option.get("deduplication_key"), option.get("deduplication_ttl"))
219
+ else:
220
+ message = self._prepare_enqueue(message, delay)
221
+ self._write_enqueue(curs, message)
222
+ inserted = True
223
+ returned.append(message)
224
+ if inserted:
225
+ published.append((message, delay))
226
+ return returned, published
227
+
228
+ def _enqueue_deduplicated(self, curs, message, delay, key, ttl):
229
+ if not isinstance(key, str) or not key:
230
+ raise ValueError("deduplication_key must be a nonempty string")
231
+ if type(ttl) is not int or ttl <= 0:
232
+ raise ValueError("deduplication_ttl must be positive integer milliseconds")
233
+ table = sql.Identifier(self.queries.schema, self.queries.prefix + "deduplication")
234
+ queue = q_name(message.queue_name)
235
+ curs.execute(sql.SQL("""
236
+ INSERT INTO {} AS stored (queue_name, key, message_id, message, expires_at)
237
+ VALUES (%s, %s, %s, %s, clock_timestamp() + %s * interval '1 millisecond')
238
+ ON CONFLICT (queue_name, key) DO UPDATE SET
239
+ message_id = EXCLUDED.message_id, message = EXCLUDED.message,
240
+ expires_at = EXCLUDED.expires_at
241
+ WHERE stored.expires_at <= clock_timestamp()
242
+ RETURNING message_id
243
+ """).format(table), (queue, key, message.message_id, Jsonb(tidy4json(message)), ttl))
244
+ if curs.fetchone() is None:
245
+ curs.execute(sql.SQL(
246
+ "SELECT message FROM {} WHERE queue_name = %s AND key = %s"
247
+ ).format(table), (queue, key))
248
+ return Message.decode(json.dumps(curs.fetchone()[0]).encode()), False
249
+ message = self._prepare_enqueue(message, delay)
250
+ self._write_enqueue(curs, message)
251
+ curs.execute(sql.SQL(
252
+ "UPDATE {} SET message = %s WHERE queue_name = %s AND key = %s"
253
+ ).format(table), (Jsonb(tidy4json(message)), queue, key))
254
+ return message, True
255
+
256
+ def _prepare_enqueue(self, message, delay):
257
+ self.emit_before("enqueue", message, delay)
258
+ if delay:
259
+ message = message.copy(queue_name=dq_name(message.queue_name))
260
+ message.options["eta"] = current_millis() + delay
261
+ return message
262
+
263
+ def _write_enqueue(self, curs, message):
264
+ logger.debug(
265
+ "Upserting %s in queue %s.", message.message_id, message.queue_name
266
+ )
267
+ curs.execute(self.queries.ENQUEUE, self._enqueue_params(message))
268
+
269
+ def _enqueue_params(self, message):
270
+ return (message.queue_name, message.message_id, Jsonb(tidy4json(message)),
271
+ self.queries.channel(message.queue_name, "enqueue"), message.message_id)
272
+
273
+
274
+
275
+ class PostgresConsumer(Consumer):
276
+ def __init__(self, *, pool, queue_name, prefetch, timeout, queries=None, queue_control=False, **kw):
277
+ self.queries = queries or QUERIES
278
+ self.queue_control = queue_control
279
+ self._consume_conn = None
280
+ self._listen_conn = None
281
+ self.notifies = []
282
+ self.pool = pool
283
+ self.queue_name = queue_name
284
+ self.timeout = timeout / 1000
285
+ self.unlock_q = Queue()
286
+ self.in_processing = set()
287
+ self.prefetch = prefetch
288
+ self.misses = 0
289
+
290
+ @raise_connection_error
291
+ def __next__(self):
292
+ # This function is executed each second.
293
+
294
+ # First, open connexion and fetch missed notifies from table.
295
+ if self._listen_conn is None:
296
+ # Before reading from LISTEN, scan queue for missed messages.
297
+ self.notifies = self.fetch_pending_notifies()
298
+ logger.debug(
299
+ "Found %s pending messages in queue %s.",
300
+ len(self.notifies),
301
+ self.queue_name,
302
+ )
303
+
304
+ self.purge_locks()
305
+
306
+ processing = len(self.in_processing)
307
+ if processing >= self.prefetch:
308
+ # Wait and don't consume the message, other worker will be faster
309
+ self.misses, backoff_ms = compute_backoff(self.misses, max_backoff=1000)
310
+ logger.debug(
311
+ f"Too many messages in processing: {processing} sleeping {backoff_ms}"
312
+ )
313
+ time.sleep(backoff_ms / 1000)
314
+ return None
315
+
316
+ if not self.notifies:
317
+ # Then, fetch notifies from Pg connexion.
318
+ self.poll_for_notify()
319
+
320
+ if not self.notifies and not randint(0, 300):
321
+ # If notifies are consumed, randomly poll for crashed messages.
322
+ # Since we're called each second, this condition limits polling to
323
+ # one SELECT every five minutes of inactivity.
324
+ self.notifies[:] = self.fetch_pending_notifies()
325
+
326
+ # If we have some notifies, loop to find one todo.
327
+ while self.notifies:
328
+ notify = self.notifies.pop(0)
329
+ payload = json.loads(notify.payload)
330
+ if payload.get("scan"):
331
+ self.notifies += self.fetch_pending_notifies()
332
+ continue
333
+ # Legacy full payloads are hints too; claim returns durable data.
334
+ message = Message(self.queue_name, "", (), {}, {}, message_id=payload["message_id"])
335
+ claimed = self.consume_one(message)
336
+ if claimed:
337
+ self.in_processing.add(claimed.message_id)
338
+ return MessageProxy(claimed)
339
+ else:
340
+ logger.debug(
341
+ "Message %s already consumed. Skipping.",
342
+ message.message_id,
343
+ )
344
+
345
+ # No message to process. Let's clean locks.
346
+ self.purge_locks()
347
+
348
+ # We have nothing to do, let's see if the queue needs some cleaning.
349
+ self.auto_purge()
350
+
351
+ @raise_connection_error
352
+ def ack(self, message):
353
+ # This function is executed in worker thread!
354
+ if getattr(message, "_pg_cancelled", False):
355
+ self.unlock_q.put_nowait(message)
356
+ self.in_processing.remove(message.message_id)
357
+ return
358
+ if getattr(message, "_pg_paused", False):
359
+ with transaction(self.pool) as curs:
360
+ curs.execute(self.queries.DEFER_PAUSED, (message.message_id, message.queue_name))
361
+ self.unlock_q.put_nowait(message)
362
+ self.in_processing.remove(message.message_id)
363
+ return
364
+
365
+ with transaction(self.pool) as curs:
366
+ channel = self.queries.channel(message.queue_name, "ack")
367
+ payload = tidy4json(message)
368
+ logger.debug("Notifying %s for ACK %s.", channel, message.message_id)
369
+ # dramatiq always ack a message, even if it has been requeued by
370
+ # the Retries middleware. Thus, only update message in state
371
+ # `consumed`.
372
+ curs.execute(
373
+ self.queries.ACK,
374
+ (
375
+ Jsonb(payload),
376
+ message.message_id,
377
+ message.queue_name,
378
+ channel,
379
+ message.message_id,
380
+ ),
381
+ )
382
+ self.unlock_q.put_nowait(message)
383
+ self.in_processing.remove(message.message_id)
384
+
385
+ @raise_connection_error
386
+ def auto_purge(self):
387
+ # Automatically purge messages every 100k iteration. Dramatiq defaults
388
+ # to 1s. This mean about 1 purge for 28h idle.
389
+ if randint(0, 100_000):
390
+ return
391
+ logger.debug("Randomly triggering garbage collector.")
392
+ with transaction(self._consume_conn) as curs:
393
+ curs.execute(self.queries.PURGE, ("30 days",))
394
+ deleted = curs.rowcount
395
+ logger.info("Purged %d messages in all queues.", deleted)
396
+
397
+ def close(self):
398
+ # Closing the sessions releases subscriptions and all advisory locks,
399
+ # including after a disconnect or a partially completed shutdown.
400
+ for name in ("_listen_conn", "_consume_conn"):
401
+ conn = getattr(self, name)
402
+ if conn is not None:
403
+ conn.close()
404
+ self.pool.putconn(conn)
405
+ setattr(self, name, None)
406
+
407
+ def get_consume_conn(self):
408
+ # Ensure connection used for message consumption is steady.
409
+ if self._consume_conn is not None:
410
+ try:
411
+ check_conn(self._consume_conn)
412
+ except BrokerConnectionError:
413
+ logger.info("Connection closed. Reconnecting...")
414
+ self.pool.putconn(self._consume_conn)
415
+ self._consume_conn = None
416
+
417
+ if self._consume_conn is None:
418
+ logger.debug("Asking new connection for message consumption.")
419
+ self._consume_conn = getconn(self.pool)
420
+
421
+ return self._consume_conn
422
+
423
+ @raise_connection_error
424
+ def get_listen_conn(self):
425
+ # Opens listening connection with proper configuration.
426
+ if self._listen_conn is not None:
427
+ try:
428
+ return check_conn(self._listen_conn)
429
+ except BrokerConnectionError:
430
+ logger.info("Connection closed. Reconnecting...")
431
+ self.pool.putconn(self._listen_conn)
432
+ self._listen_conn = None
433
+
434
+ self._listen_conn = conn = getconn(self.pool)
435
+ # This is for NOTIFY consistency, according to Psycopg documentation.
436
+ conn.autocommit = True
437
+ channel = sql.Identifier(self.queries.channel(self.queue_name, "enqueue"))
438
+ with conn.cursor() as curs:
439
+ logger.debug("Listening on channel %s.", channel)
440
+ curs.execute(sql.SQL("LISTEN {}").format(channel))
441
+ return self._listen_conn
442
+
443
+ @raise_connection_error
444
+ def consume_one(self, message):
445
+ if message.message_id in self.in_processing:
446
+ logger.debug("%s already consumed by self.", message.message_id)
447
+ return
448
+
449
+ # Race to process message.
450
+ with transaction(self.get_consume_conn()) as curs:
451
+ if self.queue_control and not allow_start(
452
+ curs, self.queue_name, self.queries.schema, self.queries.prefix
453
+ ):
454
+ return False
455
+ lock = message_lock(message.copy(queue_name=self.queue_name), schema=self.queries.schema, prefix=self.queries.prefix)
456
+ curs.execute(self.queries.CONSUME_ONE, (message.message_id, self.queue_name, lock))
457
+ row = curs.fetchone()
458
+ # If no row was updated, this mean another worker has consumed it.
459
+ successfully_consumed = row is not None
460
+
461
+ if successfully_consumed:
462
+ logger.info("Consumed %s@%s.", message.message_id, message.queue_name)
463
+ else:
464
+ # Release the lock in case lock acquisition took place before
465
+ # other clauses failed.
466
+ curs.execute(self.queries.RELEASE_ONE, (lock,))
467
+
468
+ return Message.decode(row[0].encode()) if successfully_consumed else None
469
+
470
+ @raise_connection_error
471
+ def nack(self, message):
472
+ # This function is executed in worker thread.
473
+
474
+ with transaction(self.pool) as curs:
475
+ # Use the same channel as ack. Actually means done.
476
+ channel = self.queries.channel(message.queue_name, "ack")
477
+ logger.debug("Notifying %s for NACK %s.", channel, message.message_id)
478
+ payload = tidy4json(message)
479
+ curs.execute(
480
+ self.queries.NACK,
481
+ (
482
+ Jsonb(payload),
483
+ message.message_id,
484
+ message.queue_name,
485
+ channel,
486
+ message.message_id,
487
+ ),
488
+ )
489
+ self.unlock_q.put_nowait(message)
490
+ self.in_processing.remove(message.message_id)
491
+
492
+ @raise_connection_error
493
+ def fetch_pending_notifies(self):
494
+ logger.debug("Polling for lost messages in %s.", self.queue_name)
495
+ # Get or open connection.
496
+ conn = self.get_listen_conn()
497
+ # We may have received a notify between LISTEN and SELECT of pending
498
+ # messages. That's not a problem because we are able to skip spurious
499
+ # notifies.
500
+ channel = self.queries.channel(self.queue_name, "enqueue")
501
+ with transaction(conn) as curs:
502
+ curs.execute(self.queries.FETCH_PENDING, (self.queue_name,))
503
+ return [Notify(pid=0, channel=channel, payload=r[0]) for r in curs]
504
+
505
+ @raise_connection_error
506
+ def poll_for_notify(self):
507
+ self.notifies += wait_for_notifies(self.get_listen_conn(), self.timeout)
508
+
509
+ @raise_connection_error
510
+ def purge_locks(self):
511
+ with transaction(self.get_consume_conn()) as curs:
512
+ while True:
513
+ try:
514
+ message = self.unlock_q.get(block=False)
515
+ except Empty:
516
+ return
517
+ lock = message_lock(message, schema=self.queries.schema, prefix=self.queries.prefix)
518
+ logger.debug(
519
+ "Unlocking %s@%s (%s).",
520
+ message.message_id,
521
+ message.queue_name,
522
+ lock,
523
+ )
524
+ curs.execute(
525
+ self.queries.RELEASE_ONE,
526
+ (lock,),
527
+ )
528
+ # Retry may be queued while this attempt still holds its lock.
529
+ curs.execute(
530
+ self.queries.NOTIFY_UNLOCKED,
531
+ (self.queries.channel(message.queue_name, "enqueue"),
532
+ message.message_id, message.message_id, message.queue_name),
533
+ )
534
+ self.unlock_q.task_done()
535
+
536
+ @raise_connection_error
537
+ def requeue(self, messages):
538
+ messages = list(messages)
539
+ if not len(messages):
540
+ return
541
+
542
+ logger.debug("Batch update of messages for requeue.")
543
+ with transaction(self.get_consume_conn()) as curs:
544
+ curs.execute(self.queries.REQUEUE, ([str(m.message_id) for m in messages],))
545
+ # We don't bother about locks, because requeue occurs on worker
546
+ # stop.
547
+
548
+
549
+ _max_positive_int = 2**63
550
+
551
+
552
+ def message_lock(message, *, schema="dramatiq", prefix=""):
553
+ # create sha256 hash from input and create a 64 bit int from it, using
554
+ # 16 hex char. any 16 char range is ok. it takes the center ones
555
+ global_id = message.queue_name + str(message.message_id)
556
+ if schema != "dramatiq" or prefix:
557
+ global_id = storage_namespace(schema, prefix) + global_id
558
+ hex = sha256(global_id.encode("utf-8")).hexdigest()
559
+ unsigned = int(hex[24:40], 16)
560
+ # PostgreSQL lock is a signed int on 64 bytes. Shift unsigned value from
561
+ # interval [0..2**64] to interval [-2**63..2**63].
562
+ return unsigned - _max_positive_int
563
+
564
+
565
+ QUERIES = QueryManager(
566
+ dict(
567
+ ACK=dedent(
568
+ """\
569
+ WITH updated AS (
570
+ UPDATE {schema}.{tablename}
571
+ SET "state" = 'done', message = %s
572
+ WHERE message_id = %s
573
+ AND queue_name = %s
574
+ AND state = 'consumed'
575
+ RETURNING message
576
+ )
577
+ SELECT
578
+ pg_notify(%s,
579
+ jsonb_build_object('message_id', %s::text)::text
580
+ )
581
+ FROM updated;
582
+ """
583
+ ),
584
+ DEFER_PAUSED="""
585
+ UPDATE {schema}.{tablename} SET state = 'queued'
586
+ WHERE message_id = %s AND queue_name = %s AND state = 'consumed';
587
+ """,
588
+ CONSUME_ONE=dedent(
589
+ """\
590
+ UPDATE {schema}.{tablename}
591
+ SET "state" = 'consumed', started = FALSE,
592
+ mtime = NOW()
593
+ WHERE message_id = %s AND queue_name = %s
594
+ AND state IN ('queued', 'consumed')
595
+ AND pg_try_advisory_lock(%s)
596
+ RETURNING message::text;
597
+ """
598
+ ),
599
+ NOTIFY_UNLOCKED="""
600
+ SELECT pg_notify(%s, jsonb_build_object('message_id', %s::text)::text)
601
+ FROM {schema}.{tablename}
602
+ WHERE message_id = %s AND queue_name = %s AND state = 'queued';
603
+ """,
604
+ RELEASE_ONE="""SELECT pg_advisory_unlock(%s)""",
605
+ ENQUEUE=dedent(
606
+ """\
607
+ WITH enqueued AS (
608
+ INSERT INTO {schema}.{tablename}
609
+ (queue_name, message_id, "state", message)
610
+ VALUES (%s, %s, 'queued', %s)
611
+ ON CONFLICT (message_id)
612
+ DO UPDATE SET
613
+ "state" = 'queued', started = FALSE,
614
+ message = EXCLUDED.message,
615
+ mtime = clock_timestamp(),
616
+ queue_name = EXCLUDED.queue_name
617
+ WHERE {schema}.{tablename}.state <> 'cancelled'
618
+ RETURNING queue_name, message
619
+ )
620
+ SELECT
621
+ pg_notify(%s,
622
+ jsonb_build_object('message_id', %s::text)::text
623
+ )
624
+ FROM enqueued;
625
+ """
626
+ ), # noqa
627
+ FETCH_PENDING=dedent(
628
+ """\
629
+ SELECT message::text
630
+ FROM {schema}.{tablename}
631
+ WHERE state IN ('queued', 'consumed')
632
+ AND queue_name = %s;
633
+ """
634
+ ),
635
+ NACK=dedent(
636
+ """\
637
+ WITH updated AS (
638
+ UPDATE {schema}.{tablename}
639
+ SET "state" = 'rejected', message = %s
640
+ WHERE message_id = %s
641
+ AND queue_name = %s
642
+ AND state IN ('queued', 'consumed')
643
+ RETURNING message
644
+ )
645
+ SELECT
646
+ pg_notify(%s,
647
+ jsonb_build_object('message_id', %s::text)::text
648
+ )
649
+ FROM updated;
650
+ """
651
+ ),
652
+ PURGE=dedent(
653
+ """\
654
+ DELETE FROM {schema}.{tablename}
655
+ WHERE "state" IN ('done', 'rejected', 'cancelled')
656
+ AND mtime <= (NOW() - %s::interval);
657
+ """
658
+ ),
659
+ REQUEUE=dedent(
660
+ """\
661
+ UPDATE {schema}.{tablename}
662
+ SET state = 'queued', started = FALSE, mtime = clock_timestamp()
663
+ WHERE message_id = ANY(%s::uuid[]) AND state IN ('queued', 'consumed');
664
+ """
665
+ ),
666
+ )
667
+ )