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 +15 -0
- iddqueue/broker.py +667 -0
- iddqueue/cancellation.py +51 -0
- iddqueue/cancellation.sql +6 -0
- iddqueue/cli.py +474 -0
- iddqueue/control.py +87 -0
- iddqueue/control.sql +6 -0
- iddqueue/coordination.sql +11 -0
- iddqueue/deduplication.sql +12 -0
- iddqueue/failures.py +18 -0
- iddqueue/history.py +70 -0
- iddqueue/history.sql +13 -0
- iddqueue/metrics.py +68 -0
- iddqueue/rate_limits.py +122 -0
- iddqueue/results.py +118 -0
- iddqueue/scheduler.py +75 -0
- iddqueue/scheduler.sql +9 -0
- iddqueue/schema.py +55 -0
- iddqueue/schema.sql +31 -0
- iddqueue/utils.py +206 -0
- iddqueue-0.13.0rc1.dist-info/METADATA +730 -0
- iddqueue-0.13.0rc1.dist-info/RECORD +25 -0
- iddqueue-0.13.0rc1.dist-info/WHEEL +4 -0
- iddqueue-0.13.0rc1.dist-info/entry_points.txt +3 -0
- iddqueue-0.13.0rc1.dist-info/licenses/LICENSE +19 -0
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
|
+
)
|