localqueue 0.1.0__tar.gz → 0.2.0__tar.gz
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-0.1.0 → localqueue-0.2.0}/CHANGELOG.md +15 -0
- {localqueue-0.1.0 → localqueue-0.2.0}/PKG-INFO +17 -1
- {localqueue-0.1.0 → localqueue-0.2.0}/README.md +12 -0
- {localqueue-0.1.0 → localqueue-0.2.0}/docs/api.md +4 -0
- {localqueue-0.1.0 → localqueue-0.2.0}/docs/index.md +23 -0
- {localqueue-0.1.0 → localqueue-0.2.0}/docs/queues.md +39 -3
- {localqueue-0.1.0 → localqueue-0.2.0}/docs/release.md +1 -1
- localqueue-0.2.0/examples/process_webhook.sh +10 -0
- {localqueue-0.1.0 → localqueue-0.2.0}/localqueue/cli.py +104 -14
- {localqueue-0.1.0 → localqueue-0.2.0}/pyproject.toml +7 -1
- {localqueue-0.1.0 → localqueue-0.2.0}/tests/test_cli.py +125 -0
- {localqueue-0.1.0 → localqueue-0.2.0}/uv.lock +1 -1
- {localqueue-0.1.0 → localqueue-0.2.0}/.gitignore +0 -0
- {localqueue-0.1.0 → localqueue-0.2.0}/LICENSE +0 -0
- {localqueue-0.1.0 → localqueue-0.2.0}/docs/operational-maturity.md +0 -0
- {localqueue-0.1.0 → localqueue-0.2.0}/docs/retries.md +0 -0
- {localqueue-0.1.0 → localqueue-0.2.0}/examples/email_worker.py +0 -0
- {localqueue-0.1.0 → localqueue-0.2.0}/examples/enqueue_email.py +0 -0
- {localqueue-0.1.0 → localqueue-0.2.0}/localqueue/__init__.py +0 -0
- {localqueue-0.1.0 → localqueue-0.2.0}/localqueue/queue.py +0 -0
- {localqueue-0.1.0 → localqueue-0.2.0}/localqueue/retry/__init__.py +0 -0
- {localqueue-0.1.0 → localqueue-0.2.0}/localqueue/retry/store.py +0 -0
- {localqueue-0.1.0 → localqueue-0.2.0}/localqueue/retry/tenacity.py +0 -0
- {localqueue-0.1.0 → localqueue-0.2.0}/localqueue/store.py +0 -0
- {localqueue-0.1.0 → localqueue-0.2.0}/localqueue/worker.py +0 -0
- {localqueue-0.1.0 → localqueue-0.2.0}/tests/test_queue.py +0 -0
- {localqueue-0.1.0 → localqueue-0.2.0}/tests/test_retry.py +0 -0
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## Unreleased
|
|
4
|
+
|
|
5
|
+
## 0.2.0
|
|
6
|
+
|
|
7
|
+
- Add `queue stats --watch` for monitoring queue counts while workers run.
|
|
8
|
+
- Add `queue dead --watch` for repeatedly listing dead letters.
|
|
9
|
+
- Add `queue requeue-dead --all` for bulk recovery after a fix.
|
|
10
|
+
- Add structured `command not found` handling for `queue exec`.
|
|
11
|
+
- Add `examples/process_webhook.sh` as a shell/curl worker example.
|
|
12
|
+
|
|
13
|
+
## 0.1.1
|
|
14
|
+
|
|
15
|
+
- Add project URLs for PyPI metadata.
|
|
16
|
+
- Document `queue exec` command-failure fields stored in `last_error`.
|
|
17
|
+
|
|
3
18
|
## 0.1.0
|
|
4
19
|
|
|
5
20
|
- Add persistent retry wrappers for sync and async Tenacity retryers.
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: localqueue
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Durable local queues for Python, with persistent retry state powered by Tenacity.
|
|
5
|
+
Project-URL: Homepage, https://brunoportis.github.io/localqueue/
|
|
6
|
+
Project-URL: Documentation, https://brunoportis.github.io/localqueue/
|
|
7
|
+
Project-URL: Repository, https://github.com/brunoportis/localqueue
|
|
8
|
+
Project-URL: Issues, https://github.com/brunoportis/localqueue/issues
|
|
5
9
|
Author: Bruno Portis
|
|
6
10
|
License-Expression: MIT
|
|
7
11
|
License-File: LICENSE
|
|
@@ -121,9 +125,12 @@ localqueue queue add emails --value '{"to":"user@example.com"}'
|
|
|
121
125
|
echo '{"to":"user@example.com"}' | localqueue queue add emails
|
|
122
126
|
localqueue queue size emails
|
|
123
127
|
localqueue queue stats emails
|
|
128
|
+
localqueue queue stats emails --watch --interval 1
|
|
124
129
|
localqueue queue inspect emails <message-id>
|
|
125
130
|
localqueue queue dead emails
|
|
131
|
+
localqueue queue dead emails --watch --interval 1
|
|
126
132
|
localqueue queue requeue-dead emails <message-id>
|
|
133
|
+
localqueue queue requeue-dead emails --all
|
|
127
134
|
localqueue queue pop emails --worker-id worker-1
|
|
128
135
|
localqueue queue ack emails <message-id>
|
|
129
136
|
localqueue queue exec emails -- python scripts/send_email.py
|
|
@@ -157,8 +164,13 @@ retry, release, and dead-letter rules as `queue process`.
|
|
|
157
164
|
localqueue queue exec emails -- python scripts/send_email.py
|
|
158
165
|
localqueue queue exec webhooks -- curl -X POST https://example.com/hook -d @-
|
|
159
166
|
localqueue queue exec emails -- sh -c 'jq -r .to | xargs -I{} curl https://example.com/{}'
|
|
167
|
+
localqueue queue exec webhooks -- sh examples/process_webhook.sh
|
|
160
168
|
```
|
|
161
169
|
|
|
170
|
+
Command output is captured so the CLI can keep printing its own JSON status.
|
|
171
|
+
When a command fails, `last_error` includes the command, exit code, stdout, and
|
|
172
|
+
stderr, truncated for inspection.
|
|
173
|
+
|
|
162
174
|
Use `--forever` for a long-running worker. When interrupted with `SIGINT` or
|
|
163
175
|
`SIGTERM`, the CLI finishes the current message before stopping.
|
|
164
176
|
|
|
@@ -276,6 +288,10 @@ Use `queue stats` when you need ready, delayed, inflight, dead-letter, and total
|
|
|
276
288
|
counts instead of only ready messages. Use `queue inspect` to inspect one
|
|
277
289
|
message by id, `queue dead` to list dead-letter messages, and
|
|
278
290
|
`queue requeue-dead` to retry one after the failure cause is fixed.
|
|
291
|
+
Use `queue stats --watch` to print those counts repeatedly while local workers
|
|
292
|
+
are running.
|
|
293
|
+
Use `queue dead --watch` to keep an eye on newly failed jobs, and
|
|
294
|
+
`queue requeue-dead --all` once you have fixed the underlying issue.
|
|
279
295
|
|
|
280
296
|
## Operational boundaries
|
|
281
297
|
|
|
@@ -90,9 +90,12 @@ localqueue queue add emails --value '{"to":"user@example.com"}'
|
|
|
90
90
|
echo '{"to":"user@example.com"}' | localqueue queue add emails
|
|
91
91
|
localqueue queue size emails
|
|
92
92
|
localqueue queue stats emails
|
|
93
|
+
localqueue queue stats emails --watch --interval 1
|
|
93
94
|
localqueue queue inspect emails <message-id>
|
|
94
95
|
localqueue queue dead emails
|
|
96
|
+
localqueue queue dead emails --watch --interval 1
|
|
95
97
|
localqueue queue requeue-dead emails <message-id>
|
|
98
|
+
localqueue queue requeue-dead emails --all
|
|
96
99
|
localqueue queue pop emails --worker-id worker-1
|
|
97
100
|
localqueue queue ack emails <message-id>
|
|
98
101
|
localqueue queue exec emails -- python scripts/send_email.py
|
|
@@ -126,8 +129,13 @@ retry, release, and dead-letter rules as `queue process`.
|
|
|
126
129
|
localqueue queue exec emails -- python scripts/send_email.py
|
|
127
130
|
localqueue queue exec webhooks -- curl -X POST https://example.com/hook -d @-
|
|
128
131
|
localqueue queue exec emails -- sh -c 'jq -r .to | xargs -I{} curl https://example.com/{}'
|
|
132
|
+
localqueue queue exec webhooks -- sh examples/process_webhook.sh
|
|
129
133
|
```
|
|
130
134
|
|
|
135
|
+
Command output is captured so the CLI can keep printing its own JSON status.
|
|
136
|
+
When a command fails, `last_error` includes the command, exit code, stdout, and
|
|
137
|
+
stderr, truncated for inspection.
|
|
138
|
+
|
|
131
139
|
Use `--forever` for a long-running worker. When interrupted with `SIGINT` or
|
|
132
140
|
`SIGTERM`, the CLI finishes the current message before stopping.
|
|
133
141
|
|
|
@@ -245,6 +253,10 @@ Use `queue stats` when you need ready, delayed, inflight, dead-letter, and total
|
|
|
245
253
|
counts instead of only ready messages. Use `queue inspect` to inspect one
|
|
246
254
|
message by id, `queue dead` to list dead-letter messages, and
|
|
247
255
|
`queue requeue-dead` to retry one after the failure cause is fixed.
|
|
256
|
+
Use `queue stats --watch` to print those counts repeatedly while local workers
|
|
257
|
+
are running.
|
|
258
|
+
Use `queue dead --watch` to keep an eye on newly failed jobs, and
|
|
259
|
+
`queue requeue-dead --all` once you have fixed the underlying issue.
|
|
248
260
|
|
|
249
261
|
## Operational boundaries
|
|
250
262
|
|
|
@@ -94,6 +94,10 @@ Dataclass returned by `put()` and `get_message()`.
|
|
|
94
94
|
| `last_error` | structured error from the most recent failed processing attempt, if recorded |
|
|
95
95
|
| `failed_at` | timestamp for `last_error`, if recorded |
|
|
96
96
|
|
|
97
|
+
For `localqueue queue exec` failures, `last_error` also includes `command`,
|
|
98
|
+
`exit_code`, `stdout`, and `stderr` fields so command workers can be inspected
|
|
99
|
+
from `queue inspect` and `queue dead`.
|
|
100
|
+
|
|
97
101
|
#### `QueueStats`
|
|
98
102
|
|
|
99
103
|
Dataclass returned by `stats()`.
|
|
@@ -50,6 +50,8 @@ localqueue queue exec emails -- python scripts/send_email.py
|
|
|
50
50
|
`queue exec` writes the message value to the command's stdin as JSON. Exit code
|
|
51
51
|
`0` acknowledges the message. Any other exit code is treated as a failed handler
|
|
52
52
|
attempt and follows the configured retry, release, and dead-letter policy.
|
|
53
|
+
Command failures are recorded in `last_error` with the command, exit code,
|
|
54
|
+
stdout, and stderr.
|
|
53
55
|
|
|
54
56
|
Run a continuous local worker with `--forever`. `SIGINT` and `SIGTERM` request a
|
|
55
57
|
graceful stop after the current message finishes.
|
|
@@ -62,6 +64,27 @@ localqueue queue process emails myapp.workers:send_email \
|
|
|
62
64
|
--max-tries 5
|
|
63
65
|
```
|
|
64
66
|
|
|
67
|
+
Watch queue counts while workers run:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
localqueue queue stats emails --watch --interval 1
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
For shell workers, `examples/process_webhook.sh` shows the same pattern with
|
|
74
|
+
`jq` and `curl`.
|
|
75
|
+
|
|
76
|
+
Inspect dead letters while workers are running:
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
localqueue queue dead emails --watch --interval 2
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
After fixing a problem, requeue all dead letters with:
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
localqueue queue requeue-dead emails --all
|
|
86
|
+
```
|
|
87
|
+
|
|
65
88
|
For a local smoke test, enqueue one job and process it with the bundled example
|
|
66
89
|
handler:
|
|
67
90
|
|
|
@@ -91,16 +91,29 @@ external command instead of an importable Python function.
|
|
|
91
91
|
localqueue queue exec jobs -- python scripts/process_job.py
|
|
92
92
|
localqueue queue exec webhooks -- curl -X POST https://example.com/hook -d @-
|
|
93
93
|
localqueue queue exec jobs -- sh -c 'jq -r .id | xargs -I{} ./process-job {}'
|
|
94
|
+
localqueue queue exec webhooks -- sh examples/process_webhook.sh
|
|
94
95
|
```
|
|
95
96
|
|
|
96
97
|
The command receives `message.value` on stdin as JSON. `localqueue` captures the
|
|
97
98
|
command output so the CLI can keep printing its own JSON status. Exit code `0`
|
|
98
|
-
acks the message. Any other exit code raises a command failure
|
|
99
|
-
|
|
100
|
-
|
|
99
|
+
acks the message. Any other exit code raises a command failure and applies the
|
|
100
|
+
same retry, release, or dead-letter behavior as `queue process`.
|
|
101
|
+
|
|
102
|
+
Command failures are stored in `last_error` with structured fields:
|
|
103
|
+
|
|
104
|
+
| Field | Meaning |
|
|
105
|
+
| --- | --- |
|
|
106
|
+
| `type` | `_CommandExecutionError` |
|
|
107
|
+
| `message` | human-readable command failure |
|
|
108
|
+
| `command` | argv list that was executed |
|
|
109
|
+
| `exit_code` | command exit code |
|
|
110
|
+
| `stdout` | captured stdout, truncated for inspection |
|
|
111
|
+
| `stderr` | captured stderr, truncated for inspection |
|
|
101
112
|
|
|
102
113
|
Commands are executed without an implicit shell. Use `sh -c` explicitly when you
|
|
103
114
|
want pipes, redirects, shell expansion, or multiple commands.
|
|
115
|
+
See `examples/process_webhook.sh` for a shell worker that reads JSON, extracts
|
|
116
|
+
fields with `jq`, and posts them with `curl`.
|
|
104
117
|
|
|
105
118
|
## Queue-style usage
|
|
106
119
|
|
|
@@ -305,3 +318,26 @@ by id without changing state. `dead_letters()` lists dead-letter messages for
|
|
|
305
318
|
inspection. `requeue_dead()` moves a dead-letter message back to the ready queue.
|
|
306
319
|
`purge()` removes all records for that queue, including ready, inflight, and
|
|
307
320
|
dead-letter records.
|
|
321
|
+
|
|
322
|
+
From the CLI, use `queue stats --watch` to monitor those counts while workers
|
|
323
|
+
are running:
|
|
324
|
+
|
|
325
|
+
```bash
|
|
326
|
+
localqueue queue stats jobs --watch --interval 1
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
Each sample is printed as JSON with `ready`, `delayed`, `inflight`, `dead`, and
|
|
330
|
+
`total` counts. Stop the watch with `Ctrl-C`.
|
|
331
|
+
|
|
332
|
+
Use `queue dead --watch` to keep an eye on recent failures:
|
|
333
|
+
|
|
334
|
+
```bash
|
|
335
|
+
localqueue queue dead jobs --watch --interval 2
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
When the underlying problem is fixed, `queue requeue-dead --all` moves every
|
|
339
|
+
dead letter back to ready delivery:
|
|
340
|
+
|
|
341
|
+
```bash
|
|
342
|
+
localqueue queue requeue-dead jobs --all
|
|
343
|
+
```
|
|
@@ -67,6 +67,17 @@ class _CommandExecutionError(RuntimeError):
|
|
|
67
67
|
)
|
|
68
68
|
|
|
69
69
|
|
|
70
|
+
class _CommandNotFoundError(_CommandExecutionError):
|
|
71
|
+
def __init__(self, command: list[str]) -> None:
|
|
72
|
+
super().__init__(
|
|
73
|
+
command,
|
|
74
|
+
exit_code=127,
|
|
75
|
+
stdout="",
|
|
76
|
+
stderr="command not found",
|
|
77
|
+
)
|
|
78
|
+
self.args = (f"command not found: {_format_command(command)}",)
|
|
79
|
+
|
|
80
|
+
|
|
70
81
|
def main(args: list[str] | None = None) -> None:
|
|
71
82
|
try:
|
|
72
83
|
from rich.console import Console
|
|
@@ -251,29 +262,65 @@ def _build_app(typer: Any, yaml: Any, console: Any, err_console: Any) -> Any:
|
|
|
251
262
|
def queue_stats(
|
|
252
263
|
queue: str,
|
|
253
264
|
store_path: str | None = typer.Option(None, "--store-path"),
|
|
265
|
+
watch: bool = typer.Option(False, "--watch"),
|
|
266
|
+
interval: float = typer.Option(1.0, "--interval", min=0.001),
|
|
254
267
|
) -> None:
|
|
255
|
-
|
|
256
|
-
|
|
268
|
+
persistent_queue = _queue(queue, _resolve_store_path(store_path, config))
|
|
269
|
+
with _shutdown_state() as shutdown:
|
|
270
|
+
_print_queue_stats(
|
|
271
|
+
persistent_queue,
|
|
272
|
+
console=console,
|
|
273
|
+
watch=watch,
|
|
274
|
+
interval=interval,
|
|
275
|
+
shutdown=shutdown,
|
|
276
|
+
)
|
|
257
277
|
|
|
258
278
|
@queue_app.command("dead")
|
|
259
279
|
def queue_dead(
|
|
260
280
|
queue: str,
|
|
261
281
|
store_path: str | None = typer.Option(None, "--store-path"),
|
|
262
282
|
limit: int | None = typer.Option(None, "--limit", min=0),
|
|
283
|
+
watch: bool = typer.Option(False, "--watch"),
|
|
284
|
+
interval: float = typer.Option(1.0, "--interval", min=0.001),
|
|
263
285
|
) -> None:
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
286
|
+
persistent_queue = _queue(queue, _resolve_store_path(store_path, config))
|
|
287
|
+
with _shutdown_state() as shutdown:
|
|
288
|
+
_print_dead_letters(
|
|
289
|
+
persistent_queue,
|
|
290
|
+
console=console,
|
|
291
|
+
limit=limit,
|
|
292
|
+
watch=watch,
|
|
293
|
+
interval=interval,
|
|
294
|
+
shutdown=shutdown,
|
|
295
|
+
)
|
|
268
296
|
|
|
269
297
|
@queue_app.command("requeue-dead")
|
|
270
298
|
def queue_requeue_dead(
|
|
271
299
|
queue: str,
|
|
272
|
-
message_id: str,
|
|
300
|
+
message_id: str | None = typer.Argument(None),
|
|
273
301
|
store_path: str | None = typer.Option(None, "--store-path"),
|
|
274
302
|
delay: float = typer.Option(0.0, "--delay", min=0.0),
|
|
303
|
+
all_: bool = typer.Option(False, "--all"),
|
|
275
304
|
) -> None:
|
|
276
305
|
persistent_queue = _queue(queue, _resolve_store_path(store_path, config))
|
|
306
|
+
if all_:
|
|
307
|
+
if message_id is not None:
|
|
308
|
+
err_console.print(
|
|
309
|
+
"[red]pass either a message id or --all, not both[/red]"
|
|
310
|
+
)
|
|
311
|
+
raise typer.Exit(1)
|
|
312
|
+
messages = persistent_queue.dead_letters()
|
|
313
|
+
requeued = 0
|
|
314
|
+
for message in messages:
|
|
315
|
+
if persistent_queue.requeue_dead(message, delay=delay):
|
|
316
|
+
requeued += 1
|
|
317
|
+
_print_json(console, {"requeued": requeued})
|
|
318
|
+
return
|
|
319
|
+
if message_id is None:
|
|
320
|
+
err_console.print(
|
|
321
|
+
"[red]pass a message id or use --all to requeue dead letters[/red]"
|
|
322
|
+
)
|
|
323
|
+
raise typer.Exit(1)
|
|
277
324
|
message = QueueMessage(id=message_id, value=None, queue=queue)
|
|
278
325
|
if not persistent_queue.requeue_dead(message, delay=delay):
|
|
279
326
|
err_console.print(f"[red]dead-letter message not found:[/red] {message_id}")
|
|
@@ -550,6 +597,46 @@ def _process_queue_messages(
|
|
|
550
597
|
return 0
|
|
551
598
|
|
|
552
599
|
|
|
600
|
+
def _print_dead_letters(
|
|
601
|
+
queue: PersistentQueue,
|
|
602
|
+
*,
|
|
603
|
+
console: Any,
|
|
604
|
+
limit: int | None,
|
|
605
|
+
watch: bool,
|
|
606
|
+
interval: float,
|
|
607
|
+
shutdown: _ShutdownState,
|
|
608
|
+
) -> None:
|
|
609
|
+
while True:
|
|
610
|
+
messages = queue.dead_letters(limit=limit)
|
|
611
|
+
_print_json(console, [_message_payload(message) for message in messages])
|
|
612
|
+
if not watch:
|
|
613
|
+
return
|
|
614
|
+
if shutdown.requested:
|
|
615
|
+
return
|
|
616
|
+
time.sleep(interval)
|
|
617
|
+
if shutdown.requested:
|
|
618
|
+
return
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
def _print_queue_stats(
|
|
622
|
+
queue: PersistentQueue,
|
|
623
|
+
*,
|
|
624
|
+
console: Any,
|
|
625
|
+
watch: bool,
|
|
626
|
+
interval: float,
|
|
627
|
+
shutdown: _ShutdownState,
|
|
628
|
+
) -> None:
|
|
629
|
+
while True:
|
|
630
|
+
_print_json(console, queue.stats().as_dict())
|
|
631
|
+
if not watch:
|
|
632
|
+
return
|
|
633
|
+
if shutdown.requested:
|
|
634
|
+
return
|
|
635
|
+
time.sleep(interval)
|
|
636
|
+
if shutdown.requested:
|
|
637
|
+
return
|
|
638
|
+
|
|
639
|
+
|
|
553
640
|
def _poll_timeout(forever: bool, block: bool, timeout: float | None) -> float | None:
|
|
554
641
|
if forever and block and timeout is None:
|
|
555
642
|
return 0.5
|
|
@@ -692,13 +779,16 @@ def _command_handler(command: list[str]) -> Callable[[Any], None]:
|
|
|
692
779
|
raise ValueError("command cannot be empty")
|
|
693
780
|
|
|
694
781
|
def run_command(value: Any) -> None:
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
782
|
+
try:
|
|
783
|
+
completed = subprocess.run(
|
|
784
|
+
command,
|
|
785
|
+
input=_json_dumps(value) + "\n",
|
|
786
|
+
capture_output=True,
|
|
787
|
+
text=True,
|
|
788
|
+
check=False,
|
|
789
|
+
)
|
|
790
|
+
except FileNotFoundError as exc:
|
|
791
|
+
raise _CommandNotFoundError(command) from exc
|
|
702
792
|
if completed.returncode != 0:
|
|
703
793
|
raise _CommandExecutionError(
|
|
704
794
|
command,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "localqueue"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.2.0"
|
|
4
4
|
description = "Durable local queues for Python, with persistent retry state powered by Tenacity."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.11"
|
|
@@ -43,6 +43,12 @@ all = [
|
|
|
43
43
|
[project.scripts]
|
|
44
44
|
localqueue = "localqueue.cli:main"
|
|
45
45
|
|
|
46
|
+
[project.urls]
|
|
47
|
+
Homepage = "https://brunoportis.github.io/localqueue/"
|
|
48
|
+
Documentation = "https://brunoportis.github.io/localqueue/"
|
|
49
|
+
Repository = "https://github.com/brunoportis/localqueue"
|
|
50
|
+
Issues = "https://github.com/brunoportis/localqueue/issues"
|
|
51
|
+
|
|
46
52
|
[build-system]
|
|
47
53
|
requires = ["hatchling>=1.27.0"]
|
|
48
54
|
build-backend = "hatchling.build"
|
|
@@ -28,6 +28,8 @@ from localqueue.cli import (
|
|
|
28
28
|
_load_callable,
|
|
29
29
|
_load_config,
|
|
30
30
|
_parse_json,
|
|
31
|
+
_print_dead_letters,
|
|
32
|
+
_print_queue_stats,
|
|
31
33
|
_process_message,
|
|
32
34
|
_process_queue_messages,
|
|
33
35
|
_read_value,
|
|
@@ -380,6 +382,30 @@ class CliTests(unittest.TestCase):
|
|
|
380
382
|
self.assertEqual(missing_requeue.exit_code, 1)
|
|
381
383
|
self.assertIn("dead-letter message not found", missing_requeue.output)
|
|
382
384
|
|
|
385
|
+
def test_print_dead_letters_watch_stops_on_shutdown(self) -> None:
|
|
386
|
+
queue = PersistentQueue("emails", store=MemoryQueueStore())
|
|
387
|
+
_ = queue.put({"to": "user@example.com"})
|
|
388
|
+
message = queue.get_message()
|
|
389
|
+
self.assertTrue(queue.dead_letter(message, error=RuntimeError("bad")))
|
|
390
|
+
console = _JsonConsole()
|
|
391
|
+
shutdown = _ShutdownState()
|
|
392
|
+
|
|
393
|
+
def request_shutdown(_interval: float) -> None:
|
|
394
|
+
shutdown.requested = True
|
|
395
|
+
|
|
396
|
+
with mock.patch("time.sleep", side_effect=request_shutdown):
|
|
397
|
+
_print_dead_letters(
|
|
398
|
+
queue,
|
|
399
|
+
console=console,
|
|
400
|
+
limit=None,
|
|
401
|
+
watch=True,
|
|
402
|
+
interval=0.001,
|
|
403
|
+
shutdown=shutdown,
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
self.assertEqual(len(console.values), 1)
|
|
407
|
+
self.assertEqual(console.values[0][0]["id"], message.id)
|
|
408
|
+
|
|
383
409
|
def test_queue_process_command_acks_successful_handler(self) -> None:
|
|
384
410
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
385
411
|
tmp_path = Path(tmpdir)
|
|
@@ -496,6 +522,40 @@ class CliTests(unittest.TestCase):
|
|
|
496
522
|
self.assertEqual(result.exit_code, 1)
|
|
497
523
|
self.assertIn("module:function", result.output)
|
|
498
524
|
|
|
525
|
+
def test_queue_exec_reports_missing_command(self) -> None:
|
|
526
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
527
|
+
store_path = str(Path(tmpdir) / "queues")
|
|
528
|
+
retry_store_path = str(Path(tmpdir) / "retries.sqlite3")
|
|
529
|
+
queue = PersistentQueue("emails", store_path=store_path)
|
|
530
|
+
message = queue.put({"to": "user@example.com"})
|
|
531
|
+
|
|
532
|
+
result = self._invoke(
|
|
533
|
+
[
|
|
534
|
+
"queue",
|
|
535
|
+
"exec",
|
|
536
|
+
"emails",
|
|
537
|
+
"--store-path",
|
|
538
|
+
store_path,
|
|
539
|
+
"--retry-store-path",
|
|
540
|
+
retry_store_path,
|
|
541
|
+
"--max-tries",
|
|
542
|
+
"1",
|
|
543
|
+
"--",
|
|
544
|
+
"definitely-not-a-real-command-123",
|
|
545
|
+
]
|
|
546
|
+
)
|
|
547
|
+
|
|
548
|
+
self.assertEqual(result.exit_code, 1)
|
|
549
|
+
payload = json.loads(result.stdout)
|
|
550
|
+
self.assertEqual(payload["id"], message.id)
|
|
551
|
+
self.assertEqual(payload["state"], "failed")
|
|
552
|
+
self.assertEqual(payload["last_error"]["type"], "_CommandNotFoundError")
|
|
553
|
+
self.assertEqual(payload["last_error"]["exit_code"], 127)
|
|
554
|
+
self.assertEqual(
|
|
555
|
+
payload["last_error"]["command"], ["definitely-not-a-real-command-123"]
|
|
556
|
+
)
|
|
557
|
+
self.assertIn("command not found", payload["last_error"]["message"])
|
|
558
|
+
|
|
499
559
|
def test_command_handler_passes_message_value_as_json_stdin(self) -> None:
|
|
500
560
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
501
561
|
tmp_path = Path(tmpdir)
|
|
@@ -610,6 +670,48 @@ class CliTests(unittest.TestCase):
|
|
|
610
670
|
self.assertEqual(dead_letters[0].last_error["exit_code"], 7)
|
|
611
671
|
self.assertEqual(dead_letters[0].last_error["stderr"], "cannot deliver")
|
|
612
672
|
|
|
673
|
+
def test_queue_requeue_dead_all_moves_every_dead_message(self) -> None:
|
|
674
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
675
|
+
store_path = str(Path(tmpdir) / "queues")
|
|
676
|
+
persistent_queue = PersistentQueue("emails", store_path=store_path)
|
|
677
|
+
first = persistent_queue.put({"to": "one@example.com"})
|
|
678
|
+
second = persistent_queue.put({"to": "two@example.com"})
|
|
679
|
+
self.assertTrue(
|
|
680
|
+
persistent_queue.dead_letter(
|
|
681
|
+
persistent_queue.get_message(), error=RuntimeError("bad 1")
|
|
682
|
+
)
|
|
683
|
+
)
|
|
684
|
+
self.assertTrue(
|
|
685
|
+
persistent_queue.dead_letter(
|
|
686
|
+
persistent_queue.get_message(), error=RuntimeError("bad 2")
|
|
687
|
+
)
|
|
688
|
+
)
|
|
689
|
+
|
|
690
|
+
result = self._invoke(
|
|
691
|
+
[
|
|
692
|
+
"queue",
|
|
693
|
+
"requeue-dead",
|
|
694
|
+
"emails",
|
|
695
|
+
"--all",
|
|
696
|
+
"--store-path",
|
|
697
|
+
store_path,
|
|
698
|
+
]
|
|
699
|
+
)
|
|
700
|
+
|
|
701
|
+
self.assertEqual(result.exit_code, 0, result.output)
|
|
702
|
+
self.assertEqual(json.loads(result.stdout), {"requeued": 2})
|
|
703
|
+
fresh_queue = PersistentQueue("emails", store_path=store_path)
|
|
704
|
+
self.assertEqual(fresh_queue.dead_letters(), [])
|
|
705
|
+
self.assertEqual(fresh_queue.qsize(), 2)
|
|
706
|
+
first_message = fresh_queue.inspect(first.id)
|
|
707
|
+
second_message = fresh_queue.inspect(second.id)
|
|
708
|
+
self.assertIsNotNone(first_message)
|
|
709
|
+
self.assertIsNotNone(second_message)
|
|
710
|
+
assert first_message is not None
|
|
711
|
+
assert second_message is not None
|
|
712
|
+
self.assertEqual(first_message.state, "ready")
|
|
713
|
+
self.assertEqual(second_message.state, "ready")
|
|
714
|
+
|
|
613
715
|
def test_process_message_acks_successful_handler(self) -> None:
|
|
614
716
|
queue = PersistentQueue("emails", store=MemoryQueueStore())
|
|
615
717
|
_ = queue.put({"to": "user@example.com"})
|
|
@@ -724,6 +826,29 @@ class CliTests(unittest.TestCase):
|
|
|
724
826
|
self.assertEqual(console.values, [])
|
|
725
827
|
self.assertEqual(err_console.messages, ["[yellow]queue is empty[/yellow]"])
|
|
726
828
|
|
|
829
|
+
def test_print_queue_stats_watch_stops_on_shutdown(self) -> None:
|
|
830
|
+
queue = PersistentQueue("emails", store=MemoryQueueStore())
|
|
831
|
+
_ = queue.put({"to": "user@example.com"})
|
|
832
|
+
console = _JsonConsole()
|
|
833
|
+
shutdown = _ShutdownState()
|
|
834
|
+
|
|
835
|
+
def request_shutdown(_interval: float) -> None:
|
|
836
|
+
shutdown.requested = True
|
|
837
|
+
|
|
838
|
+
with mock.patch("time.sleep", side_effect=request_shutdown):
|
|
839
|
+
_print_queue_stats(
|
|
840
|
+
queue,
|
|
841
|
+
console=console,
|
|
842
|
+
watch=True,
|
|
843
|
+
interval=0.001,
|
|
844
|
+
shutdown=shutdown,
|
|
845
|
+
)
|
|
846
|
+
|
|
847
|
+
self.assertEqual(
|
|
848
|
+
console.values,
|
|
849
|
+
[{"ready": 1, "delayed": 0, "inflight": 0, "dead": 0, "total": 1}],
|
|
850
|
+
)
|
|
851
|
+
|
|
727
852
|
def test_process_queue_messages_forever_stops_after_current_message(self) -> None:
|
|
728
853
|
queue = PersistentQueue("emails", store=MemoryQueueStore())
|
|
729
854
|
_ = queue.put({"to": "first@example.com"})
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|