py-app-runner 0.5.49.dev0__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.
- py_app_runner/__init__.py +11 -0
- py_app_runner/audit/__init__.py +29 -0
- py_app_runner/audit/_service.py +91 -0
- py_app_runner/audit/_service_args.py +44 -0
- py_app_runner/audit/audit.py +319 -0
- py_app_runner/audit/commands.py +151 -0
- py_app_runner/audit/diff.py +202 -0
- py_app_runner/audit/errors.py +8 -0
- py_app_runner/audit/event.py +130 -0
- py_app_runner/audit/store.py +134 -0
- py_app_runner/bridge/__init__.py +0 -0
- py_app_runner/bridge/_service.py +265 -0
- py_app_runner/bridge/_service_args.py +24 -0
- py_app_runner/bridge/api.py +138 -0
- py_app_runner/bridge/encoders/__init__.py +5 -0
- py_app_runner/bridge/encoders/base.py +24 -0
- py_app_runner/bridge/encoders/json_encoder.py +26 -0
- py_app_runner/bridge/encoders/msgpack_encoder.py +58 -0
- py_app_runner/bridge/web_app.py +31 -0
- py_app_runner/bridge/websocket.py +313 -0
- py_app_runner/colors.py +73 -0
- py_app_runner/config.py +132 -0
- py_app_runner/crypto/__init__.py +14 -0
- py_app_runner/crypto/_service.py +75 -0
- py_app_runner/crypto/_service_args.py +54 -0
- py_app_runner/crypto/commands.py +164 -0
- py_app_runner/crypto/envelope.py +144 -0
- py_app_runner/crypto/errors.py +8 -0
- py_app_runner/crypto/fields.py +300 -0
- py_app_runner/crypto/passwords.py +66 -0
- py_app_runner/db_pools.py +20 -0
- py_app_runner/http_exception.py +31 -0
- py_app_runner/logger_handlers.py +167 -0
- py_app_runner/migrations/__init__.py +5 -0
- py_app_runner/migrations/_service.py +296 -0
- py_app_runner/migrations/_service_args.py +91 -0
- py_app_runner/migrations/commands.py +386 -0
- py_app_runner/migrations/discovery.py +108 -0
- py_app_runner/migrations/states.py +63 -0
- py_app_runner/migrations/tracker.py +141 -0
- py_app_runner/py.typed +0 -0
- py_app_runner/pybridge.py +64 -0
- py_app_runner/queue/__init__.py +25 -0
- py_app_runner/queue/_service.py +231 -0
- py_app_runner/queue/_service_args.py +67 -0
- py_app_runner/queue/commands.py +180 -0
- py_app_runner/queue/driver_pg.py +464 -0
- py_app_runner/queue/driver_redis.py +613 -0
- py_app_runner/queue/handler.py +90 -0
- py_app_runner/queue/interface.py +63 -0
- py_app_runner/queue/job.py +46 -0
- py_app_runner/queue/worker.py +221 -0
- py_app_runner/registry.py +54 -0
- py_app_runner/request_handler/__init__.py +0 -0
- py_app_runner/request_handler/auth_service.py +123 -0
- py_app_runner/request_handler/decorators.py +304 -0
- py_app_runner/request_handler/handlers.py +604 -0
- py_app_runner/request_handler/pagination.py +24 -0
- py_app_runner/return_model.py +78 -0
- py_app_runner/runner.py +182 -0
- py_app_runner/throttle/__init__.py +5 -0
- py_app_runner/throttle/throttle.py +217 -0
- py_app_runner/tick_service.py +308 -0
- py_app_runner/timer.py +289 -0
- py_app_runner/utils.py +346 -0
- py_app_runner/wbcm/__init__.py +0 -0
- py_app_runner/wbcm/device_connections.py +89 -0
- py_app_runner/wbcm/factory.py +113 -0
- py_app_runner/wbcm/wb_connection_manager.py +333 -0
- py_app_runner/wbcm/ws_interface.py +56 -0
- py_app_runner-0.5.49.dev0.dist-info/METADATA +134 -0
- py_app_runner-0.5.49.dev0.dist-info/RECORD +75 -0
- py_app_runner-0.5.49.dev0.dist-info/WHEEL +5 -0
- py_app_runner-0.5.49.dev0.dist-info/licenses/LICENSE +21 -0
- py_app_runner-0.5.49.dev0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,464 @@
|
|
|
1
|
+
"""The PostgreSQL driver.
|
|
2
|
+
|
|
3
|
+
This is the one to reach for first, and the reason is the push: it is an INSERT, so it
|
|
4
|
+
joins the transaction that caused it. Queue the confirmation email inside the transaction
|
|
5
|
+
that writes the payment and either both happen or neither does. No other backend can offer
|
|
6
|
+
that, and it is the entire argument for keeping jobs in the application's own database
|
|
7
|
+
rather than somewhere faster.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import datetime
|
|
11
|
+
import json
|
|
12
|
+
import re
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
import psycopg
|
|
16
|
+
from psycopg import sql
|
|
17
|
+
|
|
18
|
+
from py_app_runner.queue.job import Job, QueueError
|
|
19
|
+
|
|
20
|
+
# Table names reach SQL as identifiers, which cannot be bound. Quoted by Identifier, but
|
|
21
|
+
# whitelisted first for the same reason the audit table is.
|
|
22
|
+
_TABLE_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)?$")
|
|
23
|
+
|
|
24
|
+
# A claim can be lost to another worker between the candidate select and the guarded
|
|
25
|
+
# update. Retrying a few times is cheaper than sleeping the whole poll interval when there
|
|
26
|
+
# is obviously work; giving up after five stops a hot queue from spinning here forever.
|
|
27
|
+
CLAIM_ROUNDS = 5
|
|
28
|
+
|
|
29
|
+
# Errors are cut rather than rejected. A traceback that will not fit must not be the reason
|
|
30
|
+
# a job cannot be recorded as failed.
|
|
31
|
+
MAX_ERROR_BYTES = 60000
|
|
32
|
+
|
|
33
|
+
_JOB_COLUMNS = "id, queue, name, payload, attempts, max_attempts, priority, unique_key"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def assert_table_name(table: str) -> str:
|
|
37
|
+
if _TABLE_RE.match(table) is None:
|
|
38
|
+
raise QueueError(f"{table!r} is not a plain table name")
|
|
39
|
+
|
|
40
|
+
return table
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _qualified(table: str) -> sql.Composed:
|
|
44
|
+
return sql.SQL(".").join(sql.Identifier(part) for part in table.split("."))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def fit_error(error: str) -> str:
|
|
48
|
+
raw = error.encode("utf-8")
|
|
49
|
+
if len(raw) <= MAX_ERROR_BYTES:
|
|
50
|
+
return error
|
|
51
|
+
|
|
52
|
+
# Cut on a character boundary: slicing bytes can split a multi-byte sequence and leave
|
|
53
|
+
# a value Postgres refuses as invalid UTF-8, which would turn a failed job into a
|
|
54
|
+
# failed *write*.
|
|
55
|
+
return raw[:MAX_ERROR_BYTES].decode("utf-8", errors="ignore") + "\n... truncated"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def encode_payload(payload: dict[str, Any]) -> str:
|
|
59
|
+
try:
|
|
60
|
+
return json.dumps(payload, ensure_ascii=False)
|
|
61
|
+
except (TypeError, ValueError) as e:
|
|
62
|
+
raise QueueError(f"A job payload has to be JSON encodable: {e}") from e
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def column_names(cur: psycopg.AsyncCursor) -> list[str]:
|
|
66
|
+
"""Column names of the last result set.
|
|
67
|
+
|
|
68
|
+
`description` is None for a statement that returned no result set at all. Every caller
|
|
69
|
+
here has just run a SELECT, so that is an invariant rather than a case - but reading it
|
|
70
|
+
unguarded is how a refactor that moves one of these onto a non-returning statement gets
|
|
71
|
+
a `NoneType is not iterable` a long way from the cause.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
if cur.description is None:
|
|
75
|
+
raise QueueError("Expected a result set from the queue, and the statement returned none.")
|
|
76
|
+
|
|
77
|
+
return [d.name for d in cur.description]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def now_utc() -> datetime.datetime:
|
|
81
|
+
"""Every time comparison binds a value computed here rather than using now().
|
|
82
|
+
|
|
83
|
+
The worker's clock and the database's are allowed to differ; what must not happen is
|
|
84
|
+
the two being mixed inside one comparison, which is what makes a job's due time depend
|
|
85
|
+
on which machine asked.
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
return datetime.datetime.now(datetime.UTC)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class PgQueue:
|
|
92
|
+
def __init__(
|
|
93
|
+
self,
|
|
94
|
+
conn: psycopg.AsyncConnection,
|
|
95
|
+
table: str = "queue_jobs",
|
|
96
|
+
failed_table: str = "queue_failed_jobs",
|
|
97
|
+
) -> None:
|
|
98
|
+
self.conn = conn
|
|
99
|
+
self.table = assert_table_name(table)
|
|
100
|
+
self.failed_table = assert_table_name(failed_table)
|
|
101
|
+
|
|
102
|
+
############
|
|
103
|
+
### Push ###
|
|
104
|
+
############
|
|
105
|
+
|
|
106
|
+
async def push(
|
|
107
|
+
self,
|
|
108
|
+
name: str,
|
|
109
|
+
payload: dict[str, Any] | None = None,
|
|
110
|
+
delay: int = 0,
|
|
111
|
+
queue: str = "default",
|
|
112
|
+
priority: int = 0,
|
|
113
|
+
unique: str | None = None,
|
|
114
|
+
max_attempts: int = 3,
|
|
115
|
+
) -> int:
|
|
116
|
+
"""Queue a job. Returns its id, or the id of the job already holding `unique`.
|
|
117
|
+
|
|
118
|
+
Deliberately not wrapped in a transaction of its own: the whole point is that it
|
|
119
|
+
joins whatever the caller already has open.
|
|
120
|
+
"""
|
|
121
|
+
|
|
122
|
+
moment = now_utc()
|
|
123
|
+
key = unique or None
|
|
124
|
+
|
|
125
|
+
if key is not None:
|
|
126
|
+
existing = await self._id_for_unique(key)
|
|
127
|
+
if existing is not None:
|
|
128
|
+
return existing
|
|
129
|
+
|
|
130
|
+
row = {
|
|
131
|
+
"queue": queue or "default",
|
|
132
|
+
"name": name,
|
|
133
|
+
"payload": encode_payload(payload or {}),
|
|
134
|
+
"attempts": 0,
|
|
135
|
+
"max_attempts": max(1, max_attempts),
|
|
136
|
+
"priority": priority,
|
|
137
|
+
"unique_key": key,
|
|
138
|
+
"available_at": moment + datetime.timedelta(seconds=max(0, delay)),
|
|
139
|
+
"last_error": "",
|
|
140
|
+
"created_at": moment,
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
columns = list(row.keys())
|
|
144
|
+
statement = sql.SQL("INSERT INTO {rel} ({cols}) VALUES ({vals}) RETURNING id").format(
|
|
145
|
+
rel=_qualified(self.table),
|
|
146
|
+
cols=sql.SQL(", ").join(sql.Identifier(c) for c in columns),
|
|
147
|
+
vals=sql.SQL(", ").join(sql.Placeholder() for _ in columns),
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
try:
|
|
151
|
+
async with self.conn.cursor() as cur:
|
|
152
|
+
await cur.execute(statement, tuple(row[c] for c in columns))
|
|
153
|
+
returned = await cur.fetchone()
|
|
154
|
+
if returned is None:
|
|
155
|
+
# RETURNING on a successful single-row INSERT always yields a row, so
|
|
156
|
+
# this is an invariant rather than a case - but silently returning 0
|
|
157
|
+
# would hand the caller a job id that refers to nothing.
|
|
158
|
+
raise QueueError("The queue insert reported no id; the job may not have been queued.")
|
|
159
|
+
|
|
160
|
+
return int(returned[0])
|
|
161
|
+
except psycopg.errors.UniqueViolation:
|
|
162
|
+
# Lost the race for the unique key. On Postgres this aborts the surrounding
|
|
163
|
+
# transaction, so the caller has to decide what to do next - but reporting the
|
|
164
|
+
# winner's id is still the honest answer to "what is queued under this key".
|
|
165
|
+
existing = await self._id_for_unique(key) if key is not None else None
|
|
166
|
+
if existing is not None:
|
|
167
|
+
return existing
|
|
168
|
+
|
|
169
|
+
raise
|
|
170
|
+
|
|
171
|
+
async def _id_for_unique(self, key: str) -> int | None:
|
|
172
|
+
statement = sql.SQL("SELECT id FROM {rel} WHERE unique_key = %s").format(rel=_qualified(self.table))
|
|
173
|
+
|
|
174
|
+
async with self.conn.cursor() as cur:
|
|
175
|
+
await cur.execute(statement, (key,))
|
|
176
|
+
row = await cur.fetchone()
|
|
177
|
+
return int(row[0]) if row else None
|
|
178
|
+
|
|
179
|
+
###############
|
|
180
|
+
### Reserve ###
|
|
181
|
+
###############
|
|
182
|
+
|
|
183
|
+
async def reserve(self, queues: list[str], timeout: int, worker: str) -> Job | None:
|
|
184
|
+
"""Claim the next due job, or None.
|
|
185
|
+
|
|
186
|
+
`queues` is a precedence order, not a merged sort: ["high", "default"] drains high
|
|
187
|
+
completely first. That is what "priority queue" means to somebody running two of
|
|
188
|
+
them, and a merged sort would quietly make `high` mean "slightly sooner".
|
|
189
|
+
"""
|
|
190
|
+
|
|
191
|
+
for queue in queues:
|
|
192
|
+
job = await self._reserve_from(queue, timeout, worker)
|
|
193
|
+
if job is not None:
|
|
194
|
+
return job
|
|
195
|
+
|
|
196
|
+
return None
|
|
197
|
+
|
|
198
|
+
async def _reserve_from(self, queue: str, timeout: int, worker: str) -> Job | None:
|
|
199
|
+
for _ in range(CLAIM_ROUNDS):
|
|
200
|
+
moment = now_utc()
|
|
201
|
+
until = moment + datetime.timedelta(seconds=max(1, timeout))
|
|
202
|
+
|
|
203
|
+
# One transaction around candidate-and-claim, so the FOR UPDATE lock still holds
|
|
204
|
+
# when the UPDATE runs. It ends before the handler is called: holding a
|
|
205
|
+
# transaction open for the length of a job is how a queue takes a database down
|
|
206
|
+
# with it.
|
|
207
|
+
async with self.conn.transaction():
|
|
208
|
+
candidate = await self._candidate(queue, moment)
|
|
209
|
+
if candidate is None:
|
|
210
|
+
return None
|
|
211
|
+
|
|
212
|
+
claimed = await self._claim(candidate, until, worker, moment)
|
|
213
|
+
if not claimed:
|
|
214
|
+
# Another worker got it between the select and the update. The guard did
|
|
215
|
+
# its job; try for a different one.
|
|
216
|
+
continue
|
|
217
|
+
|
|
218
|
+
row = await self._read(candidate)
|
|
219
|
+
|
|
220
|
+
if row is None:
|
|
221
|
+
continue
|
|
222
|
+
|
|
223
|
+
job = self._to_job(row)
|
|
224
|
+
if job is None:
|
|
225
|
+
# Undecodable payload. It is not going to decode on the next attempt either,
|
|
226
|
+
# so it goes straight to failed rather than burning its budget first.
|
|
227
|
+
await self._fail_row(row, "Payload is not valid JSON, so no handler could be given it.")
|
|
228
|
+
continue
|
|
229
|
+
|
|
230
|
+
return job
|
|
231
|
+
|
|
232
|
+
return None
|
|
233
|
+
|
|
234
|
+
async def _candidate(self, queue: str, moment: datetime.datetime) -> int | None:
|
|
235
|
+
statement = sql.SQL(
|
|
236
|
+
"SELECT id FROM {rel} "
|
|
237
|
+
"WHERE queue = %s AND available_at <= %s "
|
|
238
|
+
" AND (reserved_until IS NULL OR reserved_until <= %s) "
|
|
239
|
+
"ORDER BY priority DESC, available_at, id "
|
|
240
|
+
"LIMIT 1 FOR UPDATE SKIP LOCKED"
|
|
241
|
+
).format(rel=_qualified(self.table))
|
|
242
|
+
|
|
243
|
+
async with self.conn.cursor() as cur:
|
|
244
|
+
await cur.execute(statement, (queue, moment, moment))
|
|
245
|
+
row = await cur.fetchone()
|
|
246
|
+
return int(row[0]) if row else None
|
|
247
|
+
|
|
248
|
+
async def _claim(self, job_id: int, until: datetime.datetime, worker: str, moment: datetime.datetime) -> bool:
|
|
249
|
+
"""The guarded update.
|
|
250
|
+
|
|
251
|
+
The guard in the WHERE - not SKIP LOCKED - is what makes the claim safe. SKIP LOCKED
|
|
252
|
+
only stops workers queueing behind each other; remove the guard and two workers that
|
|
253
|
+
both read the same candidate would both believe they hold it. This is exactly the
|
|
254
|
+
line a later simplification would delete, so it is spelled out here.
|
|
255
|
+
"""
|
|
256
|
+
|
|
257
|
+
statement = sql.SQL(
|
|
258
|
+
"UPDATE {rel} SET reserved_until = %s, reserved_by = %s, attempts = attempts + 1 "
|
|
259
|
+
"WHERE id = %s AND (reserved_until IS NULL OR reserved_until <= %s)"
|
|
260
|
+
).format(rel=_qualified(self.table))
|
|
261
|
+
|
|
262
|
+
async with self.conn.cursor() as cur:
|
|
263
|
+
await cur.execute(statement, (until, worker[:64], job_id, moment))
|
|
264
|
+
return cur.rowcount == 1
|
|
265
|
+
|
|
266
|
+
async def _read(self, job_id: int) -> dict[str, Any] | None:
|
|
267
|
+
statement = sql.SQL("SELECT " + _JOB_COLUMNS + " FROM {rel} WHERE id = %s").format(rel=_qualified(self.table))
|
|
268
|
+
|
|
269
|
+
async with self.conn.cursor() as cur:
|
|
270
|
+
await cur.execute(statement, (job_id,))
|
|
271
|
+
row = await cur.fetchone()
|
|
272
|
+
if row is None:
|
|
273
|
+
return None
|
|
274
|
+
|
|
275
|
+
return dict(zip(column_names(cur), row, strict=True))
|
|
276
|
+
|
|
277
|
+
def _to_job(self, row: dict[str, Any]) -> Job | None:
|
|
278
|
+
try:
|
|
279
|
+
payload = json.loads(row["payload"])
|
|
280
|
+
except (TypeError, ValueError):
|
|
281
|
+
return None
|
|
282
|
+
|
|
283
|
+
if not isinstance(payload, dict):
|
|
284
|
+
return None
|
|
285
|
+
|
|
286
|
+
return Job(
|
|
287
|
+
id=int(row["id"]),
|
|
288
|
+
queue=row["queue"],
|
|
289
|
+
name=row["name"],
|
|
290
|
+
payload=payload,
|
|
291
|
+
payload_json=row["payload"],
|
|
292
|
+
attempts=int(row["attempts"]),
|
|
293
|
+
max_attempts=int(row["max_attempts"]),
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
##################
|
|
297
|
+
### Completion ###
|
|
298
|
+
##################
|
|
299
|
+
|
|
300
|
+
async def delete(self, job: Job) -> None:
|
|
301
|
+
statement = sql.SQL("DELETE FROM {rel} WHERE id = %s").format(rel=_qualified(self.table))
|
|
302
|
+
|
|
303
|
+
async with self.conn.cursor() as cur:
|
|
304
|
+
await cur.execute(statement, (job.id,))
|
|
305
|
+
|
|
306
|
+
async def release(self, job: Job, delay: int = 0, error: str = "") -> None:
|
|
307
|
+
"""Put a job back. `attempts` is untouched - the claim already counted it."""
|
|
308
|
+
|
|
309
|
+
statement = sql.SQL(
|
|
310
|
+
"UPDATE {rel} SET reserved_until = NULL, reserved_by = NULL, available_at = %s, last_error = %s "
|
|
311
|
+
"WHERE id = %s"
|
|
312
|
+
).format(rel=_qualified(self.table))
|
|
313
|
+
|
|
314
|
+
available = now_utc() + datetime.timedelta(seconds=max(0, delay))
|
|
315
|
+
|
|
316
|
+
async with self.conn.cursor() as cur:
|
|
317
|
+
await cur.execute(statement, (available, fit_error(error), job.id))
|
|
318
|
+
|
|
319
|
+
async def fail(self, job: Job, error: str) -> None:
|
|
320
|
+
await self._move_to_failed(
|
|
321
|
+
job_id=job.id,
|
|
322
|
+
queue=job.queue,
|
|
323
|
+
name=job.name,
|
|
324
|
+
payload=job.payload_json,
|
|
325
|
+
attempts=job.attempts,
|
|
326
|
+
error=error,
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
async def _fail_row(self, row: dict[str, Any], error: str) -> None:
|
|
330
|
+
await self._move_to_failed(
|
|
331
|
+
job_id=int(row["id"]),
|
|
332
|
+
queue=row["queue"],
|
|
333
|
+
name=row["name"],
|
|
334
|
+
payload=row["payload"],
|
|
335
|
+
attempts=int(row["attempts"]),
|
|
336
|
+
error=error,
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
async def _move_to_failed(
|
|
340
|
+
self, job_id: int, queue: str, name: str, payload: str, attempts: int, error: str
|
|
341
|
+
) -> None:
|
|
342
|
+
insert = sql.SQL(
|
|
343
|
+
"INSERT INTO {rel} (queue, name, payload, attempts, error, failed_at) VALUES (%s, %s, %s, %s, %s, %s)"
|
|
344
|
+
).format(rel=_qualified(self.failed_table))
|
|
345
|
+
delete = sql.SQL("DELETE FROM {rel} WHERE id = %s").format(rel=_qualified(self.table))
|
|
346
|
+
|
|
347
|
+
# One transaction so a job cannot be in both tables or neither.
|
|
348
|
+
async with self.conn.transaction():
|
|
349
|
+
async with self.conn.cursor() as cur:
|
|
350
|
+
await cur.execute(insert, (queue, name, payload, attempts, fit_error(error), now_utc()))
|
|
351
|
+
await cur.execute(delete, (job_id,))
|
|
352
|
+
|
|
353
|
+
###############
|
|
354
|
+
### Reports ###
|
|
355
|
+
###############
|
|
356
|
+
|
|
357
|
+
async def pending(self, queue: str | None = None) -> int:
|
|
358
|
+
"""How many could be picked up right now: excludes delayed jobs that are not due and
|
|
359
|
+
jobs another worker currently holds."""
|
|
360
|
+
|
|
361
|
+
moment = now_utc()
|
|
362
|
+
condition = sql.SQL("available_at <= %s AND (reserved_until IS NULL OR reserved_until <= %s)")
|
|
363
|
+
params: tuple[Any, ...] = (moment, moment)
|
|
364
|
+
|
|
365
|
+
if queue is not None:
|
|
366
|
+
condition = sql.SQL("queue = %s AND ") + condition
|
|
367
|
+
params = (queue, *params)
|
|
368
|
+
|
|
369
|
+
statement = sql.SQL("SELECT count(*) FROM {rel} WHERE {cond}").format(
|
|
370
|
+
rel=_qualified(self.table), cond=condition
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
async with self.conn.cursor() as cur:
|
|
374
|
+
await cur.execute(statement, params)
|
|
375
|
+
row = await cur.fetchone()
|
|
376
|
+
return int(row[0]) if row else 0
|
|
377
|
+
|
|
378
|
+
async def stats(self) -> list[dict[str, Any]]:
|
|
379
|
+
moment = now_utc()
|
|
380
|
+
statement = sql.SQL(
|
|
381
|
+
"SELECT queue,"
|
|
382
|
+
" count(*) FILTER (WHERE available_at <= %s"
|
|
383
|
+
" AND (reserved_until IS NULL OR reserved_until <= %s)) AS pending,"
|
|
384
|
+
" count(*) FILTER (WHERE available_at > %s) AS delayed,"
|
|
385
|
+
" count(*) FILTER (WHERE reserved_until > %s) AS reserved,"
|
|
386
|
+
" count(*) AS total "
|
|
387
|
+
"FROM {rel} GROUP BY queue ORDER BY queue"
|
|
388
|
+
).format(rel=_qualified(self.table))
|
|
389
|
+
|
|
390
|
+
async with self.conn.cursor() as cur:
|
|
391
|
+
await cur.execute(statement, (moment, moment, moment, moment))
|
|
392
|
+
names = column_names(cur)
|
|
393
|
+
return [dict(zip(names, row, strict=True)) for row in await cur.fetchall()]
|
|
394
|
+
|
|
395
|
+
async def failed_count(self) -> int:
|
|
396
|
+
statement = sql.SQL("SELECT count(*) FROM {rel}").format(rel=_qualified(self.failed_table))
|
|
397
|
+
|
|
398
|
+
async with self.conn.cursor() as cur:
|
|
399
|
+
await cur.execute(statement)
|
|
400
|
+
row = await cur.fetchone()
|
|
401
|
+
return int(row[0]) if row else 0
|
|
402
|
+
|
|
403
|
+
async def failed_rows(self, limit: int) -> list[dict[str, Any]]:
|
|
404
|
+
statement = sql.SQL(
|
|
405
|
+
"SELECT id, failed_at, queue, name, attempts, error FROM {rel} ORDER BY failed_at DESC, id DESC LIMIT %s"
|
|
406
|
+
).format(rel=_qualified(self.failed_table))
|
|
407
|
+
|
|
408
|
+
async with self.conn.cursor() as cur:
|
|
409
|
+
await cur.execute(statement, (max(1, limit),))
|
|
410
|
+
names = column_names(cur)
|
|
411
|
+
return [dict(zip(names, row, strict=True)) for row in await cur.fetchall()]
|
|
412
|
+
|
|
413
|
+
async def retry_failed(self, job_id: int | None, max_attempts: int) -> int:
|
|
414
|
+
"""Move failed jobs back onto the queue. Returns how many were requeued.
|
|
415
|
+
|
|
416
|
+
One transaction per job rather than one for the batch: four hundred failed jobs
|
|
417
|
+
should not be an all-or-nothing operation, and an interrupted run leaves the ones it
|
|
418
|
+
already moved on the queue.
|
|
419
|
+
"""
|
|
420
|
+
|
|
421
|
+
select = sql.SQL("SELECT id, queue, name, payload FROM {rel}").format(rel=_qualified(self.failed_table))
|
|
422
|
+
params: tuple[Any, ...] = ()
|
|
423
|
+
if job_id is not None:
|
|
424
|
+
select = select + sql.SQL(" WHERE id = %s")
|
|
425
|
+
params = (job_id,)
|
|
426
|
+
|
|
427
|
+
async with self.conn.cursor() as cur:
|
|
428
|
+
await cur.execute(select + sql.SQL(" ORDER BY id"), params)
|
|
429
|
+
rows = await cur.fetchall()
|
|
430
|
+
|
|
431
|
+
insert = sql.SQL(
|
|
432
|
+
"INSERT INTO {rel} (queue, name, payload, attempts, max_attempts, priority, unique_key,"
|
|
433
|
+
" available_at, last_error, created_at) "
|
|
434
|
+
"VALUES (%s, %s, %s, 0, %s, 0, NULL, %s, '', %s)"
|
|
435
|
+
).format(rel=_qualified(self.table))
|
|
436
|
+
delete = sql.SQL("DELETE FROM {rel} WHERE id = %s").format(rel=_qualified(self.failed_table))
|
|
437
|
+
|
|
438
|
+
requeued = 0
|
|
439
|
+
for failed_id, queue, name, payload in rows:
|
|
440
|
+
moment = now_utc()
|
|
441
|
+
async with self.conn.transaction():
|
|
442
|
+
async with self.conn.cursor() as cur:
|
|
443
|
+
await cur.execute(insert, (queue, name, payload, max(1, max_attempts), moment, moment))
|
|
444
|
+
await cur.execute(delete, (failed_id,))
|
|
445
|
+
requeued += 1
|
|
446
|
+
|
|
447
|
+
return requeued
|
|
448
|
+
|
|
449
|
+
async def forget_failed(self, job_id: int | None, before: str | None) -> int:
|
|
450
|
+
statement = sql.SQL("DELETE FROM {rel}").format(rel=_qualified(self.failed_table))
|
|
451
|
+
params: tuple[Any, ...] = ()
|
|
452
|
+
|
|
453
|
+
if job_id is not None:
|
|
454
|
+
statement = statement + sql.SQL(" WHERE id = %s")
|
|
455
|
+
params = (job_id,)
|
|
456
|
+
elif before is not None:
|
|
457
|
+
statement = statement + sql.SQL(" WHERE failed_at < %s")
|
|
458
|
+
params = (before,)
|
|
459
|
+
|
|
460
|
+
# With neither argument this deletes everything, which is what --all means. The CLI
|
|
461
|
+
# is what insists one of the three was given.
|
|
462
|
+
async with self.conn.cursor() as cur:
|
|
463
|
+
await cur.execute(statement, params)
|
|
464
|
+
return cur.rowcount
|