localqueue 0.1.0__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.
- localqueue/__init__.py +67 -0
- localqueue/cli.py +744 -0
- localqueue/queue.py +261 -0
- localqueue/retry/__init__.py +39 -0
- localqueue/retry/store.py +182 -0
- localqueue/retry/tenacity.py +685 -0
- localqueue/store.py +1197 -0
- localqueue/worker.py +192 -0
- localqueue-0.1.0.dist-info/METADATA +356 -0
- localqueue-0.1.0.dist-info/RECORD +13 -0
- localqueue-0.1.0.dist-info/WHEEL +4 -0
- localqueue-0.1.0.dist-info/entry_points.txt +2 -0
- localqueue-0.1.0.dist-info/licenses/LICENSE +21 -0
localqueue/cli.py
ADDED
|
@@ -0,0 +1,744 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import importlib
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import signal
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
import time
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
from contextlib import contextmanager
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from queue import Empty
|
|
15
|
+
from types import FrameType
|
|
16
|
+
from typing import Any, Iterator
|
|
17
|
+
|
|
18
|
+
from tenacity import wait_none
|
|
19
|
+
|
|
20
|
+
from .queue import PersistentQueue
|
|
21
|
+
from .retry import (
|
|
22
|
+
PersistentRetryExhausted,
|
|
23
|
+
PersistentRetrying,
|
|
24
|
+
SQLiteAttemptStore,
|
|
25
|
+
)
|
|
26
|
+
from .store import QueueMessage
|
|
27
|
+
|
|
28
|
+
DEFAULT_STORE_PATH = "localqueue_queue.sqlite3"
|
|
29
|
+
DEFAULT_RETRY_STORE_PATH = "localqueue_retries.sqlite3"
|
|
30
|
+
CONFIG_FILENAME = "config.yaml"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True, slots=True)
|
|
34
|
+
class _ProcessResult:
|
|
35
|
+
processed: bool
|
|
36
|
+
last_error: dict[str, Any] | None = None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(slots=True)
|
|
40
|
+
class _ShutdownState:
|
|
41
|
+
requested: bool = False
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class _CommandExecutionError(RuntimeError):
|
|
45
|
+
command: list[str]
|
|
46
|
+
exit_code: int
|
|
47
|
+
stdout: str
|
|
48
|
+
stderr: str
|
|
49
|
+
|
|
50
|
+
def __init__(
|
|
51
|
+
self,
|
|
52
|
+
command: list[str],
|
|
53
|
+
*,
|
|
54
|
+
exit_code: int,
|
|
55
|
+
stdout: str,
|
|
56
|
+
stderr: str,
|
|
57
|
+
) -> None:
|
|
58
|
+
self.command = command
|
|
59
|
+
self.exit_code = exit_code
|
|
60
|
+
self.stdout = _truncate_output(stdout.strip())
|
|
61
|
+
self.stderr = _truncate_output(stderr.strip())
|
|
62
|
+
stderr_message = _truncate_output(stderr.strip())
|
|
63
|
+
detail = f": {stderr_message}" if stderr_message else ""
|
|
64
|
+
super().__init__(
|
|
65
|
+
f"command exited with status {exit_code}: {_format_command(command)}"
|
|
66
|
+
+ detail
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def main(args: list[str] | None = None) -> None:
|
|
71
|
+
try:
|
|
72
|
+
from rich.console import Console
|
|
73
|
+
import typer
|
|
74
|
+
import yaml
|
|
75
|
+
except ImportError as exc:
|
|
76
|
+
raise SystemExit(
|
|
77
|
+
"The localqueue CLI requires optional dependencies. "
|
|
78
|
+
"Install with: pip install 'localqueue[cli]'"
|
|
79
|
+
) from exc
|
|
80
|
+
|
|
81
|
+
app = _build_app(typer, yaml, Console(), Console(stderr=True))
|
|
82
|
+
app(args=args)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _build_app(typer: Any, yaml: Any, console: Any, err_console: Any) -> Any:
|
|
86
|
+
config = _load_config(yaml)
|
|
87
|
+
app = typer.Typer(no_args_is_help=True)
|
|
88
|
+
queue_app = typer.Typer(no_args_is_help=True)
|
|
89
|
+
config_app = typer.Typer(no_args_is_help=True)
|
|
90
|
+
app.add_typer(queue_app, name="queue")
|
|
91
|
+
app.add_typer(config_app, name="config")
|
|
92
|
+
|
|
93
|
+
@config_app.command("path")
|
|
94
|
+
def config_path() -> None:
|
|
95
|
+
console.print(str(_config_path()))
|
|
96
|
+
|
|
97
|
+
@config_app.command("show")
|
|
98
|
+
def config_show() -> None:
|
|
99
|
+
_print_json(console, config)
|
|
100
|
+
|
|
101
|
+
@config_app.command("init")
|
|
102
|
+
def config_init(
|
|
103
|
+
store_path: str = typer.Option(DEFAULT_STORE_PATH, "--store-path"),
|
|
104
|
+
retry_store_path: str | None = typer.Option(None, "--retry-store-path"),
|
|
105
|
+
force: bool = typer.Option(False, "--force"),
|
|
106
|
+
) -> None:
|
|
107
|
+
path = _config_path()
|
|
108
|
+
if path.exists() and not force:
|
|
109
|
+
err_console.print(
|
|
110
|
+
f"[red]config already exists:[/red] {path}; pass --force to overwrite"
|
|
111
|
+
)
|
|
112
|
+
raise typer.Exit(1)
|
|
113
|
+
|
|
114
|
+
new_config: dict[str, Any] = {"store_path": store_path}
|
|
115
|
+
if retry_store_path is not None:
|
|
116
|
+
new_config["retry_store_path"] = retry_store_path
|
|
117
|
+
_write_config(yaml, new_config)
|
|
118
|
+
config.clear()
|
|
119
|
+
config.update(new_config)
|
|
120
|
+
console.print(f"[green]wrote config:[/green] {path}")
|
|
121
|
+
|
|
122
|
+
@config_app.command("set")
|
|
123
|
+
def config_set(
|
|
124
|
+
key: str,
|
|
125
|
+
value: str,
|
|
126
|
+
) -> None:
|
|
127
|
+
if key not in {"store_path", "retry_store_path"}:
|
|
128
|
+
err_console.print(
|
|
129
|
+
"[red]unsupported config key:[/red] "
|
|
130
|
+
+ f"{key}; use store_path or retry_store_path"
|
|
131
|
+
)
|
|
132
|
+
raise typer.Exit(1)
|
|
133
|
+
|
|
134
|
+
updated = dict(config)
|
|
135
|
+
updated[key] = value
|
|
136
|
+
_write_config(yaml, updated)
|
|
137
|
+
config.clear()
|
|
138
|
+
config.update(updated)
|
|
139
|
+
_print_json(console, config)
|
|
140
|
+
|
|
141
|
+
@queue_app.command("add")
|
|
142
|
+
def queue_add(
|
|
143
|
+
queue: str,
|
|
144
|
+
value: str | None = typer.Option(None, "--value"),
|
|
145
|
+
store_path: str | None = typer.Option(None, "--store-path"),
|
|
146
|
+
delay: float = typer.Option(0.0, "--delay", min=0.0),
|
|
147
|
+
raw: bool = typer.Option(False, "--raw"),
|
|
148
|
+
) -> None:
|
|
149
|
+
try:
|
|
150
|
+
payload_value = _read_value(value)
|
|
151
|
+
payload = payload_value if raw else _parse_json(payload_value)
|
|
152
|
+
except ValueError as exc:
|
|
153
|
+
raise typer.BadParameter(str(exc)) from exc
|
|
154
|
+
message = _queue(queue, _resolve_store_path(store_path, config)).put(
|
|
155
|
+
payload, delay=delay
|
|
156
|
+
)
|
|
157
|
+
_print_json(console, _message_payload(message))
|
|
158
|
+
|
|
159
|
+
@queue_app.command("pop")
|
|
160
|
+
def queue_pop(
|
|
161
|
+
queue: str,
|
|
162
|
+
store_path: str | None = typer.Option(None, "--store-path"),
|
|
163
|
+
lease_timeout: float = typer.Option(30.0, "--lease-timeout", min=0.001),
|
|
164
|
+
worker_id: str | None = typer.Option(None, "--worker-id"),
|
|
165
|
+
block: bool = typer.Option(False, "--block"),
|
|
166
|
+
timeout: float | None = typer.Option(None, "--timeout", min=0.0),
|
|
167
|
+
) -> None:
|
|
168
|
+
try:
|
|
169
|
+
message = _queue(
|
|
170
|
+
queue,
|
|
171
|
+
_resolve_store_path(store_path, config),
|
|
172
|
+
lease_timeout=lease_timeout,
|
|
173
|
+
).get_message(block=block, timeout=timeout, leased_by=worker_id)
|
|
174
|
+
except Empty as exc:
|
|
175
|
+
err_console.print("[yellow]queue is empty[/yellow]")
|
|
176
|
+
raise typer.Exit(1) from exc
|
|
177
|
+
_print_json(console, _message_payload(message))
|
|
178
|
+
|
|
179
|
+
@queue_app.command("inspect")
|
|
180
|
+
def queue_inspect(
|
|
181
|
+
queue: str,
|
|
182
|
+
message_id: str,
|
|
183
|
+
store_path: str | None = typer.Option(None, "--store-path"),
|
|
184
|
+
) -> None:
|
|
185
|
+
message = _queue(queue, _resolve_store_path(store_path, config)).inspect(
|
|
186
|
+
message_id
|
|
187
|
+
)
|
|
188
|
+
if message is None:
|
|
189
|
+
err_console.print(f"[red]message not found:[/red] {message_id}")
|
|
190
|
+
raise typer.Exit(1)
|
|
191
|
+
_print_json(console, _message_payload(message))
|
|
192
|
+
|
|
193
|
+
@queue_app.command("ack")
|
|
194
|
+
def queue_ack(
|
|
195
|
+
queue: str,
|
|
196
|
+
message_id: str,
|
|
197
|
+
store_path: str | None = typer.Option(None, "--store-path"),
|
|
198
|
+
) -> None:
|
|
199
|
+
_complete_message(
|
|
200
|
+
typer,
|
|
201
|
+
console,
|
|
202
|
+
err_console,
|
|
203
|
+
queue,
|
|
204
|
+
_resolve_store_path(store_path, config),
|
|
205
|
+
message_id,
|
|
206
|
+
"ack",
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
@queue_app.command("release")
|
|
210
|
+
def queue_release(
|
|
211
|
+
queue: str,
|
|
212
|
+
message_id: str,
|
|
213
|
+
store_path: str | None = typer.Option(None, "--store-path"),
|
|
214
|
+
delay: float = typer.Option(0.0, "--delay", min=0.0),
|
|
215
|
+
) -> None:
|
|
216
|
+
_complete_message(
|
|
217
|
+
typer,
|
|
218
|
+
console,
|
|
219
|
+
err_console,
|
|
220
|
+
queue,
|
|
221
|
+
_resolve_store_path(store_path, config),
|
|
222
|
+
message_id,
|
|
223
|
+
"release",
|
|
224
|
+
delay=delay,
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
@queue_app.command("dead-letter")
|
|
228
|
+
def queue_dead_letter(
|
|
229
|
+
queue: str,
|
|
230
|
+
message_id: str,
|
|
231
|
+
store_path: str | None = typer.Option(None, "--store-path"),
|
|
232
|
+
) -> None:
|
|
233
|
+
_complete_message(
|
|
234
|
+
typer,
|
|
235
|
+
console,
|
|
236
|
+
err_console,
|
|
237
|
+
queue,
|
|
238
|
+
_resolve_store_path(store_path, config),
|
|
239
|
+
message_id,
|
|
240
|
+
"dead-letter",
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
@queue_app.command("size")
|
|
244
|
+
def queue_size(
|
|
245
|
+
queue: str,
|
|
246
|
+
store_path: str | None = typer.Option(None, "--store-path"),
|
|
247
|
+
) -> None:
|
|
248
|
+
console.print(_queue(queue, _resolve_store_path(store_path, config)).qsize())
|
|
249
|
+
|
|
250
|
+
@queue_app.command("stats")
|
|
251
|
+
def queue_stats(
|
|
252
|
+
queue: str,
|
|
253
|
+
store_path: str | None = typer.Option(None, "--store-path"),
|
|
254
|
+
) -> None:
|
|
255
|
+
stats = _queue(queue, _resolve_store_path(store_path, config)).stats()
|
|
256
|
+
_print_json(console, stats.as_dict())
|
|
257
|
+
|
|
258
|
+
@queue_app.command("dead")
|
|
259
|
+
def queue_dead(
|
|
260
|
+
queue: str,
|
|
261
|
+
store_path: str | None = typer.Option(None, "--store-path"),
|
|
262
|
+
limit: int | None = typer.Option(None, "--limit", min=0),
|
|
263
|
+
) -> None:
|
|
264
|
+
messages = _queue(queue, _resolve_store_path(store_path, config)).dead_letters(
|
|
265
|
+
limit=limit
|
|
266
|
+
)
|
|
267
|
+
_print_json(console, [_message_payload(message) for message in messages])
|
|
268
|
+
|
|
269
|
+
@queue_app.command("requeue-dead")
|
|
270
|
+
def queue_requeue_dead(
|
|
271
|
+
queue: str,
|
|
272
|
+
message_id: str,
|
|
273
|
+
store_path: str | None = typer.Option(None, "--store-path"),
|
|
274
|
+
delay: float = typer.Option(0.0, "--delay", min=0.0),
|
|
275
|
+
) -> None:
|
|
276
|
+
persistent_queue = _queue(queue, _resolve_store_path(store_path, config))
|
|
277
|
+
message = QueueMessage(id=message_id, value=None, queue=queue)
|
|
278
|
+
if not persistent_queue.requeue_dead(message, delay=delay):
|
|
279
|
+
err_console.print(f"[red]dead-letter message not found:[/red] {message_id}")
|
|
280
|
+
raise typer.Exit(1)
|
|
281
|
+
_print_json(console, {"id": message_id, "state": "requeued"})
|
|
282
|
+
|
|
283
|
+
@queue_app.command("purge")
|
|
284
|
+
def queue_purge(
|
|
285
|
+
queue: str,
|
|
286
|
+
store_path: str | None = typer.Option(None, "--store-path"),
|
|
287
|
+
) -> None:
|
|
288
|
+
console.print(_queue(queue, _resolve_store_path(store_path, config)).purge())
|
|
289
|
+
|
|
290
|
+
@queue_app.command("process")
|
|
291
|
+
def queue_process(
|
|
292
|
+
queue: str,
|
|
293
|
+
handler: str,
|
|
294
|
+
store_path: str | None = typer.Option(None, "--store-path"),
|
|
295
|
+
retry_store_path: str | None = typer.Option(None, "--retry-store-path"),
|
|
296
|
+
max_jobs: int = typer.Option(1, "--max-jobs", min=1),
|
|
297
|
+
forever: bool = typer.Option(False, "--forever"),
|
|
298
|
+
max_tries: int = typer.Option(3, "--max-tries", min=1),
|
|
299
|
+
lease_timeout: float = typer.Option(30.0, "--lease-timeout", min=0.001),
|
|
300
|
+
worker_id: str | None = typer.Option(None, "--worker-id"),
|
|
301
|
+
block: bool = typer.Option(False, "--block"),
|
|
302
|
+
timeout: float | None = typer.Option(None, "--timeout", min=0.0),
|
|
303
|
+
idle_sleep: float = typer.Option(1.0, "--idle-sleep", min=0.001),
|
|
304
|
+
release_delay: float = typer.Option(0.0, "--release-delay", min=0.0),
|
|
305
|
+
dead_letter_on_exhaustion: bool = typer.Option(
|
|
306
|
+
True,
|
|
307
|
+
"--dead-letter-on-exhaustion/--release-on-exhaustion",
|
|
308
|
+
),
|
|
309
|
+
) -> None:
|
|
310
|
+
try:
|
|
311
|
+
worker = _load_callable(handler)
|
|
312
|
+
except (AttributeError, ImportError, TypeError, ValueError) as exc:
|
|
313
|
+
err_console.print(f"[red]{exc}[/red]")
|
|
314
|
+
raise typer.Exit(1) from exc
|
|
315
|
+
persistent_queue = _queue(
|
|
316
|
+
queue,
|
|
317
|
+
_resolve_store_path(store_path, config),
|
|
318
|
+
lease_timeout=lease_timeout,
|
|
319
|
+
)
|
|
320
|
+
with _shutdown_state() as shutdown:
|
|
321
|
+
exit_code = _process_queue_messages(
|
|
322
|
+
persistent_queue,
|
|
323
|
+
worker,
|
|
324
|
+
console=console,
|
|
325
|
+
err_console=err_console,
|
|
326
|
+
shutdown=shutdown,
|
|
327
|
+
retry_store_path=_resolve_retry_store_path(retry_store_path, config),
|
|
328
|
+
max_jobs=max_jobs,
|
|
329
|
+
forever=forever,
|
|
330
|
+
max_tries=max_tries,
|
|
331
|
+
worker_id=worker_id,
|
|
332
|
+
block=block,
|
|
333
|
+
timeout=timeout,
|
|
334
|
+
idle_sleep=idle_sleep,
|
|
335
|
+
release_delay=release_delay,
|
|
336
|
+
dead_letter_on_exhaustion=dead_letter_on_exhaustion,
|
|
337
|
+
)
|
|
338
|
+
if exit_code:
|
|
339
|
+
raise typer.Exit(exit_code)
|
|
340
|
+
|
|
341
|
+
@queue_app.command("exec")
|
|
342
|
+
def queue_exec(
|
|
343
|
+
queue: str,
|
|
344
|
+
command: list[str] = typer.Argument(
|
|
345
|
+
...,
|
|
346
|
+
help="Command to run. Use '--' before commands that contain options.",
|
|
347
|
+
),
|
|
348
|
+
store_path: str | None = typer.Option(None, "--store-path"),
|
|
349
|
+
retry_store_path: str | None = typer.Option(None, "--retry-store-path"),
|
|
350
|
+
max_jobs: int = typer.Option(1, "--max-jobs", min=1),
|
|
351
|
+
forever: bool = typer.Option(False, "--forever"),
|
|
352
|
+
max_tries: int = typer.Option(3, "--max-tries", min=1),
|
|
353
|
+
lease_timeout: float = typer.Option(30.0, "--lease-timeout", min=0.001),
|
|
354
|
+
worker_id: str | None = typer.Option(None, "--worker-id"),
|
|
355
|
+
block: bool = typer.Option(False, "--block"),
|
|
356
|
+
timeout: float | None = typer.Option(None, "--timeout", min=0.0),
|
|
357
|
+
idle_sleep: float = typer.Option(1.0, "--idle-sleep", min=0.001),
|
|
358
|
+
release_delay: float = typer.Option(0.0, "--release-delay", min=0.0),
|
|
359
|
+
dead_letter_on_exhaustion: bool = typer.Option(
|
|
360
|
+
True,
|
|
361
|
+
"--dead-letter-on-exhaustion/--release-on-exhaustion",
|
|
362
|
+
),
|
|
363
|
+
) -> None:
|
|
364
|
+
persistent_queue = _queue(
|
|
365
|
+
queue,
|
|
366
|
+
_resolve_store_path(store_path, config),
|
|
367
|
+
lease_timeout=lease_timeout,
|
|
368
|
+
)
|
|
369
|
+
with _shutdown_state() as shutdown:
|
|
370
|
+
exit_code = _process_queue_messages(
|
|
371
|
+
persistent_queue,
|
|
372
|
+
_command_handler(command),
|
|
373
|
+
console=console,
|
|
374
|
+
err_console=err_console,
|
|
375
|
+
shutdown=shutdown,
|
|
376
|
+
retry_store_path=_resolve_retry_store_path(retry_store_path, config),
|
|
377
|
+
max_jobs=max_jobs,
|
|
378
|
+
forever=forever,
|
|
379
|
+
max_tries=max_tries,
|
|
380
|
+
worker_id=worker_id,
|
|
381
|
+
block=block,
|
|
382
|
+
timeout=timeout,
|
|
383
|
+
idle_sleep=idle_sleep,
|
|
384
|
+
release_delay=release_delay,
|
|
385
|
+
dead_letter_on_exhaustion=dead_letter_on_exhaustion,
|
|
386
|
+
)
|
|
387
|
+
if exit_code:
|
|
388
|
+
raise typer.Exit(exit_code)
|
|
389
|
+
|
|
390
|
+
return app
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def _queue(
|
|
394
|
+
name: str, store_path: str, *, lease_timeout: float = 30.0
|
|
395
|
+
) -> PersistentQueue:
|
|
396
|
+
return PersistentQueue(name, store_path=store_path, lease_timeout=lease_timeout)
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def _complete_message(
|
|
400
|
+
typer: Any,
|
|
401
|
+
console: Any,
|
|
402
|
+
err_console: Any,
|
|
403
|
+
queue: str,
|
|
404
|
+
store_path: str,
|
|
405
|
+
message_id: str,
|
|
406
|
+
action: str,
|
|
407
|
+
*,
|
|
408
|
+
delay: float = 0.0,
|
|
409
|
+
) -> None:
|
|
410
|
+
persistent_queue = _queue(queue, store_path)
|
|
411
|
+
message = QueueMessage(id=message_id, value=None, queue=queue)
|
|
412
|
+
if action == "ack":
|
|
413
|
+
completed = persistent_queue.ack(message)
|
|
414
|
+
elif action == "release":
|
|
415
|
+
completed = persistent_queue.release(message, delay=delay)
|
|
416
|
+
elif action == "dead-letter":
|
|
417
|
+
completed = persistent_queue.dead_letter(message)
|
|
418
|
+
else:
|
|
419
|
+
raise ValueError(f"unsupported queue action: {action}")
|
|
420
|
+
|
|
421
|
+
if not completed:
|
|
422
|
+
err_console.print(f"[red]message not found:[/red] {message_id}")
|
|
423
|
+
raise typer.Exit(1)
|
|
424
|
+
_print_json(console, {"id": message_id, "state": action})
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _process_message(
|
|
428
|
+
queue: PersistentQueue,
|
|
429
|
+
message: QueueMessage,
|
|
430
|
+
handler: Callable[[Any], Any],
|
|
431
|
+
*,
|
|
432
|
+
retry_store: Any | None = None,
|
|
433
|
+
retry_store_path: str | None,
|
|
434
|
+
max_tries: int,
|
|
435
|
+
release_delay: float,
|
|
436
|
+
dead_letter_on_exhaustion: bool,
|
|
437
|
+
) -> _ProcessResult:
|
|
438
|
+
retry_kwargs: dict[str, Any] = {
|
|
439
|
+
"key": message.id,
|
|
440
|
+
"max_tries": max_tries,
|
|
441
|
+
"wait": wait_none(),
|
|
442
|
+
}
|
|
443
|
+
owned_retry_store: SQLiteAttemptStore | None = None
|
|
444
|
+
if retry_store is not None:
|
|
445
|
+
retry_kwargs["store"] = retry_store
|
|
446
|
+
elif retry_store_path is not None:
|
|
447
|
+
owned_retry_store = SQLiteAttemptStore(retry_store_path)
|
|
448
|
+
retry_kwargs["store"] = owned_retry_store
|
|
449
|
+
|
|
450
|
+
retryer = PersistentRetrying(**retry_kwargs)
|
|
451
|
+
try:
|
|
452
|
+
try:
|
|
453
|
+
_ = retryer(handler, message.value)
|
|
454
|
+
except PersistentRetryExhausted as exc:
|
|
455
|
+
last_error = _error_payload(exc)
|
|
456
|
+
_ = _finish_failed_message(
|
|
457
|
+
queue,
|
|
458
|
+
message,
|
|
459
|
+
release_delay=release_delay,
|
|
460
|
+
dead_letter_on_exhaustion=dead_letter_on_exhaustion,
|
|
461
|
+
error=exc,
|
|
462
|
+
)
|
|
463
|
+
return _ProcessResult(processed=False, last_error=last_error)
|
|
464
|
+
except Exception as exc:
|
|
465
|
+
last_error = _error_payload(exc)
|
|
466
|
+
record = retryer.get_record(message.id)
|
|
467
|
+
exhausted = record is not None and record.exhausted
|
|
468
|
+
if exhausted:
|
|
469
|
+
_ = _finish_failed_message(
|
|
470
|
+
queue,
|
|
471
|
+
message,
|
|
472
|
+
release_delay=release_delay,
|
|
473
|
+
dead_letter_on_exhaustion=dead_letter_on_exhaustion,
|
|
474
|
+
error=exc,
|
|
475
|
+
)
|
|
476
|
+
else:
|
|
477
|
+
_ = queue.release(message, delay=release_delay, error=exc)
|
|
478
|
+
return _ProcessResult(processed=False, last_error=last_error)
|
|
479
|
+
|
|
480
|
+
return _ProcessResult(processed=queue.ack(message))
|
|
481
|
+
finally:
|
|
482
|
+
if owned_retry_store is not None:
|
|
483
|
+
owned_retry_store.close()
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def _process_queue_messages(
|
|
487
|
+
queue: PersistentQueue,
|
|
488
|
+
handler: Callable[[Any], Any],
|
|
489
|
+
*,
|
|
490
|
+
console: Any,
|
|
491
|
+
err_console: Any,
|
|
492
|
+
shutdown: _ShutdownState,
|
|
493
|
+
retry_store_path: str | None,
|
|
494
|
+
max_jobs: int,
|
|
495
|
+
forever: bool,
|
|
496
|
+
max_tries: int,
|
|
497
|
+
worker_id: str | None,
|
|
498
|
+
block: bool,
|
|
499
|
+
timeout: float | None,
|
|
500
|
+
idle_sleep: float,
|
|
501
|
+
release_delay: float,
|
|
502
|
+
dead_letter_on_exhaustion: bool,
|
|
503
|
+
) -> int:
|
|
504
|
+
processed = 0
|
|
505
|
+
|
|
506
|
+
while forever or processed < max_jobs:
|
|
507
|
+
if shutdown.requested:
|
|
508
|
+
if forever:
|
|
509
|
+
_print_json(console, {"state": "stopped", "processed": processed})
|
|
510
|
+
return 0
|
|
511
|
+
|
|
512
|
+
try:
|
|
513
|
+
message = queue.get_message(
|
|
514
|
+
block=block,
|
|
515
|
+
timeout=_poll_timeout(forever, block, timeout),
|
|
516
|
+
leased_by=worker_id,
|
|
517
|
+
)
|
|
518
|
+
except Empty:
|
|
519
|
+
if forever:
|
|
520
|
+
if not block:
|
|
521
|
+
time.sleep(idle_sleep)
|
|
522
|
+
continue
|
|
523
|
+
if processed == 0:
|
|
524
|
+
err_console.print("[yellow]queue is empty[/yellow]")
|
|
525
|
+
return 1
|
|
526
|
+
break
|
|
527
|
+
|
|
528
|
+
result = _process_message(
|
|
529
|
+
queue,
|
|
530
|
+
message,
|
|
531
|
+
handler,
|
|
532
|
+
retry_store_path=retry_store_path,
|
|
533
|
+
max_tries=max_tries,
|
|
534
|
+
release_delay=release_delay,
|
|
535
|
+
dead_letter_on_exhaustion=dead_letter_on_exhaustion,
|
|
536
|
+
)
|
|
537
|
+
if result.processed:
|
|
538
|
+
processed += 1
|
|
539
|
+
_print_json(console, {"id": message.id, "state": "acked"})
|
|
540
|
+
continue
|
|
541
|
+
|
|
542
|
+
_print_json(
|
|
543
|
+
console,
|
|
544
|
+
{"id": message.id, "state": "failed", "last_error": result.last_error},
|
|
545
|
+
)
|
|
546
|
+
if forever:
|
|
547
|
+
continue
|
|
548
|
+
return 1
|
|
549
|
+
|
|
550
|
+
return 0
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
def _poll_timeout(forever: bool, block: bool, timeout: float | None) -> float | None:
|
|
554
|
+
if forever and block and timeout is None:
|
|
555
|
+
return 0.5
|
|
556
|
+
return timeout
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
@contextmanager
|
|
560
|
+
def _shutdown_state() -> Iterator[_ShutdownState]:
|
|
561
|
+
state = _ShutdownState()
|
|
562
|
+
|
|
563
|
+
def request_shutdown(_signum: int, _frame: FrameType | None) -> None:
|
|
564
|
+
state.requested = True
|
|
565
|
+
|
|
566
|
+
previous_sigint = signal.getsignal(signal.SIGINT)
|
|
567
|
+
previous_sigterm = signal.getsignal(signal.SIGTERM)
|
|
568
|
+
signal.signal(signal.SIGINT, request_shutdown)
|
|
569
|
+
signal.signal(signal.SIGTERM, request_shutdown)
|
|
570
|
+
try:
|
|
571
|
+
yield state
|
|
572
|
+
finally:
|
|
573
|
+
signal.signal(signal.SIGINT, previous_sigint)
|
|
574
|
+
signal.signal(signal.SIGTERM, previous_sigterm)
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
def _finish_failed_message(
|
|
578
|
+
queue: PersistentQueue,
|
|
579
|
+
message: QueueMessage,
|
|
580
|
+
*,
|
|
581
|
+
release_delay: float,
|
|
582
|
+
dead_letter_on_exhaustion: bool,
|
|
583
|
+
error: BaseException | str | None,
|
|
584
|
+
) -> bool:
|
|
585
|
+
if dead_letter_on_exhaustion:
|
|
586
|
+
return queue.dead_letter(message, error=error)
|
|
587
|
+
return queue.release(message, delay=release_delay, error=error)
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
def _error_payload(error: BaseException | str | None) -> dict[str, Any] | None:
|
|
591
|
+
if error is None:
|
|
592
|
+
return None
|
|
593
|
+
if isinstance(error, _CommandExecutionError):
|
|
594
|
+
return {
|
|
595
|
+
"type": type(error).__name__,
|
|
596
|
+
"module": type(error).__module__,
|
|
597
|
+
"message": str(error),
|
|
598
|
+
"command": error.command,
|
|
599
|
+
"exit_code": error.exit_code,
|
|
600
|
+
"stdout": _truncate_output(error.stdout.strip()),
|
|
601
|
+
"stderr": _truncate_output(error.stderr.strip()),
|
|
602
|
+
}
|
|
603
|
+
if isinstance(error, BaseException):
|
|
604
|
+
error_type = type(error)
|
|
605
|
+
return {
|
|
606
|
+
"type": error_type.__name__,
|
|
607
|
+
"module": error_type.__module__,
|
|
608
|
+
"message": str(error),
|
|
609
|
+
}
|
|
610
|
+
return {"type": None, "module": None, "message": str(error)}
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
def _parse_json(value: str) -> Any:
|
|
614
|
+
try:
|
|
615
|
+
return json.loads(value)
|
|
616
|
+
except json.JSONDecodeError as exc:
|
|
617
|
+
raise ValueError(
|
|
618
|
+
f"value must be valid JSON unless --raw is passed: {exc}"
|
|
619
|
+
) from exc
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
def _read_value(value: str | None) -> str:
|
|
623
|
+
if value is not None:
|
|
624
|
+
return value
|
|
625
|
+
if sys.stdin.isatty():
|
|
626
|
+
raise ValueError("missing value; pass an argument or pipe data on stdin")
|
|
627
|
+
stdin_value = sys.stdin.read()
|
|
628
|
+
if stdin_value == "":
|
|
629
|
+
raise ValueError("stdin did not contain a value")
|
|
630
|
+
return stdin_value
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
def _config_path() -> Path:
|
|
634
|
+
config_home = os.environ.get("XDG_CONFIG_HOME")
|
|
635
|
+
if config_home:
|
|
636
|
+
return Path(config_home) / "localqueue" / CONFIG_FILENAME
|
|
637
|
+
return Path.home() / ".config" / "localqueue" / CONFIG_FILENAME
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
def _load_config(yaml: Any, path: Path | None = None) -> dict[str, Any]:
|
|
641
|
+
config_path = path if path is not None else _config_path()
|
|
642
|
+
if not config_path.exists():
|
|
643
|
+
return {}
|
|
644
|
+
|
|
645
|
+
with config_path.open("r", encoding="utf-8") as file:
|
|
646
|
+
loaded = yaml.safe_load(file)
|
|
647
|
+
|
|
648
|
+
if loaded is None:
|
|
649
|
+
return {}
|
|
650
|
+
if not isinstance(loaded, dict):
|
|
651
|
+
raise ValueError(f"config must be a YAML mapping: {config_path}")
|
|
652
|
+
return dict(loaded)
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
def _write_config(yaml: Any, config: dict[str, Any], path: Path | None = None) -> None:
|
|
656
|
+
config_path = path if path is not None else _config_path()
|
|
657
|
+
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
658
|
+
with config_path.open("w", encoding="utf-8") as file:
|
|
659
|
+
yaml.safe_dump(config, file, sort_keys=False)
|
|
660
|
+
|
|
661
|
+
|
|
662
|
+
def _resolve_store_path(explicit: str | None, config: dict[str, Any]) -> str:
|
|
663
|
+
return str(explicit or config.get("store_path") or DEFAULT_STORE_PATH)
|
|
664
|
+
|
|
665
|
+
|
|
666
|
+
def _resolve_retry_store_path(explicit: str | None, config: dict[str, Any]) -> str:
|
|
667
|
+
value = explicit if explicit is not None else config.get("retry_store_path")
|
|
668
|
+
if value is None:
|
|
669
|
+
return DEFAULT_RETRY_STORE_PATH
|
|
670
|
+
return str(value)
|
|
671
|
+
|
|
672
|
+
|
|
673
|
+
def _load_callable(spec: str) -> Callable[[Any], Any]:
|
|
674
|
+
module_name, separator, attribute = spec.partition(":")
|
|
675
|
+
if not separator or not module_name or not attribute:
|
|
676
|
+
raise ValueError("handler must use 'module:function' format")
|
|
677
|
+
cwd = str(Path.cwd())
|
|
678
|
+
if cwd not in sys.path:
|
|
679
|
+
sys.path.insert(0, cwd)
|
|
680
|
+
importlib.invalidate_caches()
|
|
681
|
+
module = importlib.import_module(module_name)
|
|
682
|
+
target: Any = module
|
|
683
|
+
for part in attribute.split("."):
|
|
684
|
+
target = getattr(target, part)
|
|
685
|
+
if not callable(target):
|
|
686
|
+
raise TypeError(f"handler is not callable: {spec}")
|
|
687
|
+
return target
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
def _command_handler(command: list[str]) -> Callable[[Any], None]:
|
|
691
|
+
if not command:
|
|
692
|
+
raise ValueError("command cannot be empty")
|
|
693
|
+
|
|
694
|
+
def run_command(value: Any) -> None:
|
|
695
|
+
completed = subprocess.run(
|
|
696
|
+
command,
|
|
697
|
+
input=_json_dumps(value) + "\n",
|
|
698
|
+
capture_output=True,
|
|
699
|
+
text=True,
|
|
700
|
+
check=False,
|
|
701
|
+
)
|
|
702
|
+
if completed.returncode != 0:
|
|
703
|
+
raise _CommandExecutionError(
|
|
704
|
+
command,
|
|
705
|
+
exit_code=completed.returncode,
|
|
706
|
+
stdout=completed.stdout,
|
|
707
|
+
stderr=completed.stderr,
|
|
708
|
+
)
|
|
709
|
+
|
|
710
|
+
return run_command
|
|
711
|
+
|
|
712
|
+
|
|
713
|
+
def _message_payload(message: QueueMessage) -> dict[str, Any]:
|
|
714
|
+
return {
|
|
715
|
+
"id": message.id,
|
|
716
|
+
"queue": message.queue,
|
|
717
|
+
"state": message.state,
|
|
718
|
+
"value": message.value,
|
|
719
|
+
"attempts": message.attempts,
|
|
720
|
+
"created_at": message.created_at,
|
|
721
|
+
"available_at": message.available_at,
|
|
722
|
+
"leased_until": message.leased_until,
|
|
723
|
+
"leased_by": message.leased_by,
|
|
724
|
+
"last_error": message.last_error,
|
|
725
|
+
"failed_at": message.failed_at,
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
|
|
729
|
+
def _json_dumps(value: Any) -> str:
|
|
730
|
+
return json.dumps(value, ensure_ascii=False, sort_keys=True)
|
|
731
|
+
|
|
732
|
+
|
|
733
|
+
def _format_command(command: list[str]) -> str:
|
|
734
|
+
return " ".join(command)
|
|
735
|
+
|
|
736
|
+
|
|
737
|
+
def _truncate_output(value: str, *, limit: int = 500) -> str:
|
|
738
|
+
if len(value) <= limit:
|
|
739
|
+
return value
|
|
740
|
+
return value[: limit - 3] + "..."
|
|
741
|
+
|
|
742
|
+
|
|
743
|
+
def _print_json(console: Any, value: Any) -> None:
|
|
744
|
+
console.print_json(_json_dumps(value))
|