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,64 @@
|
|
|
1
|
+
from importlib import import_module
|
|
2
|
+
from typing import Any, Literal
|
|
3
|
+
|
|
4
|
+
from py_app_runner.db_pools import DbPools
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class PyBridge:
|
|
8
|
+
services_placeholder: dict[str, Any]
|
|
9
|
+
db_pools: DbPools
|
|
10
|
+
|
|
11
|
+
def __init__(self) -> None:
|
|
12
|
+
self.services_placeholder = {}
|
|
13
|
+
|
|
14
|
+
def _import_or_none(self, module_name: str) -> Any | None:
|
|
15
|
+
"""Imports `module_name`, returning None only when that module - or one of its own
|
|
16
|
+
ancestor packages - is the thing that is missing. An ImportError naming anything
|
|
17
|
+
else is a broken import *inside* the module and must surface - swallowing it would
|
|
18
|
+
let a typo in a project service silently fall through to the built-in of the same
|
|
19
|
+
name."""
|
|
20
|
+
|
|
21
|
+
try:
|
|
22
|
+
return import_module(module_name)
|
|
23
|
+
except ModuleNotFoundError as e:
|
|
24
|
+
if e.name is not None and (e.name == module_name or module_name.startswith(f"{e.name}.")):
|
|
25
|
+
return None
|
|
26
|
+
|
|
27
|
+
raise
|
|
28
|
+
|
|
29
|
+
def _resolve(self, service_name: str, module: str) -> Any | Literal[False]:
|
|
30
|
+
"""Project services win over built-ins, so a project can override `bridge` or
|
|
31
|
+
`migrations` by shipping its own `services.<name>.<module>`."""
|
|
32
|
+
|
|
33
|
+
for package in (f"services.{service_name}", f"py_app_runner.{service_name}"):
|
|
34
|
+
found = self._import_or_none(f"{package}.{module}")
|
|
35
|
+
if found is not None:
|
|
36
|
+
return found
|
|
37
|
+
|
|
38
|
+
return False
|
|
39
|
+
|
|
40
|
+
def load_service(self, service_name: str) -> Any:
|
|
41
|
+
service = self._resolve(service_name, "_service")
|
|
42
|
+
if service is not False:
|
|
43
|
+
return service
|
|
44
|
+
|
|
45
|
+
return import_module(f"services.{service_name}")
|
|
46
|
+
|
|
47
|
+
def load_service_args(self, service_name: str) -> Any | Literal[False]:
|
|
48
|
+
return self._resolve(service_name, "_service_args")
|
|
49
|
+
|
|
50
|
+
def load_service_runner(self, service_name: str) -> Any | Literal[False]:
|
|
51
|
+
return self._resolve(service_name, "_service")
|
|
52
|
+
|
|
53
|
+
def load_service_pybridge(self, service_name: str) -> Any | Literal[False]:
|
|
54
|
+
return self._resolve(service_name, "_service_pybridge")
|
|
55
|
+
|
|
56
|
+
def cache_service(self, service_name: str, service: Any) -> None:
|
|
57
|
+
self.services_placeholder[service_name] = service
|
|
58
|
+
|
|
59
|
+
def register_alias(self, name: str, handler: Any) -> None:
|
|
60
|
+
"""Expose an already-loaded handler under an additional service name."""
|
|
61
|
+
self.services_placeholder[name] = handler
|
|
62
|
+
|
|
63
|
+
def get_service(self, service_name: str) -> Any | Literal[False]:
|
|
64
|
+
return self.services_placeholder.get(service_name, False)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Jobs on PostgreSQL, or on Redis streams behind the same interface.
|
|
2
|
+
|
|
3
|
+
The database driver is the one to reach for first: a push there is an INSERT, so it joins
|
|
4
|
+
the transaction that caused it. Redis cannot do that, whatever it is set to.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from py_app_runner.queue.driver_pg import PgQueue
|
|
8
|
+
from py_app_runner.queue.driver_redis import RedisQueue
|
|
9
|
+
from py_app_runner.queue.handler import Handler, assert_resolvable, resolve
|
|
10
|
+
from py_app_runner.queue.interface import QueueDriver
|
|
11
|
+
from py_app_runner.queue.job import Job, QueueError
|
|
12
|
+
from py_app_runner.queue.worker import Worker, backoff
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"Handler",
|
|
16
|
+
"Job",
|
|
17
|
+
"PgQueue",
|
|
18
|
+
"QueueDriver",
|
|
19
|
+
"QueueError",
|
|
20
|
+
"RedisQueue",
|
|
21
|
+
"Worker",
|
|
22
|
+
"assert_resolvable",
|
|
23
|
+
"backoff",
|
|
24
|
+
"resolve",
|
|
25
|
+
]
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import logging
|
|
3
|
+
import os
|
|
4
|
+
import pathlib
|
|
5
|
+
import signal
|
|
6
|
+
import socket
|
|
7
|
+
from argparse import Namespace
|
|
8
|
+
from datetime import UTC, datetime
|
|
9
|
+
from typing import Any, cast
|
|
10
|
+
|
|
11
|
+
import psycopg
|
|
12
|
+
from database_wrapper_redis import RedisDbWithPoolAsync
|
|
13
|
+
|
|
14
|
+
from py_app_runner.migrations._service import connect_kwargs, resolve_targets
|
|
15
|
+
from py_app_runner.pybridge import PyBridge
|
|
16
|
+
from py_app_runner.queue.commands import (
|
|
17
|
+
Out,
|
|
18
|
+
cmd_failed,
|
|
19
|
+
cmd_forget,
|
|
20
|
+
cmd_install,
|
|
21
|
+
cmd_retry,
|
|
22
|
+
cmd_status,
|
|
23
|
+
parse_queues,
|
|
24
|
+
resolve_handlers,
|
|
25
|
+
)
|
|
26
|
+
from py_app_runner.queue.driver_pg import PgQueue
|
|
27
|
+
from py_app_runner.queue.driver_redis import RedisQueue
|
|
28
|
+
from py_app_runner.queue.interface import QueueDriver
|
|
29
|
+
from py_app_runner.queue.job import QueueError
|
|
30
|
+
from py_app_runner.queue.worker import Worker
|
|
31
|
+
from py_app_runner.registry import AppRegistry
|
|
32
|
+
|
|
33
|
+
_DEFAULTS: dict[str, Any] = {
|
|
34
|
+
# "database" first because a push there joins the caller's transaction, which is the
|
|
35
|
+
# one thing redis cannot offer however fast it is.
|
|
36
|
+
"driver": "database",
|
|
37
|
+
"db": "main",
|
|
38
|
+
"table": "queue_jobs",
|
|
39
|
+
"failed_table": "queue_failed_jobs",
|
|
40
|
+
# Deliberately not the cache connection: a queue that shares a database with a cache is
|
|
41
|
+
# one FLUSHDB away from an empty backlog.
|
|
42
|
+
"redis": {},
|
|
43
|
+
"queue": "default",
|
|
44
|
+
"tries": 3,
|
|
45
|
+
"backoff": [10, 60, 300],
|
|
46
|
+
"timeout": 300,
|
|
47
|
+
"sleep": 1.0,
|
|
48
|
+
"handlers": {},
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _settings(config: dict[str, Any]) -> dict[str, Any]:
|
|
53
|
+
return {**_DEFAULTS, **(config.get("queue") or {})}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _migrations_dir(args: Namespace, config: dict[str, Any]) -> pathlib.Path:
|
|
57
|
+
raw = getattr(args, "dir", None)
|
|
58
|
+
if raw:
|
|
59
|
+
directory = pathlib.Path(raw)
|
|
60
|
+
if directory.is_absolute():
|
|
61
|
+
return directory
|
|
62
|
+
|
|
63
|
+
return pathlib.Path(config.get("current_path") or ".") / directory
|
|
64
|
+
|
|
65
|
+
return next(iter(resolve_targets(config).values())).directory
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _install_shutdown_handlers(worker: Worker, logger: logging.Logger) -> None:
|
|
69
|
+
"""SIGTERM and SIGINT ask the loop to stop; nothing interrupts the job in hand.
|
|
70
|
+
|
|
71
|
+
Installed on the running loop rather than through signal.signal, so the flag is set
|
|
72
|
+
between awaits rather than inside arbitrary C code. A second signal is left to the
|
|
73
|
+
default handler: if the first one is being ignored because a job is wedged, the
|
|
74
|
+
operator needs a way out that does not require finding the pid.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
loop = asyncio.get_running_loop()
|
|
78
|
+
for name in ("SIGTERM", "SIGINT"):
|
|
79
|
+
try:
|
|
80
|
+
loop.add_signal_handler(getattr(signal, name), worker.stop)
|
|
81
|
+
except (NotImplementedError, AttributeError, RuntimeError):
|
|
82
|
+
# No signal support (Windows, or a non-main thread). The visibility timeout is
|
|
83
|
+
# still the backstop, so a killed worker's jobs come back on their own.
|
|
84
|
+
logger.debug("queue: could not install a %s handler; shutdown will not be graceful", name)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
async def init_service(args: Namespace, _pybridge: PyBridge, logger: logging.Logger) -> None:
|
|
88
|
+
out: Out = print
|
|
89
|
+
|
|
90
|
+
# Same reasoning as migrations: runner.py catches Exception around init_service and
|
|
91
|
+
# returns normally, which exits 0. A worker that could not reach the database must not
|
|
92
|
+
# look like a worker that ran cleanly.
|
|
93
|
+
code = 1
|
|
94
|
+
try:
|
|
95
|
+
config = AppRegistry.config()
|
|
96
|
+
settings = _settings(config)
|
|
97
|
+
|
|
98
|
+
driver = settings["driver"]
|
|
99
|
+
if driver not in ("database", "redis"):
|
|
100
|
+
out(f'error: config["queue"]["driver"] is {driver!r}; it must be "database" or "redis".')
|
|
101
|
+
raise SystemExit(2)
|
|
102
|
+
|
|
103
|
+
if args.step == "install":
|
|
104
|
+
if driver == "redis":
|
|
105
|
+
# Nothing to create: the keys appear on first push. Exiting 0 rather than
|
|
106
|
+
# refusing, so a deploy playbook can run `queue install` unconditionally
|
|
107
|
+
# whichever driver a project happens to be on.
|
|
108
|
+
out("Nothing to install: this queue is not on a database.")
|
|
109
|
+
raise SystemExit(0)
|
|
110
|
+
|
|
111
|
+
raise SystemExit(
|
|
112
|
+
cmd_install(
|
|
113
|
+
_migrations_dir(args, config),
|
|
114
|
+
settings["table"],
|
|
115
|
+
settings["failed_table"],
|
|
116
|
+
datetime.now(UTC),
|
|
117
|
+
out,
|
|
118
|
+
)
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
if driver == "redis":
|
|
122
|
+
# No database connection is opened at all on this path, so a worker host running
|
|
123
|
+
# the redis driver needs no database credentials.
|
|
124
|
+
#
|
|
125
|
+
# `prefix` and `group` are this module's keys and are not part of the connector's
|
|
126
|
+
# config, so they are split out rather than handed to it.
|
|
127
|
+
redis_settings = dict(settings["redis"] or {})
|
|
128
|
+
prefix = redis_settings.pop("prefix", None) or "queue:"
|
|
129
|
+
group = redis_settings.pop("group", None) or "workers"
|
|
130
|
+
|
|
131
|
+
pool = RedisDbWithPoolAsync(cast(Any, redis_settings))
|
|
132
|
+
try:
|
|
133
|
+
async with pool as redis_con:
|
|
134
|
+
queue = RedisQueue(redis_con, prefix, group)
|
|
135
|
+
code = await _dispatch(args, settings, config, queue, logger, out)
|
|
136
|
+
finally:
|
|
137
|
+
await pool.close()
|
|
138
|
+
|
|
139
|
+
raise SystemExit(code)
|
|
140
|
+
|
|
141
|
+
db_name = settings["db"]
|
|
142
|
+
db_config = config.get("db") or {}
|
|
143
|
+
if db_name not in db_config:
|
|
144
|
+
out(
|
|
145
|
+
f'error: no database {db_name!r} in config["db"]; '
|
|
146
|
+
f"configured are: {', '.join(sorted(db_config)) or 'none'}."
|
|
147
|
+
)
|
|
148
|
+
raise SystemExit(2)
|
|
149
|
+
|
|
150
|
+
async with await psycopg.AsyncConnection.connect(**connect_kwargs(db_config[db_name])) as conn:
|
|
151
|
+
code = await _dispatch(
|
|
152
|
+
args, settings, config, PgQueue(conn, settings["table"], settings["failed_table"]), logger, out
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
except QueueError as e:
|
|
156
|
+
out(f"error: {e}")
|
|
157
|
+
raise SystemExit(1) from None
|
|
158
|
+
except (KeyboardInterrupt, asyncio.CancelledError):
|
|
159
|
+
# Above `except Exception` because both derive from BaseException. A worker
|
|
160
|
+
# interrupted mid-job leaves that job reserved until its deadline passes, which is
|
|
161
|
+
# correct - but it must not be reported as a clean run.
|
|
162
|
+
logger.error("queue: interrupted")
|
|
163
|
+
raise SystemExit(1) from None
|
|
164
|
+
except Exception:
|
|
165
|
+
logger.exception("queue: unhandled failure")
|
|
166
|
+
raise SystemExit(1) from None
|
|
167
|
+
|
|
168
|
+
raise SystemExit(code)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
async def _dispatch(
|
|
172
|
+
args: Namespace,
|
|
173
|
+
settings: dict[str, Any],
|
|
174
|
+
config: dict[str, Any],
|
|
175
|
+
queue: QueueDriver,
|
|
176
|
+
logger: logging.Logger,
|
|
177
|
+
out: Out,
|
|
178
|
+
) -> int:
|
|
179
|
+
"""Every command below is driver-agnostic, which is the point of the interface: switching
|
|
180
|
+
`driver` in config changes where the jobs live and nothing else about the CLI."""
|
|
181
|
+
|
|
182
|
+
if args.step == "work":
|
|
183
|
+
return await _work(args, settings, config, queue, logger, out)
|
|
184
|
+
|
|
185
|
+
if args.step == "status":
|
|
186
|
+
return await cmd_status(queue, out)
|
|
187
|
+
|
|
188
|
+
if args.step == "failed":
|
|
189
|
+
return await cmd_failed(queue, args.limit, out)
|
|
190
|
+
|
|
191
|
+
if args.step == "retry":
|
|
192
|
+
return await cmd_retry(queue, args.id, args.all, settings["tries"], out)
|
|
193
|
+
|
|
194
|
+
if args.step == "forget":
|
|
195
|
+
return await cmd_forget(queue, args.id, args.all, args.before, out)
|
|
196
|
+
|
|
197
|
+
out(f"error: unknown queue command {args.step!r}")
|
|
198
|
+
return 1
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
async def _work(
|
|
202
|
+
args: Namespace,
|
|
203
|
+
settings: dict[str, Any],
|
|
204
|
+
config: dict[str, Any],
|
|
205
|
+
queue: QueueDriver,
|
|
206
|
+
logger: logging.Logger,
|
|
207
|
+
out: Out,
|
|
208
|
+
) -> int:
|
|
209
|
+
worker = Worker(
|
|
210
|
+
queue=queue,
|
|
211
|
+
handlers=resolve_handlers(config),
|
|
212
|
+
backoff_steps=settings["backoff"],
|
|
213
|
+
out=out,
|
|
214
|
+
worker_id=f"{socket.gethostname()}:{os.getpid()}"[:64],
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
_install_shutdown_handlers(worker, logger)
|
|
218
|
+
|
|
219
|
+
# --once is not run_next(): it is a full run with the limits set, so it still installs
|
|
220
|
+
# signal handlers and still reports what it did.
|
|
221
|
+
max_jobs = 1 if args.once else args.max_jobs
|
|
222
|
+
stop_when_empty = args.stop_when_empty or args.once
|
|
223
|
+
|
|
224
|
+
return await worker.run(
|
|
225
|
+
queues=parse_queues(args.queue, settings["queue"]),
|
|
226
|
+
timeout=args.timeout if args.timeout is not None else settings["timeout"],
|
|
227
|
+
sleep=args.sleep if args.sleep is not None else settings["sleep"],
|
|
228
|
+
max_jobs=max_jobs,
|
|
229
|
+
max_time=args.max_time,
|
|
230
|
+
stop_when_empty=stop_when_empty,
|
|
231
|
+
)
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""CLI subparsers for the built-in queue service.
|
|
2
|
+
|
|
3
|
+
python3 src/app.py queue install [--dir PATH]
|
|
4
|
+
python3 src/app.py queue work [--queue a,b] [--once] [--stop-when-empty]
|
|
5
|
+
[--max-jobs N] [--max-time N] [--timeout N] [--sleep N]
|
|
6
|
+
python3 src/app.py queue status
|
|
7
|
+
python3 src/app.py queue failed [--limit N]
|
|
8
|
+
python3 src/app.py queue retry (--id N | --all)
|
|
9
|
+
python3 src/app.py queue forget (--id N | --all | --before DATE)
|
|
10
|
+
|
|
11
|
+
There is no --driver flag by design: the driver comes from config, so a push and a worker
|
|
12
|
+
cannot disagree about where the jobs are.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import logging
|
|
16
|
+
from argparse import ArgumentParser, _SubParsersAction # type: ignore
|
|
17
|
+
|
|
18
|
+
from py_app_runner.pybridge import PyBridge
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def reg_subparsers(
|
|
22
|
+
subparsers: "_SubParsersAction[ArgumentParser]",
|
|
23
|
+
_pybridge: PyBridge,
|
|
24
|
+
_base_logger: logging.Logger,
|
|
25
|
+
) -> None:
|
|
26
|
+
"""Command line subparsers"""
|
|
27
|
+
|
|
28
|
+
parser = subparsers.add_parser(
|
|
29
|
+
"queue",
|
|
30
|
+
description="Install the queue schema, run workers, and work through failed jobs",
|
|
31
|
+
help="Job queue",
|
|
32
|
+
)
|
|
33
|
+
group = parser.add_subparsers(title="command", dest="step", required=True)
|
|
34
|
+
|
|
35
|
+
install_parser = group.add_parser("install", help="Write the queue schema into the migrations directory")
|
|
36
|
+
install_parser.add_argument("--dir", default=None, help="Migrations directory to write into")
|
|
37
|
+
|
|
38
|
+
work_parser = group.add_parser("work", help="Reserve and run jobs until told to stop")
|
|
39
|
+
work_parser.add_argument("--queue", default=None, help="Comma separated, in precedence order")
|
|
40
|
+
work_parser.add_argument("--timeout", type=int, default=None, help="Visibility and per-job timeout")
|
|
41
|
+
work_parser.add_argument("--sleep", type=float, default=None, help="Idle poll interval in seconds")
|
|
42
|
+
work_parser.add_argument("--max-jobs", type=int, default=0, help="Stop after N jobs (0 = no limit)")
|
|
43
|
+
work_parser.add_argument("--max-time", type=int, default=0, help="Stop after N seconds (0 = no limit)")
|
|
44
|
+
work_parser.add_argument(
|
|
45
|
+
"--stop-when-empty",
|
|
46
|
+
action="store_true",
|
|
47
|
+
help="Exit once the queue has nothing due, for a cron-driven worker",
|
|
48
|
+
)
|
|
49
|
+
work_parser.add_argument(
|
|
50
|
+
"--once",
|
|
51
|
+
action="store_true",
|
|
52
|
+
help="Run at most one job and exit; implies --stop-when-empty",
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
group.add_parser("status", help="Show what is queued, delayed, held and failed")
|
|
56
|
+
|
|
57
|
+
failed_parser = group.add_parser("failed", help="List failed jobs")
|
|
58
|
+
failed_parser.add_argument("--limit", type=int, default=20, help="How many to show (default: 20)")
|
|
59
|
+
|
|
60
|
+
retry_parser = group.add_parser("retry", help="Put failed jobs back on the queue")
|
|
61
|
+
retry_parser.add_argument("--id", type=int, default=None, help="A single failed job id")
|
|
62
|
+
retry_parser.add_argument("--all", action="store_true", help="Every failed job")
|
|
63
|
+
|
|
64
|
+
forget_parser = group.add_parser("forget", help="Delete failed jobs without requeueing them")
|
|
65
|
+
forget_parser.add_argument("--id", type=int, default=None, help="A single failed job id")
|
|
66
|
+
forget_parser.add_argument("--all", action="store_true", help="Every failed job")
|
|
67
|
+
forget_parser.add_argument("--before", default=None, help="Delete rows failed before this date")
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Command implementations, importable directly for programmatic use."""
|
|
2
|
+
|
|
3
|
+
import datetime
|
|
4
|
+
import pathlib
|
|
5
|
+
import re
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import psycopg
|
|
10
|
+
|
|
11
|
+
from py_app_runner.migrations.discovery import MigrationError, new_filename
|
|
12
|
+
from py_app_runner.queue.interface import QueueDriver
|
|
13
|
+
from py_app_runner.queue.job import QueueError
|
|
14
|
+
|
|
15
|
+
Out = Callable[[str], None]
|
|
16
|
+
|
|
17
|
+
_TEMPLATE = pathlib.Path(__file__).parent / "files" / "install.pgsql.sql"
|
|
18
|
+
|
|
19
|
+
_BEFORE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}( \d{2}:\d{2}:\d{2})?$")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def cmd_install(
|
|
23
|
+
migrations_dir: pathlib.Path,
|
|
24
|
+
table: str,
|
|
25
|
+
failed_table: str,
|
|
26
|
+
now: datetime.datetime,
|
|
27
|
+
out: Out,
|
|
28
|
+
) -> int:
|
|
29
|
+
"""Write the queue schema into the project's own migrations directory."""
|
|
30
|
+
|
|
31
|
+
if not migrations_dir.is_dir():
|
|
32
|
+
out(f"error: no migrations directory at {migrations_dir}")
|
|
33
|
+
return 2
|
|
34
|
+
|
|
35
|
+
try:
|
|
36
|
+
schema = _TEMPLATE.read_text(encoding="utf-8")
|
|
37
|
+
except OSError as e:
|
|
38
|
+
out(f"error: could not read {_TEMPLATE}: {e}")
|
|
39
|
+
return 1
|
|
40
|
+
|
|
41
|
+
# The failed table is substituted first. "queue_jobs" is a prefix of nothing, but
|
|
42
|
+
# "queue_failed_jobs" contains "queue_jobs" nowhere while its *index* names derive from
|
|
43
|
+
# it - do the longer name first and neither can be half-rewritten by the other.
|
|
44
|
+
if failed_table != "queue_failed_jobs":
|
|
45
|
+
schema = schema.replace("queue_failed_jobs", failed_table)
|
|
46
|
+
if table != "queue_jobs":
|
|
47
|
+
schema = schema.replace("queue_jobs", table)
|
|
48
|
+
|
|
49
|
+
try:
|
|
50
|
+
target = migrations_dir / new_filename("create queue tables", now)
|
|
51
|
+
except MigrationError as e:
|
|
52
|
+
out(f"error: {e}")
|
|
53
|
+
return 2
|
|
54
|
+
|
|
55
|
+
if target.exists():
|
|
56
|
+
out(f"error: {target} already exists")
|
|
57
|
+
return 1
|
|
58
|
+
|
|
59
|
+
try:
|
|
60
|
+
target.write_text(schema, encoding="utf-8")
|
|
61
|
+
except OSError as e:
|
|
62
|
+
out(f"error: could not write {target}: {e}")
|
|
63
|
+
return 1
|
|
64
|
+
|
|
65
|
+
out(f"Wrote {target}")
|
|
66
|
+
out("Review it, then: python3 src/app.py migrations apply")
|
|
67
|
+
|
|
68
|
+
return 0
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
async def cmd_status(queue: QueueDriver, out: Out) -> int:
|
|
72
|
+
try:
|
|
73
|
+
rows = await queue.stats()
|
|
74
|
+
failed = await queue.failed_count()
|
|
75
|
+
except (psycopg.Error, QueueError) as e:
|
|
76
|
+
out(f"error: cannot read the queue: {e}")
|
|
77
|
+
return 1
|
|
78
|
+
|
|
79
|
+
if not rows:
|
|
80
|
+
out("No jobs queued.")
|
|
81
|
+
else:
|
|
82
|
+
out(f"{'queue':<24} {'pending':>9} {'delayed':>9} {'held':>9} {'total':>9}")
|
|
83
|
+
for row in rows:
|
|
84
|
+
out(f"{row['queue']:<24} {row['pending']:>9} {row['delayed']:>9} {row['reserved']:>9} {row['total']:>9}")
|
|
85
|
+
|
|
86
|
+
out("")
|
|
87
|
+
out(f"failed: {failed}")
|
|
88
|
+
if failed > 0:
|
|
89
|
+
out("Read them with: python3 src/app.py queue failed")
|
|
90
|
+
|
|
91
|
+
return 0
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
async def cmd_failed(queue: QueueDriver, limit: int, out: Out) -> int:
|
|
95
|
+
if limit < 1:
|
|
96
|
+
out("error: --limit must be at least 1")
|
|
97
|
+
return 2
|
|
98
|
+
|
|
99
|
+
try:
|
|
100
|
+
rows = await queue.failed_rows(limit)
|
|
101
|
+
except (psycopg.Error, QueueError) as e:
|
|
102
|
+
out(f"error: cannot read the queue: {e}")
|
|
103
|
+
return 1
|
|
104
|
+
|
|
105
|
+
if not rows:
|
|
106
|
+
out("Nothing has failed.")
|
|
107
|
+
return 0
|
|
108
|
+
|
|
109
|
+
for row in rows:
|
|
110
|
+
out(
|
|
111
|
+
f"#{str(row['id']):<6} {str(row['failed_at']):<26} {row['queue']:<16} "
|
|
112
|
+
f"{row['name']} ({row['attempts']} attempts)"
|
|
113
|
+
)
|
|
114
|
+
# Only the first line: a traceback in a list view buries the next entry.
|
|
115
|
+
first_line = (row["error"] or "").split("\n", 1)[0]
|
|
116
|
+
if first_line:
|
|
117
|
+
out(f" {first_line}")
|
|
118
|
+
|
|
119
|
+
out("")
|
|
120
|
+
out("Requeue one with: python3 src/app.py queue retry --id N")
|
|
121
|
+
|
|
122
|
+
return 0
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
async def cmd_retry(queue: QueueDriver, job_id: int | None, all_jobs: bool, max_attempts: int, out: Out) -> int:
|
|
126
|
+
if job_id is None and not all_jobs:
|
|
127
|
+
out("error: retry needs --id N or --all")
|
|
128
|
+
return 2
|
|
129
|
+
|
|
130
|
+
try:
|
|
131
|
+
count = await queue.retry_failed(job_id, max_attempts)
|
|
132
|
+
except (psycopg.Error, QueueError) as e:
|
|
133
|
+
out(f"error: could not requeue: {e}")
|
|
134
|
+
return 1
|
|
135
|
+
|
|
136
|
+
if count == 0:
|
|
137
|
+
out("Nothing to requeue.")
|
|
138
|
+
# Naming a specific id that is not there is a failed instruction; --all finding
|
|
139
|
+
# nothing is a legitimately empty table.
|
|
140
|
+
return 0 if job_id is None else 1
|
|
141
|
+
|
|
142
|
+
out(f"Requeued {count} job(s).")
|
|
143
|
+
return 0
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
async def cmd_forget(
|
|
147
|
+
queue: QueueDriver,
|
|
148
|
+
job_id: int | None,
|
|
149
|
+
all_jobs: bool,
|
|
150
|
+
before: str | None,
|
|
151
|
+
out: Out,
|
|
152
|
+
) -> int:
|
|
153
|
+
if job_id is None and not all_jobs and before is None:
|
|
154
|
+
out("error: forget needs --id N, --all or --before DATE")
|
|
155
|
+
return 2
|
|
156
|
+
|
|
157
|
+
if before is not None and _BEFORE_RE.match(before) is None:
|
|
158
|
+
out(f"error: --before={before!r} must be YYYY-MM-DD or 'YYYY-MM-DD HH:MM:SS'")
|
|
159
|
+
return 2
|
|
160
|
+
|
|
161
|
+
try:
|
|
162
|
+
count = await queue.forget_failed(job_id, before)
|
|
163
|
+
except (psycopg.Error, QueueError) as e:
|
|
164
|
+
out(f"error: could not forget: {e}")
|
|
165
|
+
return 1
|
|
166
|
+
|
|
167
|
+
out(f"Deleted {count} row(s).")
|
|
168
|
+
return 0
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def parse_queues(raw: str | None, default: str = "default") -> list[str]:
|
|
172
|
+
"""Comma separated, in precedence order. Blanks dropped so a trailing comma is not a
|
|
173
|
+
queue named "" that never has work."""
|
|
174
|
+
|
|
175
|
+
names = [part.strip() for part in (raw or default).split(",")]
|
|
176
|
+
return [name for name in names if name] or [default]
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def resolve_handlers(config: dict[str, Any]) -> dict[str, Any]:
|
|
180
|
+
return (config.get("queue") or {}).get("handlers") or {}
|