localqueue 0.2.0__py3-none-any.whl → 0.3.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/cli.py +536 -15
- localqueue/failure.py +27 -0
- localqueue/queue.py +48 -0
- localqueue/retry/store.py +80 -0
- localqueue/store.py +717 -18
- localqueue/worker.py +202 -6
- localqueue-0.3.0.dist-info/METADATA +83 -0
- localqueue-0.3.0.dist-info/RECORD +14 -0
- localqueue-0.2.0.dist-info/METADATA +0 -372
- localqueue-0.2.0.dist-info/RECORD +0 -13
- {localqueue-0.2.0.dist-info → localqueue-0.3.0.dist-info}/WHEEL +0 -0
- {localqueue-0.2.0.dist-info → localqueue-0.3.0.dist-info}/entry_points.txt +0 -0
- {localqueue-0.2.0.dist-info → localqueue-0.3.0.dist-info}/licenses/LICENSE +0 -0
localqueue/cli.py
CHANGED
|
@@ -7,6 +7,7 @@ import signal
|
|
|
7
7
|
import subprocess
|
|
8
8
|
import sys
|
|
9
9
|
import time
|
|
10
|
+
from collections import Counter
|
|
10
11
|
from collections.abc import Callable
|
|
11
12
|
from contextlib import contextmanager
|
|
12
13
|
from dataclasses import dataclass
|
|
@@ -17,6 +18,7 @@ from typing import Any, Iterator
|
|
|
17
18
|
|
|
18
19
|
from tenacity import wait_none
|
|
19
20
|
|
|
21
|
+
from .failure import is_permanent_failure
|
|
20
22
|
from .queue import PersistentQueue
|
|
21
23
|
from .retry import (
|
|
22
24
|
PersistentRetryExhausted,
|
|
@@ -24,6 +26,13 @@ from .retry import (
|
|
|
24
26
|
SQLiteAttemptStore,
|
|
25
27
|
)
|
|
26
28
|
from .store import QueueMessage
|
|
29
|
+
from .worker import (
|
|
30
|
+
PersistentWorkerConfig,
|
|
31
|
+
WorkerPolicyState,
|
|
32
|
+
_record_failure,
|
|
33
|
+
_record_success,
|
|
34
|
+
_sleep_for_policy,
|
|
35
|
+
)
|
|
27
36
|
|
|
28
37
|
DEFAULT_STORE_PATH = "localqueue_queue.sqlite3"
|
|
29
38
|
DEFAULT_RETRY_STORE_PATH = "localqueue_retries.sqlite3"
|
|
@@ -34,6 +43,8 @@ CONFIG_FILENAME = "config.yaml"
|
|
|
34
43
|
class _ProcessResult:
|
|
35
44
|
processed: bool
|
|
36
45
|
last_error: dict[str, Any] | None = None
|
|
46
|
+
final_state: str | None = None
|
|
47
|
+
permanent_failure: bool = False
|
|
37
48
|
|
|
38
49
|
|
|
39
50
|
@dataclass(slots=True)
|
|
@@ -97,8 +108,10 @@ def _build_app(typer: Any, yaml: Any, console: Any, err_console: Any) -> Any:
|
|
|
97
108
|
config = _load_config(yaml)
|
|
98
109
|
app = typer.Typer(no_args_is_help=True)
|
|
99
110
|
queue_app = typer.Typer(no_args_is_help=True)
|
|
111
|
+
retry_app = typer.Typer(no_args_is_help=True)
|
|
100
112
|
config_app = typer.Typer(no_args_is_help=True)
|
|
101
113
|
app.add_typer(queue_app, name="queue")
|
|
114
|
+
app.add_typer(retry_app, name="retry")
|
|
102
115
|
app.add_typer(config_app, name="config")
|
|
103
116
|
|
|
104
117
|
@config_app.command("path")
|
|
@@ -113,6 +126,12 @@ def _build_app(typer: Any, yaml: Any, console: Any, err_console: Any) -> Any:
|
|
|
113
126
|
def config_init(
|
|
114
127
|
store_path: str = typer.Option(DEFAULT_STORE_PATH, "--store-path"),
|
|
115
128
|
retry_store_path: str | None = typer.Option(None, "--retry-store-path"),
|
|
129
|
+
dead_letter_ttl_seconds: float | None = typer.Option(
|
|
130
|
+
None, "--dead-letter-ttl-seconds", min=0.0
|
|
131
|
+
),
|
|
132
|
+
retry_record_ttl_seconds: float | None = typer.Option(
|
|
133
|
+
None, "--retry-record-ttl-seconds", min=0.0
|
|
134
|
+
),
|
|
116
135
|
force: bool = typer.Option(False, "--force"),
|
|
117
136
|
) -> None:
|
|
118
137
|
path = _config_path()
|
|
@@ -125,6 +144,10 @@ def _build_app(typer: Any, yaml: Any, console: Any, err_console: Any) -> Any:
|
|
|
125
144
|
new_config: dict[str, Any] = {"store_path": store_path}
|
|
126
145
|
if retry_store_path is not None:
|
|
127
146
|
new_config["retry_store_path"] = retry_store_path
|
|
147
|
+
if dead_letter_ttl_seconds is not None:
|
|
148
|
+
new_config["dead_letter_ttl_seconds"] = dead_letter_ttl_seconds
|
|
149
|
+
if retry_record_ttl_seconds is not None:
|
|
150
|
+
new_config["retry_record_ttl_seconds"] = retry_record_ttl_seconds
|
|
128
151
|
_write_config(yaml, new_config)
|
|
129
152
|
config.clear()
|
|
130
153
|
config.update(new_config)
|
|
@@ -135,15 +158,23 @@ def _build_app(typer: Any, yaml: Any, console: Any, err_console: Any) -> Any:
|
|
|
135
158
|
key: str,
|
|
136
159
|
value: str,
|
|
137
160
|
) -> None:
|
|
138
|
-
if key not in {
|
|
161
|
+
if key not in {
|
|
162
|
+
"store_path",
|
|
163
|
+
"retry_store_path",
|
|
164
|
+
"dead_letter_ttl_seconds",
|
|
165
|
+
"retry_record_ttl_seconds",
|
|
166
|
+
}:
|
|
139
167
|
err_console.print(
|
|
140
168
|
"[red]unsupported config key:[/red] "
|
|
141
|
-
+
|
|
169
|
+
+ (
|
|
170
|
+
f"{key}; use store_path, retry_store_path, "
|
|
171
|
+
"dead_letter_ttl_seconds, or retry_record_ttl_seconds"
|
|
172
|
+
)
|
|
142
173
|
)
|
|
143
174
|
raise typer.Exit(1)
|
|
144
175
|
|
|
145
176
|
updated = dict(config)
|
|
146
|
-
updated[key] = value
|
|
177
|
+
updated[key] = _coerce_config_value(key, value)
|
|
147
178
|
_write_config(yaml, updated)
|
|
148
179
|
config.clear()
|
|
149
180
|
config.update(updated)
|
|
@@ -155,7 +186,9 @@ def _build_app(typer: Any, yaml: Any, console: Any, err_console: Any) -> Any:
|
|
|
155
186
|
value: str | None = typer.Option(None, "--value"),
|
|
156
187
|
store_path: str | None = typer.Option(None, "--store-path"),
|
|
157
188
|
delay: float = typer.Option(0.0, "--delay", min=0.0),
|
|
189
|
+
dedupe_key: str | None = typer.Option(None, "--dedupe-key"),
|
|
158
190
|
raw: bool = typer.Option(False, "--raw"),
|
|
191
|
+
log_events: bool = typer.Option(False, "--log-events"),
|
|
159
192
|
) -> None:
|
|
160
193
|
try:
|
|
161
194
|
payload_value = _read_value(value)
|
|
@@ -163,8 +196,17 @@ def _build_app(typer: Any, yaml: Any, console: Any, err_console: Any) -> Any:
|
|
|
163
196
|
except ValueError as exc:
|
|
164
197
|
raise typer.BadParameter(str(exc)) from exc
|
|
165
198
|
message = _queue(queue, _resolve_store_path(store_path, config)).put(
|
|
166
|
-
payload, delay=delay
|
|
199
|
+
payload, delay=delay, dedupe_key=dedupe_key
|
|
167
200
|
)
|
|
201
|
+
if log_events:
|
|
202
|
+
_emit_event(
|
|
203
|
+
err_console,
|
|
204
|
+
"queue.enqueue",
|
|
205
|
+
queue=queue,
|
|
206
|
+
message_id=message.id,
|
|
207
|
+
state=message.state,
|
|
208
|
+
available_at=message.available_at,
|
|
209
|
+
)
|
|
168
210
|
_print_json(console, _message_payload(message))
|
|
169
211
|
|
|
170
212
|
@queue_app.command("pop")
|
|
@@ -175,6 +217,7 @@ def _build_app(typer: Any, yaml: Any, console: Any, err_console: Any) -> Any:
|
|
|
175
217
|
worker_id: str | None = typer.Option(None, "--worker-id"),
|
|
176
218
|
block: bool = typer.Option(False, "--block"),
|
|
177
219
|
timeout: float | None = typer.Option(None, "--timeout", min=0.0),
|
|
220
|
+
log_events: bool = typer.Option(False, "--log-events"),
|
|
178
221
|
) -> None:
|
|
179
222
|
try:
|
|
180
223
|
message = _queue(
|
|
@@ -185,6 +228,16 @@ def _build_app(typer: Any, yaml: Any, console: Any, err_console: Any) -> Any:
|
|
|
185
228
|
except Empty as exc:
|
|
186
229
|
err_console.print("[yellow]queue is empty[/yellow]")
|
|
187
230
|
raise typer.Exit(1) from exc
|
|
231
|
+
if log_events:
|
|
232
|
+
_emit_event(
|
|
233
|
+
err_console,
|
|
234
|
+
"queue.lease",
|
|
235
|
+
queue=message.queue,
|
|
236
|
+
message_id=message.id,
|
|
237
|
+
leased_by=message.leased_by,
|
|
238
|
+
attempts=message.attempts,
|
|
239
|
+
leased_until=message.leased_until,
|
|
240
|
+
)
|
|
188
241
|
_print_json(console, _message_payload(message))
|
|
189
242
|
|
|
190
243
|
@queue_app.command("inspect")
|
|
@@ -192,6 +245,7 @@ def _build_app(typer: Any, yaml: Any, console: Any, err_console: Any) -> Any:
|
|
|
192
245
|
queue: str,
|
|
193
246
|
message_id: str,
|
|
194
247
|
store_path: str | None = typer.Option(None, "--store-path"),
|
|
248
|
+
json_output: bool = typer.Option(False, "--json"),
|
|
195
249
|
) -> None:
|
|
196
250
|
message = _queue(queue, _resolve_store_path(store_path, config)).inspect(
|
|
197
251
|
message_id
|
|
@@ -206,6 +260,7 @@ def _build_app(typer: Any, yaml: Any, console: Any, err_console: Any) -> Any:
|
|
|
206
260
|
queue: str,
|
|
207
261
|
message_id: str,
|
|
208
262
|
store_path: str | None = typer.Option(None, "--store-path"),
|
|
263
|
+
log_events: bool = typer.Option(False, "--log-events"),
|
|
209
264
|
) -> None:
|
|
210
265
|
_complete_message(
|
|
211
266
|
typer,
|
|
@@ -215,6 +270,7 @@ def _build_app(typer: Any, yaml: Any, console: Any, err_console: Any) -> Any:
|
|
|
215
270
|
_resolve_store_path(store_path, config),
|
|
216
271
|
message_id,
|
|
217
272
|
"ack",
|
|
273
|
+
log_events=log_events,
|
|
218
274
|
)
|
|
219
275
|
|
|
220
276
|
@queue_app.command("release")
|
|
@@ -223,6 +279,7 @@ def _build_app(typer: Any, yaml: Any, console: Any, err_console: Any) -> Any:
|
|
|
223
279
|
message_id: str,
|
|
224
280
|
store_path: str | None = typer.Option(None, "--store-path"),
|
|
225
281
|
delay: float = typer.Option(0.0, "--delay", min=0.0),
|
|
282
|
+
log_events: bool = typer.Option(False, "--log-events"),
|
|
226
283
|
) -> None:
|
|
227
284
|
_complete_message(
|
|
228
285
|
typer,
|
|
@@ -233,6 +290,7 @@ def _build_app(typer: Any, yaml: Any, console: Any, err_console: Any) -> Any:
|
|
|
233
290
|
message_id,
|
|
234
291
|
"release",
|
|
235
292
|
delay=delay,
|
|
293
|
+
log_events=log_events,
|
|
236
294
|
)
|
|
237
295
|
|
|
238
296
|
@queue_app.command("dead-letter")
|
|
@@ -240,6 +298,7 @@ def _build_app(typer: Any, yaml: Any, console: Any, err_console: Any) -> Any:
|
|
|
240
298
|
queue: str,
|
|
241
299
|
message_id: str,
|
|
242
300
|
store_path: str | None = typer.Option(None, "--store-path"),
|
|
301
|
+
log_events: bool = typer.Option(False, "--log-events"),
|
|
243
302
|
) -> None:
|
|
244
303
|
_complete_message(
|
|
245
304
|
typer,
|
|
@@ -249,6 +308,7 @@ def _build_app(typer: Any, yaml: Any, console: Any, err_console: Any) -> Any:
|
|
|
249
308
|
_resolve_store_path(store_path, config),
|
|
250
309
|
message_id,
|
|
251
310
|
"dead-letter",
|
|
311
|
+
log_events=log_events,
|
|
252
312
|
)
|
|
253
313
|
|
|
254
314
|
@queue_app.command("size")
|
|
@@ -264,6 +324,8 @@ def _build_app(typer: Any, yaml: Any, console: Any, err_console: Any) -> Any:
|
|
|
264
324
|
store_path: str | None = typer.Option(None, "--store-path"),
|
|
265
325
|
watch: bool = typer.Option(False, "--watch"),
|
|
266
326
|
interval: float = typer.Option(1.0, "--interval", min=0.001),
|
|
327
|
+
stale_after: float | None = typer.Option(None, "--stale-after", min=0.0),
|
|
328
|
+
json_output: bool = typer.Option(False, "--json"),
|
|
267
329
|
) -> None:
|
|
268
330
|
persistent_queue = _queue(queue, _resolve_store_path(store_path, config))
|
|
269
331
|
with _shutdown_state() as shutdown:
|
|
@@ -272,9 +334,33 @@ def _build_app(typer: Any, yaml: Any, console: Any, err_console: Any) -> Any:
|
|
|
272
334
|
console=console,
|
|
273
335
|
watch=watch,
|
|
274
336
|
interval=interval,
|
|
337
|
+
stale_after=stale_after,
|
|
275
338
|
shutdown=shutdown,
|
|
276
339
|
)
|
|
277
340
|
|
|
341
|
+
@queue_app.command("health")
|
|
342
|
+
def queue_health(
|
|
343
|
+
queue: str,
|
|
344
|
+
store_path: str | None = typer.Option(None, "--store-path"),
|
|
345
|
+
stale_after: float = typer.Option(120.0, "--stale-after", min=0.0),
|
|
346
|
+
json_output: bool = typer.Option(False, "--json"),
|
|
347
|
+
) -> None:
|
|
348
|
+
persistent_queue = _queue(queue, _resolve_store_path(store_path, config))
|
|
349
|
+
stats = persistent_queue.stats()
|
|
350
|
+
worker_health = _worker_health_summary(
|
|
351
|
+
stats.last_seen_by_worker_id, stale_after=stale_after
|
|
352
|
+
)
|
|
353
|
+
_print_json(
|
|
354
|
+
console,
|
|
355
|
+
{
|
|
356
|
+
"queue": queue,
|
|
357
|
+
"stats": stats.as_dict(),
|
|
358
|
+
"worker_health": worker_health,
|
|
359
|
+
"dead_letters": _dead_letter_summary(persistent_queue.dead_letters()),
|
|
360
|
+
"retention": _retention_settings(config),
|
|
361
|
+
},
|
|
362
|
+
)
|
|
363
|
+
|
|
278
364
|
@queue_app.command("dead")
|
|
279
365
|
def queue_dead(
|
|
280
366
|
queue: str,
|
|
@@ -282,8 +368,56 @@ def _build_app(typer: Any, yaml: Any, console: Any, err_console: Any) -> Any:
|
|
|
282
368
|
limit: int | None = typer.Option(None, "--limit", min=0),
|
|
283
369
|
watch: bool = typer.Option(False, "--watch"),
|
|
284
370
|
interval: float = typer.Option(1.0, "--interval", min=0.001),
|
|
371
|
+
json_output: bool = typer.Option(False, "--json"),
|
|
372
|
+
summary: bool = typer.Option(False, "--summary"),
|
|
373
|
+
min_attempts: int | None = typer.Option(None, "--min-attempts", min=0),
|
|
374
|
+
max_attempts: int | None = typer.Option(None, "--max-attempts", min=0),
|
|
375
|
+
error_contains: str | None = typer.Option(None, "--error-contains"),
|
|
376
|
+
failed_within: float | None = typer.Option(None, "--failed-within", min=0.0),
|
|
377
|
+
dry_run: bool = typer.Option(False, "--dry-run"),
|
|
378
|
+
prune_older_than: float | None = typer.Option(
|
|
379
|
+
None, "--prune-older-than", min=0.0
|
|
380
|
+
),
|
|
285
381
|
) -> None:
|
|
286
382
|
persistent_queue = _queue(queue, _resolve_store_path(store_path, config))
|
|
383
|
+
if prune_older_than is not None or dry_run:
|
|
384
|
+
older_than = _resolve_dead_letter_ttl(prune_older_than, config)
|
|
385
|
+
if older_than is None:
|
|
386
|
+
err_console.print(
|
|
387
|
+
"[red]pass --prune-older-than or configure "
|
|
388
|
+
"dead_letter_ttl_seconds[/red]"
|
|
389
|
+
)
|
|
390
|
+
raise typer.Exit(1)
|
|
391
|
+
if any(
|
|
392
|
+
(
|
|
393
|
+
watch,
|
|
394
|
+
summary,
|
|
395
|
+
limit is not None,
|
|
396
|
+
min_attempts is not None,
|
|
397
|
+
max_attempts is not None,
|
|
398
|
+
error_contains is not None,
|
|
399
|
+
failed_within is not None,
|
|
400
|
+
)
|
|
401
|
+
):
|
|
402
|
+
err_console.print(
|
|
403
|
+
"[red]pass --prune-older-than on its own; "
|
|
404
|
+
+ "it cannot be combined with list or watch options[/red]"
|
|
405
|
+
)
|
|
406
|
+
raise typer.Exit(1)
|
|
407
|
+
deleted = (
|
|
408
|
+
persistent_queue.count_dead_letters_older_than(older_than=older_than)
|
|
409
|
+
if dry_run
|
|
410
|
+
else persistent_queue.prune_dead_letters(older_than=older_than)
|
|
411
|
+
)
|
|
412
|
+
_print_json(
|
|
413
|
+
console,
|
|
414
|
+
{
|
|
415
|
+
"dry_run": dry_run,
|
|
416
|
+
"older_than": older_than,
|
|
417
|
+
"would_delete" if dry_run else "deleted": deleted,
|
|
418
|
+
},
|
|
419
|
+
)
|
|
420
|
+
return
|
|
287
421
|
with _shutdown_state() as shutdown:
|
|
288
422
|
_print_dead_letters(
|
|
289
423
|
persistent_queue,
|
|
@@ -292,7 +426,45 @@ def _build_app(typer: Any, yaml: Any, console: Any, err_console: Any) -> Any:
|
|
|
292
426
|
watch=watch,
|
|
293
427
|
interval=interval,
|
|
294
428
|
shutdown=shutdown,
|
|
429
|
+
summary=summary,
|
|
430
|
+
min_attempts=min_attempts,
|
|
431
|
+
max_attempts=max_attempts,
|
|
432
|
+
error_contains=error_contains,
|
|
433
|
+
failed_within=failed_within,
|
|
434
|
+
)
|
|
435
|
+
|
|
436
|
+
@retry_app.command("prune")
|
|
437
|
+
def retry_prune(
|
|
438
|
+
older_than: float | None = typer.Option(None, "--older-than", min=0.0),
|
|
439
|
+
retry_store_path: str | None = typer.Option(None, "--retry-store-path"),
|
|
440
|
+
dry_run: bool = typer.Option(False, "--dry-run"),
|
|
441
|
+
) -> None:
|
|
442
|
+
store = SQLiteAttemptStore(_resolve_retry_store_path(retry_store_path, config))
|
|
443
|
+
try:
|
|
444
|
+
resolved_older_than = _resolve_retry_record_ttl(older_than, config)
|
|
445
|
+
if resolved_older_than is None:
|
|
446
|
+
err_console.print(
|
|
447
|
+
"[red]pass --older-than or configure retry_record_ttl_seconds[/red]"
|
|
448
|
+
)
|
|
449
|
+
raise typer.Exit(1)
|
|
450
|
+
now = time.time()
|
|
451
|
+
deleted = (
|
|
452
|
+
store.count_exhausted_older_than(
|
|
453
|
+
older_than=resolved_older_than, now=now
|
|
454
|
+
)
|
|
455
|
+
if dry_run
|
|
456
|
+
else store.prune_exhausted(older_than=resolved_older_than, now=now)
|
|
295
457
|
)
|
|
458
|
+
finally:
|
|
459
|
+
store.close()
|
|
460
|
+
_print_json(
|
|
461
|
+
console,
|
|
462
|
+
{
|
|
463
|
+
"dry_run": dry_run,
|
|
464
|
+
"older_than": resolved_older_than,
|
|
465
|
+
"would_delete" if dry_run else "deleted": deleted,
|
|
466
|
+
},
|
|
467
|
+
)
|
|
296
468
|
|
|
297
469
|
@queue_app.command("requeue-dead")
|
|
298
470
|
def queue_requeue_dead(
|
|
@@ -301,6 +473,7 @@ def _build_app(typer: Any, yaml: Any, console: Any, err_console: Any) -> Any:
|
|
|
301
473
|
store_path: str | None = typer.Option(None, "--store-path"),
|
|
302
474
|
delay: float = typer.Option(0.0, "--delay", min=0.0),
|
|
303
475
|
all_: bool = typer.Option(False, "--all"),
|
|
476
|
+
log_events: bool = typer.Option(False, "--log-events"),
|
|
304
477
|
) -> None:
|
|
305
478
|
persistent_queue = _queue(queue, _resolve_store_path(store_path, config))
|
|
306
479
|
if all_:
|
|
@@ -314,6 +487,15 @@ def _build_app(typer: Any, yaml: Any, console: Any, err_console: Any) -> Any:
|
|
|
314
487
|
for message in messages:
|
|
315
488
|
if persistent_queue.requeue_dead(message, delay=delay):
|
|
316
489
|
requeued += 1
|
|
490
|
+
if log_events:
|
|
491
|
+
_emit_event(
|
|
492
|
+
err_console,
|
|
493
|
+
"queue.requeue",
|
|
494
|
+
queue=queue,
|
|
495
|
+
all=True,
|
|
496
|
+
requeued=requeued,
|
|
497
|
+
delay=delay,
|
|
498
|
+
)
|
|
317
499
|
_print_json(console, {"requeued": requeued})
|
|
318
500
|
return
|
|
319
501
|
if message_id is None:
|
|
@@ -325,14 +507,26 @@ def _build_app(typer: Any, yaml: Any, console: Any, err_console: Any) -> Any:
|
|
|
325
507
|
if not persistent_queue.requeue_dead(message, delay=delay):
|
|
326
508
|
err_console.print(f"[red]dead-letter message not found:[/red] {message_id}")
|
|
327
509
|
raise typer.Exit(1)
|
|
510
|
+
if log_events:
|
|
511
|
+
_emit_event(
|
|
512
|
+
err_console,
|
|
513
|
+
"queue.requeue",
|
|
514
|
+
queue=queue,
|
|
515
|
+
message_id=message_id,
|
|
516
|
+
delay=delay,
|
|
517
|
+
)
|
|
328
518
|
_print_json(console, {"id": message_id, "state": "requeued"})
|
|
329
519
|
|
|
330
520
|
@queue_app.command("purge")
|
|
331
521
|
def queue_purge(
|
|
332
522
|
queue: str,
|
|
333
523
|
store_path: str | None = typer.Option(None, "--store-path"),
|
|
524
|
+
log_events: bool = typer.Option(False, "--log-events"),
|
|
334
525
|
) -> None:
|
|
335
|
-
|
|
526
|
+
deleted = _queue(queue, _resolve_store_path(store_path, config)).purge()
|
|
527
|
+
if log_events:
|
|
528
|
+
_emit_event(err_console, "queue.purge", queue=queue, deleted=deleted)
|
|
529
|
+
console.print(deleted)
|
|
336
530
|
|
|
337
531
|
@queue_app.command("process")
|
|
338
532
|
def queue_process(
|
|
@@ -349,11 +543,27 @@ def _build_app(typer: Any, yaml: Any, console: Any, err_console: Any) -> Any:
|
|
|
349
543
|
timeout: float | None = typer.Option(None, "--timeout", min=0.0),
|
|
350
544
|
idle_sleep: float = typer.Option(1.0, "--idle-sleep", min=0.001),
|
|
351
545
|
release_delay: float = typer.Option(0.0, "--release-delay", min=0.0),
|
|
546
|
+
min_interval: float = typer.Option(0.0, "--min-interval", min=0.0),
|
|
547
|
+
circuit_breaker_failures: int = typer.Option(
|
|
548
|
+
0, "--circuit-breaker-failures", min=0
|
|
549
|
+
),
|
|
550
|
+
circuit_breaker_cooldown: float = typer.Option(
|
|
551
|
+
0.0, "--circuit-breaker-cooldown", min=0.0
|
|
552
|
+
),
|
|
352
553
|
dead_letter_on_exhaustion: bool = typer.Option(
|
|
353
554
|
True,
|
|
354
555
|
"--dead-letter-on-exhaustion/--release-on-exhaustion",
|
|
355
556
|
),
|
|
557
|
+
log_events: bool = typer.Option(False, "--log-events"),
|
|
356
558
|
) -> None:
|
|
559
|
+
try:
|
|
560
|
+
_validate_worker_loop_options(
|
|
561
|
+
max_jobs=max_jobs,
|
|
562
|
+
forever=forever,
|
|
563
|
+
)
|
|
564
|
+
except ValueError as exc:
|
|
565
|
+
err_console.print(f"[red]{exc}[/red]")
|
|
566
|
+
raise typer.Exit(1) from exc
|
|
357
567
|
try:
|
|
358
568
|
worker = _load_callable(handler)
|
|
359
569
|
except (AttributeError, ImportError, TypeError, ValueError) as exc:
|
|
@@ -364,6 +574,13 @@ def _build_app(typer: Any, yaml: Any, console: Any, err_console: Any) -> Any:
|
|
|
364
574
|
_resolve_store_path(store_path, config),
|
|
365
575
|
lease_timeout=lease_timeout,
|
|
366
576
|
)
|
|
577
|
+
if worker_id is not None:
|
|
578
|
+
persistent_queue.record_worker_heartbeat(worker_id)
|
|
579
|
+
worker_policy = PersistentWorkerConfig(
|
|
580
|
+
min_interval=min_interval,
|
|
581
|
+
circuit_breaker_failures=circuit_breaker_failures,
|
|
582
|
+
circuit_breaker_cooldown=circuit_breaker_cooldown,
|
|
583
|
+
)
|
|
367
584
|
with _shutdown_state() as shutdown:
|
|
368
585
|
exit_code = _process_queue_messages(
|
|
369
586
|
persistent_queue,
|
|
@@ -371,6 +588,7 @@ def _build_app(typer: Any, yaml: Any, console: Any, err_console: Any) -> Any:
|
|
|
371
588
|
console=console,
|
|
372
589
|
err_console=err_console,
|
|
373
590
|
shutdown=shutdown,
|
|
591
|
+
worker_policy=worker_policy,
|
|
374
592
|
retry_store_path=_resolve_retry_store_path(retry_store_path, config),
|
|
375
593
|
max_jobs=max_jobs,
|
|
376
594
|
forever=forever,
|
|
@@ -381,6 +599,8 @@ def _build_app(typer: Any, yaml: Any, console: Any, err_console: Any) -> Any:
|
|
|
381
599
|
idle_sleep=idle_sleep,
|
|
382
600
|
release_delay=release_delay,
|
|
383
601
|
dead_letter_on_exhaustion=dead_letter_on_exhaustion,
|
|
602
|
+
log_events=log_events,
|
|
603
|
+
mode="process",
|
|
384
604
|
)
|
|
385
605
|
if exit_code:
|
|
386
606
|
raise typer.Exit(exit_code)
|
|
@@ -403,16 +623,39 @@ def _build_app(typer: Any, yaml: Any, console: Any, err_console: Any) -> Any:
|
|
|
403
623
|
timeout: float | None = typer.Option(None, "--timeout", min=0.0),
|
|
404
624
|
idle_sleep: float = typer.Option(1.0, "--idle-sleep", min=0.001),
|
|
405
625
|
release_delay: float = typer.Option(0.0, "--release-delay", min=0.0),
|
|
626
|
+
min_interval: float = typer.Option(0.0, "--min-interval", min=0.0),
|
|
627
|
+
circuit_breaker_failures: int = typer.Option(
|
|
628
|
+
0, "--circuit-breaker-failures", min=0
|
|
629
|
+
),
|
|
630
|
+
circuit_breaker_cooldown: float = typer.Option(
|
|
631
|
+
0.0, "--circuit-breaker-cooldown", min=0.0
|
|
632
|
+
),
|
|
406
633
|
dead_letter_on_exhaustion: bool = typer.Option(
|
|
407
634
|
True,
|
|
408
635
|
"--dead-letter-on-exhaustion/--release-on-exhaustion",
|
|
409
636
|
),
|
|
637
|
+
log_events: bool = typer.Option(False, "--log-events"),
|
|
410
638
|
) -> None:
|
|
639
|
+
try:
|
|
640
|
+
_validate_worker_loop_options(
|
|
641
|
+
max_jobs=max_jobs,
|
|
642
|
+
forever=forever,
|
|
643
|
+
)
|
|
644
|
+
except ValueError as exc:
|
|
645
|
+
err_console.print(f"[red]{exc}[/red]")
|
|
646
|
+
raise typer.Exit(1) from exc
|
|
411
647
|
persistent_queue = _queue(
|
|
412
648
|
queue,
|
|
413
649
|
_resolve_store_path(store_path, config),
|
|
414
650
|
lease_timeout=lease_timeout,
|
|
415
651
|
)
|
|
652
|
+
if worker_id is not None:
|
|
653
|
+
persistent_queue.record_worker_heartbeat(worker_id)
|
|
654
|
+
worker_policy = PersistentWorkerConfig(
|
|
655
|
+
min_interval=min_interval,
|
|
656
|
+
circuit_breaker_failures=circuit_breaker_failures,
|
|
657
|
+
circuit_breaker_cooldown=circuit_breaker_cooldown,
|
|
658
|
+
)
|
|
416
659
|
with _shutdown_state() as shutdown:
|
|
417
660
|
exit_code = _process_queue_messages(
|
|
418
661
|
persistent_queue,
|
|
@@ -420,6 +663,7 @@ def _build_app(typer: Any, yaml: Any, console: Any, err_console: Any) -> Any:
|
|
|
420
663
|
console=console,
|
|
421
664
|
err_console=err_console,
|
|
422
665
|
shutdown=shutdown,
|
|
666
|
+
worker_policy=worker_policy,
|
|
423
667
|
retry_store_path=_resolve_retry_store_path(retry_store_path, config),
|
|
424
668
|
max_jobs=max_jobs,
|
|
425
669
|
forever=forever,
|
|
@@ -430,6 +674,8 @@ def _build_app(typer: Any, yaml: Any, console: Any, err_console: Any) -> Any:
|
|
|
430
674
|
idle_sleep=idle_sleep,
|
|
431
675
|
release_delay=release_delay,
|
|
432
676
|
dead_letter_on_exhaustion=dead_letter_on_exhaustion,
|
|
677
|
+
log_events=log_events,
|
|
678
|
+
mode="exec",
|
|
433
679
|
)
|
|
434
680
|
if exit_code:
|
|
435
681
|
raise typer.Exit(exit_code)
|
|
@@ -453,6 +699,7 @@ def _complete_message(
|
|
|
453
699
|
action: str,
|
|
454
700
|
*,
|
|
455
701
|
delay: float = 0.0,
|
|
702
|
+
log_events: bool = False,
|
|
456
703
|
) -> None:
|
|
457
704
|
persistent_queue = _queue(queue, store_path)
|
|
458
705
|
message = QueueMessage(id=message_id, value=None, queue=queue)
|
|
@@ -468,6 +715,14 @@ def _complete_message(
|
|
|
468
715
|
if not completed:
|
|
469
716
|
err_console.print(f"[red]message not found:[/red] {message_id}")
|
|
470
717
|
raise typer.Exit(1)
|
|
718
|
+
if log_events:
|
|
719
|
+
_emit_event(
|
|
720
|
+
err_console,
|
|
721
|
+
f"queue.{action.replace('-', '_')}",
|
|
722
|
+
queue=queue,
|
|
723
|
+
message_id=message_id,
|
|
724
|
+
delay=delay if action == "release" else None,
|
|
725
|
+
)
|
|
471
726
|
_print_json(console, {"id": message_id, "state": action})
|
|
472
727
|
|
|
473
728
|
|
|
@@ -481,6 +736,9 @@ def _process_message(
|
|
|
481
736
|
max_tries: int,
|
|
482
737
|
release_delay: float,
|
|
483
738
|
dead_letter_on_exhaustion: bool,
|
|
739
|
+
log_events: bool = False,
|
|
740
|
+
mode: str = "process",
|
|
741
|
+
err_console: Any | None = None,
|
|
484
742
|
) -> _ProcessResult:
|
|
485
743
|
retry_kwargs: dict[str, Any] = {
|
|
486
744
|
"key": message.id,
|
|
@@ -507,24 +765,69 @@ def _process_message(
|
|
|
507
765
|
dead_letter_on_exhaustion=dead_letter_on_exhaustion,
|
|
508
766
|
error=exc,
|
|
509
767
|
)
|
|
510
|
-
|
|
768
|
+
if log_events and err_console is not None:
|
|
769
|
+
_emit_event(
|
|
770
|
+
err_console,
|
|
771
|
+
f"{mode}.dead_letter",
|
|
772
|
+
queue=message.queue,
|
|
773
|
+
message_id=message.id,
|
|
774
|
+
attempts=message.attempts,
|
|
775
|
+
leased_by=message.leased_by,
|
|
776
|
+
last_error=last_error,
|
|
777
|
+
)
|
|
778
|
+
return _ProcessResult(
|
|
779
|
+
processed=False,
|
|
780
|
+
last_error=last_error,
|
|
781
|
+
final_state="dead-letter",
|
|
782
|
+
permanent_failure=False,
|
|
783
|
+
)
|
|
511
784
|
except Exception as exc:
|
|
512
785
|
last_error = _error_payload(exc)
|
|
513
786
|
record = retryer.get_record(message.id)
|
|
514
787
|
exhausted = record is not None and record.exhausted
|
|
515
|
-
|
|
788
|
+
permanent_failure = is_permanent_failure(exc)
|
|
789
|
+
if permanent_failure or exhausted:
|
|
516
790
|
_ = _finish_failed_message(
|
|
517
791
|
queue,
|
|
518
792
|
message,
|
|
519
793
|
release_delay=release_delay,
|
|
520
|
-
dead_letter_on_exhaustion=
|
|
794
|
+
dead_letter_on_exhaustion=True
|
|
795
|
+
if permanent_failure
|
|
796
|
+
else dead_letter_on_exhaustion,
|
|
521
797
|
error=exc,
|
|
522
798
|
)
|
|
799
|
+
final_state = "dead-letter"
|
|
523
800
|
else:
|
|
524
801
|
_ = queue.release(message, delay=release_delay, error=exc)
|
|
525
|
-
|
|
802
|
+
final_state = "release"
|
|
803
|
+
if log_events and err_console is not None:
|
|
804
|
+
_emit_event(
|
|
805
|
+
err_console,
|
|
806
|
+
f"{mode}.{final_state.replace('-', '_')}",
|
|
807
|
+
queue=message.queue,
|
|
808
|
+
message_id=message.id,
|
|
809
|
+
attempts=message.attempts,
|
|
810
|
+
leased_by=message.leased_by,
|
|
811
|
+
last_error=last_error,
|
|
812
|
+
)
|
|
813
|
+
return _ProcessResult(
|
|
814
|
+
processed=False,
|
|
815
|
+
last_error=last_error,
|
|
816
|
+
final_state=final_state,
|
|
817
|
+
permanent_failure=permanent_failure,
|
|
818
|
+
)
|
|
526
819
|
|
|
527
|
-
|
|
820
|
+
acked = queue.ack(message)
|
|
821
|
+
if log_events and err_console is not None:
|
|
822
|
+
_emit_event(
|
|
823
|
+
err_console,
|
|
824
|
+
f"{mode}.ack",
|
|
825
|
+
queue=message.queue,
|
|
826
|
+
message_id=message.id,
|
|
827
|
+
attempts=message.attempts,
|
|
828
|
+
leased_by=message.leased_by,
|
|
829
|
+
)
|
|
830
|
+
return _ProcessResult(processed=acked, final_state="acked")
|
|
528
831
|
finally:
|
|
529
832
|
if owned_retry_store is not None:
|
|
530
833
|
owned_retry_store.close()
|
|
@@ -537,6 +840,7 @@ def _process_queue_messages(
|
|
|
537
840
|
console: Any,
|
|
538
841
|
err_console: Any,
|
|
539
842
|
shutdown: _ShutdownState,
|
|
843
|
+
worker_policy: PersistentWorkerConfig | None = None,
|
|
540
844
|
retry_store_path: str | None,
|
|
541
845
|
max_jobs: int,
|
|
542
846
|
forever: bool,
|
|
@@ -547,8 +851,14 @@ def _process_queue_messages(
|
|
|
547
851
|
idle_sleep: float,
|
|
548
852
|
release_delay: float,
|
|
549
853
|
dead_letter_on_exhaustion: bool,
|
|
854
|
+
log_events: bool = False,
|
|
855
|
+
mode: str = "process",
|
|
550
856
|
) -> int:
|
|
551
857
|
processed = 0
|
|
858
|
+
policy_state = WorkerPolicyState()
|
|
859
|
+
resolved_policy = (
|
|
860
|
+
worker_policy if worker_policy is not None else PersistentWorkerConfig()
|
|
861
|
+
)
|
|
552
862
|
|
|
553
863
|
while forever or processed < max_jobs:
|
|
554
864
|
if shutdown.requested:
|
|
@@ -556,6 +866,10 @@ def _process_queue_messages(
|
|
|
556
866
|
_print_json(console, {"state": "stopped", "processed": processed})
|
|
557
867
|
return 0
|
|
558
868
|
|
|
869
|
+
if worker_id is not None:
|
|
870
|
+
queue.record_worker_heartbeat(worker_id)
|
|
871
|
+
_sleep_for_policy(policy_state, resolved_policy)
|
|
872
|
+
|
|
559
873
|
try:
|
|
560
874
|
message = queue.get_message(
|
|
561
875
|
block=block,
|
|
@@ -568,10 +882,22 @@ def _process_queue_messages(
|
|
|
568
882
|
time.sleep(idle_sleep)
|
|
569
883
|
continue
|
|
570
884
|
if processed == 0:
|
|
571
|
-
err_console
|
|
885
|
+
if err_console is not None:
|
|
886
|
+
err_console.print("[yellow]queue is empty[/yellow]")
|
|
572
887
|
return 1
|
|
573
888
|
break
|
|
574
889
|
|
|
890
|
+
if log_events and err_console is not None:
|
|
891
|
+
_emit_event(
|
|
892
|
+
err_console,
|
|
893
|
+
f"{mode}.lease",
|
|
894
|
+
queue=message.queue,
|
|
895
|
+
message_id=message.id,
|
|
896
|
+
leased_by=message.leased_by,
|
|
897
|
+
attempts=message.attempts,
|
|
898
|
+
leased_until=message.leased_until,
|
|
899
|
+
)
|
|
900
|
+
|
|
575
901
|
result = _process_message(
|
|
576
902
|
queue,
|
|
577
903
|
message,
|
|
@@ -580,16 +906,39 @@ def _process_queue_messages(
|
|
|
580
906
|
max_tries=max_tries,
|
|
581
907
|
release_delay=release_delay,
|
|
582
908
|
dead_letter_on_exhaustion=dead_letter_on_exhaustion,
|
|
909
|
+
log_events=log_events,
|
|
910
|
+
mode=mode,
|
|
911
|
+
err_console=err_console,
|
|
583
912
|
)
|
|
584
913
|
if result.processed:
|
|
585
914
|
processed += 1
|
|
915
|
+
_record_success(policy_state)
|
|
916
|
+
if worker_id is not None:
|
|
917
|
+
queue.record_worker_heartbeat(worker_id)
|
|
586
918
|
_print_json(console, {"id": message.id, "state": "acked"})
|
|
587
919
|
continue
|
|
588
920
|
|
|
921
|
+
_record_failure(
|
|
922
|
+
policy_state,
|
|
923
|
+
resolved_policy,
|
|
924
|
+
permanent=result.permanent_failure,
|
|
925
|
+
)
|
|
926
|
+
|
|
927
|
+
payload = (
|
|
928
|
+
_message_payload(current_message)
|
|
929
|
+
if (current_message := queue.inspect(message.id)) is not None
|
|
930
|
+
else {"id": message.id, "queue": message.queue}
|
|
931
|
+
)
|
|
589
932
|
_print_json(
|
|
590
933
|
console,
|
|
591
|
-
{
|
|
934
|
+
{
|
|
935
|
+
**payload,
|
|
936
|
+
"state": "failed",
|
|
937
|
+
"last_error": result.last_error,
|
|
938
|
+
},
|
|
592
939
|
)
|
|
940
|
+
if worker_id is not None:
|
|
941
|
+
queue.record_worker_heartbeat(worker_id)
|
|
593
942
|
if forever:
|
|
594
943
|
continue
|
|
595
944
|
return 1
|
|
@@ -605,10 +954,24 @@ def _print_dead_letters(
|
|
|
605
954
|
watch: bool,
|
|
606
955
|
interval: float,
|
|
607
956
|
shutdown: _ShutdownState,
|
|
957
|
+
summary: bool,
|
|
958
|
+
min_attempts: int | None,
|
|
959
|
+
max_attempts: int | None,
|
|
960
|
+
error_contains: str | None,
|
|
961
|
+
failed_within: float | None,
|
|
608
962
|
) -> None:
|
|
609
963
|
while True:
|
|
610
|
-
messages =
|
|
611
|
-
|
|
964
|
+
messages = _filter_dead_letters(
|
|
965
|
+
queue.dead_letters(limit=limit),
|
|
966
|
+
min_attempts=min_attempts,
|
|
967
|
+
max_attempts=max_attempts,
|
|
968
|
+
error_contains=error_contains,
|
|
969
|
+
failed_within=failed_within,
|
|
970
|
+
)
|
|
971
|
+
if summary:
|
|
972
|
+
_print_json(console, _dead_letter_summary(messages))
|
|
973
|
+
else:
|
|
974
|
+
_print_json(console, [_message_payload(message) for message in messages])
|
|
612
975
|
if not watch:
|
|
613
976
|
return
|
|
614
977
|
if shutdown.requested:
|
|
@@ -624,10 +987,17 @@ def _print_queue_stats(
|
|
|
624
987
|
console: Any,
|
|
625
988
|
watch: bool,
|
|
626
989
|
interval: float,
|
|
990
|
+
stale_after: float | None,
|
|
627
991
|
shutdown: _ShutdownState,
|
|
628
992
|
) -> None:
|
|
629
993
|
while True:
|
|
630
|
-
|
|
994
|
+
stats = queue.stats()
|
|
995
|
+
payload = stats.as_dict()
|
|
996
|
+
if stale_after is not None:
|
|
997
|
+
payload["worker_health"] = _worker_health_summary(
|
|
998
|
+
stats.last_seen_by_worker_id, stale_after=stale_after
|
|
999
|
+
)
|
|
1000
|
+
_print_json(console, payload)
|
|
631
1001
|
if not watch:
|
|
632
1002
|
return
|
|
633
1003
|
if shutdown.requested:
|
|
@@ -643,6 +1013,11 @@ def _poll_timeout(forever: bool, block: bool, timeout: float | None) -> float |
|
|
|
643
1013
|
return timeout
|
|
644
1014
|
|
|
645
1015
|
|
|
1016
|
+
def _validate_worker_loop_options(*, max_jobs: int, forever: bool) -> None:
|
|
1017
|
+
if forever and max_jobs != 1:
|
|
1018
|
+
raise ValueError("pass either --forever or --max-jobs, not both")
|
|
1019
|
+
|
|
1020
|
+
|
|
646
1021
|
@contextmanager
|
|
647
1022
|
def _shutdown_state() -> Iterator[_ShutdownState]:
|
|
648
1023
|
state = _ShutdownState()
|
|
@@ -757,6 +1132,40 @@ def _resolve_retry_store_path(explicit: str | None, config: dict[str, Any]) -> s
|
|
|
757
1132
|
return str(value)
|
|
758
1133
|
|
|
759
1134
|
|
|
1135
|
+
def _resolve_dead_letter_ttl(
|
|
1136
|
+
explicit: float | None, config: dict[str, Any]
|
|
1137
|
+
) -> float | None:
|
|
1138
|
+
value = explicit if explicit is not None else config.get("dead_letter_ttl_seconds")
|
|
1139
|
+
if value is None:
|
|
1140
|
+
return None
|
|
1141
|
+
return float(value)
|
|
1142
|
+
|
|
1143
|
+
|
|
1144
|
+
def _resolve_retry_record_ttl(
|
|
1145
|
+
explicit: float | None, config: dict[str, Any]
|
|
1146
|
+
) -> float | None:
|
|
1147
|
+
value = explicit if explicit is not None else config.get("retry_record_ttl_seconds")
|
|
1148
|
+
if value is None:
|
|
1149
|
+
return None
|
|
1150
|
+
return float(value)
|
|
1151
|
+
|
|
1152
|
+
|
|
1153
|
+
def _retention_settings(config: dict[str, Any]) -> dict[str, Any]:
|
|
1154
|
+
return {
|
|
1155
|
+
"dead_letter_ttl_seconds": config.get("dead_letter_ttl_seconds"),
|
|
1156
|
+
"retry_record_ttl_seconds": config.get("retry_record_ttl_seconds"),
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
|
|
1160
|
+
def _coerce_config_value(key: str, value: str) -> str | float:
|
|
1161
|
+
if key in {"dead_letter_ttl_seconds", "retry_record_ttl_seconds"}:
|
|
1162
|
+
ttl = float(value)
|
|
1163
|
+
if ttl < 0:
|
|
1164
|
+
raise ValueError(f"{key} must be greater than or equal to zero")
|
|
1165
|
+
return ttl
|
|
1166
|
+
return value
|
|
1167
|
+
|
|
1168
|
+
|
|
760
1169
|
def _load_callable(spec: str) -> Callable[[Any], Any]:
|
|
761
1170
|
module_name, separator, attribute = spec.partition(":")
|
|
762
1171
|
if not separator or not module_name or not attribute:
|
|
@@ -813,6 +1222,8 @@ def _message_payload(message: QueueMessage) -> dict[str, Any]:
|
|
|
813
1222
|
"leased_by": message.leased_by,
|
|
814
1223
|
"last_error": message.last_error,
|
|
815
1224
|
"failed_at": message.failed_at,
|
|
1225
|
+
"dedupe_key": message.dedupe_key,
|
|
1226
|
+
"attempt_history": message.attempt_history,
|
|
816
1227
|
}
|
|
817
1228
|
|
|
818
1229
|
|
|
@@ -820,6 +1231,108 @@ def _json_dumps(value: Any) -> str:
|
|
|
820
1231
|
return json.dumps(value, ensure_ascii=False, sort_keys=True)
|
|
821
1232
|
|
|
822
1233
|
|
|
1234
|
+
def _filter_dead_letters(
|
|
1235
|
+
messages: list[QueueMessage],
|
|
1236
|
+
*,
|
|
1237
|
+
min_attempts: int | None,
|
|
1238
|
+
max_attempts: int | None,
|
|
1239
|
+
error_contains: str | None,
|
|
1240
|
+
failed_within: float | None,
|
|
1241
|
+
) -> list[QueueMessage]:
|
|
1242
|
+
now = time.time()
|
|
1243
|
+
error_filter = error_contains.lower() if error_contains is not None else None
|
|
1244
|
+
cutoff = None if failed_within is None else now - failed_within
|
|
1245
|
+
filtered: list[QueueMessage] = []
|
|
1246
|
+
for message in messages:
|
|
1247
|
+
if min_attempts is not None and message.attempts < min_attempts:
|
|
1248
|
+
continue
|
|
1249
|
+
if max_attempts is not None and message.attempts > max_attempts:
|
|
1250
|
+
continue
|
|
1251
|
+
if cutoff is not None:
|
|
1252
|
+
failed_at = message.failed_at
|
|
1253
|
+
if failed_at is None or failed_at < cutoff:
|
|
1254
|
+
continue
|
|
1255
|
+
if error_filter is not None:
|
|
1256
|
+
last_error = message.last_error or {}
|
|
1257
|
+
haystack = " ".join(
|
|
1258
|
+
str(part)
|
|
1259
|
+
for part in (
|
|
1260
|
+
last_error.get("type"),
|
|
1261
|
+
last_error.get("message"),
|
|
1262
|
+
last_error.get("command"),
|
|
1263
|
+
last_error.get("stderr"),
|
|
1264
|
+
)
|
|
1265
|
+
if part is not None
|
|
1266
|
+
).lower()
|
|
1267
|
+
if error_filter not in haystack:
|
|
1268
|
+
continue
|
|
1269
|
+
filtered.append(message)
|
|
1270
|
+
return filtered
|
|
1271
|
+
|
|
1272
|
+
|
|
1273
|
+
def _dead_letter_summary(messages: list[QueueMessage]) -> dict[str, Any]:
|
|
1274
|
+
by_error_type = Counter[str]()
|
|
1275
|
+
by_attempts = Counter[str]()
|
|
1276
|
+
by_worker_id = Counter[str]()
|
|
1277
|
+
failed_ats: list[float] = []
|
|
1278
|
+
for message in messages:
|
|
1279
|
+
error_type = "None"
|
|
1280
|
+
if message.last_error is not None:
|
|
1281
|
+
error_type = str(message.last_error.get("type") or "None")
|
|
1282
|
+
by_error_type[error_type] += 1
|
|
1283
|
+
by_attempts[str(message.attempts)] += 1
|
|
1284
|
+
worker_id = _last_attempt_worker_id(message)
|
|
1285
|
+
if worker_id is not None:
|
|
1286
|
+
by_worker_id[worker_id] += 1
|
|
1287
|
+
if message.failed_at is not None:
|
|
1288
|
+
failed_ats.append(message.failed_at)
|
|
1289
|
+
summary: dict[str, Any] = {
|
|
1290
|
+
"count": len(messages),
|
|
1291
|
+
"by_error_type": dict(sorted(by_error_type.items())),
|
|
1292
|
+
"by_attempts": dict(sorted(by_attempts.items(), key=lambda item: int(item[0]))),
|
|
1293
|
+
"by_worker_id": dict(sorted(by_worker_id.items())),
|
|
1294
|
+
}
|
|
1295
|
+
if failed_ats:
|
|
1296
|
+
summary["failed_at"] = {
|
|
1297
|
+
"oldest": min(failed_ats),
|
|
1298
|
+
"newest": max(failed_ats),
|
|
1299
|
+
}
|
|
1300
|
+
return summary
|
|
1301
|
+
|
|
1302
|
+
|
|
1303
|
+
def _worker_health_summary(
|
|
1304
|
+
last_seen_by_worker_id: dict[str, float], *, stale_after: float
|
|
1305
|
+
) -> dict[str, Any]:
|
|
1306
|
+
now = time.time()
|
|
1307
|
+
workers_active: dict[str, float] = {}
|
|
1308
|
+
workers_stale: dict[str, float] = {}
|
|
1309
|
+
for worker_id, last_seen in sorted(last_seen_by_worker_id.items()):
|
|
1310
|
+
age = max(now - last_seen, 0.0)
|
|
1311
|
+
if age > stale_after:
|
|
1312
|
+
workers_stale[worker_id] = age
|
|
1313
|
+
else:
|
|
1314
|
+
workers_active[worker_id] = age
|
|
1315
|
+
return {
|
|
1316
|
+
"stale_after_seconds": stale_after,
|
|
1317
|
+
"active": {
|
|
1318
|
+
"count": len(workers_active),
|
|
1319
|
+
"by_worker_id": workers_active,
|
|
1320
|
+
},
|
|
1321
|
+
"stale": {
|
|
1322
|
+
"count": len(workers_stale),
|
|
1323
|
+
"by_worker_id": workers_stale,
|
|
1324
|
+
},
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
|
|
1328
|
+
def _last_attempt_worker_id(message: QueueMessage) -> str | None:
|
|
1329
|
+
for event in reversed(message.attempt_history):
|
|
1330
|
+
if event.get("type") == "leased":
|
|
1331
|
+
worker_id = event.get("leased_by")
|
|
1332
|
+
return None if worker_id is None else str(worker_id)
|
|
1333
|
+
return None
|
|
1334
|
+
|
|
1335
|
+
|
|
823
1336
|
def _format_command(command: list[str]) -> str:
|
|
824
1337
|
return " ".join(command)
|
|
825
1338
|
|
|
@@ -832,3 +1345,11 @@ def _truncate_output(value: str, *, limit: int = 500) -> str:
|
|
|
832
1345
|
|
|
833
1346
|
def _print_json(console: Any, value: Any) -> None:
|
|
834
1347
|
console.print_json(_json_dumps(value))
|
|
1348
|
+
|
|
1349
|
+
|
|
1350
|
+
def _emit_event(console: Any, event: str, **fields: Any) -> None:
|
|
1351
|
+
payload = {
|
|
1352
|
+
"event": event,
|
|
1353
|
+
**{key: value for key, value in fields.items() if value is not None},
|
|
1354
|
+
}
|
|
1355
|
+
_print_json(console, payload)
|