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 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 {"store_path", "retry_store_path"}:
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
- + f"{key}; use store_path or retry_store_path"
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
- console.print(_queue(queue, _resolve_store_path(store_path, config)).purge())
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
- return _ProcessResult(processed=False, last_error=last_error)
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
- if exhausted:
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=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
- return _ProcessResult(processed=False, last_error=last_error)
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
- return _ProcessResult(processed=queue.ack(message))
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.print("[yellow]queue is empty[/yellow]")
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
- {"id": message.id, "state": "failed", "last_error": result.last_error},
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 = queue.dead_letters(limit=limit)
611
- _print_json(console, [_message_payload(message) for message in messages])
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
- _print_json(console, queue.stats().as_dict())
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)