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/cancellation.py
ADDED
|
@@ -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,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
|
+
}
|