localqueue 0.3.1__tar.gz → 0.3.2__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.
Files changed (35) hide show
  1. {localqueue-0.3.1 → localqueue-0.3.2}/.gitignore +1 -0
  2. {localqueue-0.3.1 → localqueue-0.3.2}/CHANGELOG.md +5 -0
  3. {localqueue-0.3.1 → localqueue-0.3.2}/PKG-INFO +9 -2
  4. {localqueue-0.3.1 → localqueue-0.3.2}/README.md +8 -1
  5. {localqueue-0.3.1 → localqueue-0.3.2}/docs/release.md +1 -1
  6. {localqueue-0.3.1 → localqueue-0.3.2}/examples/enqueue_email.py +2 -1
  7. {localqueue-0.3.1 → localqueue-0.3.2}/examples/retry_demo.py +2 -1
  8. {localqueue-0.3.1 → localqueue-0.3.2}/examples/sqlite_concurrency_benchmark.py +3 -3
  9. {localqueue-0.3.1 → localqueue-0.3.2}/examples/sqlite_process_harness.py +4 -2
  10. {localqueue-0.3.1 → localqueue-0.3.2}/localqueue/cli.py +318 -107
  11. {localqueue-0.3.1 → localqueue-0.3.2}/localqueue/failure.py +6 -2
  12. {localqueue-0.3.1 → localqueue-0.3.2}/localqueue/queue.py +9 -7
  13. {localqueue-0.3.1 → localqueue-0.3.2}/localqueue/retry/store.py +2 -1
  14. {localqueue-0.3.1 → localqueue-0.3.2}/localqueue/retry/tenacity.py +76 -22
  15. {localqueue-0.3.1 → localqueue-0.3.2}/localqueue/store.py +3 -2
  16. {localqueue-0.3.1 → localqueue-0.3.2}/localqueue/worker.py +24 -16
  17. {localqueue-0.3.1 → localqueue-0.3.2}/pyproject.toml +42 -1
  18. {localqueue-0.3.1 → localqueue-0.3.2}/tests/test_cli.py +567 -2
  19. {localqueue-0.3.1 → localqueue-0.3.2}/tests/test_queue.py +966 -35
  20. {localqueue-0.3.1 → localqueue-0.3.2}/tests/test_retry.py +451 -2
  21. {localqueue-0.3.1 → localqueue-0.3.2}/uv.lock +1 -1
  22. {localqueue-0.3.1 → localqueue-0.3.2}/LICENSE +0 -0
  23. {localqueue-0.3.1 → localqueue-0.3.2}/docs/api.md +0 -0
  24. {localqueue-0.3.1 → localqueue-0.3.2}/docs/compare.md +0 -0
  25. {localqueue-0.3.1 → localqueue-0.3.2}/docs/index.md +0 -0
  26. {localqueue-0.3.1 → localqueue-0.3.2}/docs/operational-maturity.md +0 -0
  27. {localqueue-0.3.1 → localqueue-0.3.2}/docs/queues.md +0 -0
  28. {localqueue-0.3.1 → localqueue-0.3.2}/docs/retries.md +0 -0
  29. {localqueue-0.3.1 → localqueue-0.3.2}/docs/stability.md +0 -0
  30. {localqueue-0.3.1 → localqueue-0.3.2}/examples/email_worker.py +0 -0
  31. {localqueue-0.3.1 → localqueue-0.3.2}/examples/process_webhook.sh +0 -0
  32. {localqueue-0.3.1 → localqueue-0.3.2}/localqueue/__init__.py +0 -0
  33. {localqueue-0.3.1 → localqueue-0.3.2}/localqueue/paths.py +0 -0
  34. {localqueue-0.3.1 → localqueue-0.3.2}/localqueue/retry/__init__.py +0 -0
  35. {localqueue-0.3.1 → localqueue-0.3.2}/tests/test_process_harness.py +0 -0
@@ -20,3 +20,4 @@ site/
20
20
  /examples/*.sqlite3
21
21
  /examples/*.sqlite3-*
22
22
  feedback.md
23
+ /mutants
@@ -1,5 +1,10 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.2
4
+
5
+ - Add a CLI Docker image and publish it to GHCR on version tags.
6
+ - Raise the coverage gate to 100% and align the README badge.
7
+
3
8
  ## 0.3.1
4
9
 
5
10
  - Use XDG data directories for default SQLite queue and retry store files.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: localqueue
3
- Version: 0.3.1
3
+ Version: 0.3.2
4
4
  Summary: Durable local queues for Python, with persistent retry state powered by Tenacity.
5
5
  Project-URL: Homepage, https://brunoportis.github.io/localqueue/
6
6
  Project-URL: Documentation, https://brunoportis.github.io/localqueue/
@@ -37,7 +37,8 @@ Description-Content-Type: text/markdown
37
37
 
38
38
  [![Tests](https://github.com/brunoportis/localqueue/actions/workflows/tests.yml/badge.svg)](https://github.com/brunoportis/localqueue/actions/workflows/tests.yml)
39
39
  [![CodeQL](https://github.com/brunoportis/localqueue/actions/workflows/github-code-scanning/codeql/badge.svg)](https://github.com/brunoportis/localqueue/actions/workflows/github-code-scanning/codeql)
40
- ![Coverage](https://img.shields.io/badge/coverage-%E2%89%A595%25-brightgreen)
40
+ [![PyPI version](https://img.shields.io/pypi/v/localqueue.svg)](https://pypi.org/project/localqueue/)
41
+ ![Coverage](https://img.shields.io/badge/coverage-100%25-brightgreen)
41
42
 
42
43
  `localqueue` is a small durable queue for one machine. It stores work on the local filesystem by default and keeps retry state with Tenacity.
43
44
 
@@ -47,6 +48,12 @@ Use it for scripts, CLI tools, cron jobs, and small Python workers that share on
47
48
  localqueue queue exec emails -- python scripts/send_email.py
48
49
  ```
49
50
 
51
+ To run the CLI in a clean container:
52
+
53
+ ```bash
54
+ docker run --rm ghcr.io/brunoportis/localqueue:latest --help
55
+ ```
56
+
50
57
  ```python
51
58
  from localqueue import PersistentQueue, persistent_worker
52
59
 
@@ -2,7 +2,8 @@
2
2
 
3
3
  [![Tests](https://github.com/brunoportis/localqueue/actions/workflows/tests.yml/badge.svg)](https://github.com/brunoportis/localqueue/actions/workflows/tests.yml)
4
4
  [![CodeQL](https://github.com/brunoportis/localqueue/actions/workflows/github-code-scanning/codeql/badge.svg)](https://github.com/brunoportis/localqueue/actions/workflows/github-code-scanning/codeql)
5
- ![Coverage](https://img.shields.io/badge/coverage-%E2%89%A595%25-brightgreen)
5
+ [![PyPI version](https://img.shields.io/pypi/v/localqueue.svg)](https://pypi.org/project/localqueue/)
6
+ ![Coverage](https://img.shields.io/badge/coverage-100%25-brightgreen)
6
7
 
7
8
  `localqueue` is a small durable queue for one machine. It stores work on the local filesystem by default and keeps retry state with Tenacity.
8
9
 
@@ -12,6 +13,12 @@ Use it for scripts, CLI tools, cron jobs, and small Python workers that share on
12
13
  localqueue queue exec emails -- python scripts/send_email.py
13
14
  ```
14
15
 
16
+ To run the CLI in a clean container:
17
+
18
+ ```bash
19
+ docker run --rm ghcr.io/brunoportis/localqueue:latest --help
20
+ ```
21
+
15
22
  ```python
16
23
  from localqueue import PersistentQueue, persistent_worker
17
24
 
@@ -104,4 +104,4 @@ Before `1.0.0`, use this convention:
104
104
 
105
105
  ## Current Release
106
106
 
107
- The current planned release is `0.3.0`.
107
+ The current planned release is `0.3.2`.
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import argparse
4
+ from typing import Any
4
5
 
5
6
  from localqueue import PersistentQueue
6
7
 
@@ -12,7 +13,7 @@ def main() -> None:
12
13
  parser.add_argument("--fail", action="store_true")
13
14
  args = parser.parse_args()
14
15
 
15
- queue = (
16
+ queue: PersistentQueue[dict[str, Any]] = (
16
17
  PersistentQueue("emails", store_path=args.store_path)
17
18
  if args.store_path is not None
18
19
  else PersistentQueue("emails")
@@ -3,6 +3,7 @@ import random
3
3
  import time
4
4
  from queue import Empty
5
5
  from typing import Callable
6
+ from typing import Any
6
7
 
7
8
  from localqueue.retry import (
8
9
  AttemptStoreLockedError,
@@ -156,7 +157,7 @@ def run_worker_demo(
156
157
  db_path: str, *, worker_failure_rate: float = DEFAULT_WORKER_FAILURE_RATE
157
158
  ) -> None:
158
159
  print("\n=== persistent queue worker ===")
159
- queue = PersistentQueue(
160
+ queue: PersistentQueue[dict[str, Any]] = PersistentQueue(
160
161
  "worker-jobs",
161
162
  store_path=f"{db_path}_queue",
162
163
  lease_timeout=2,
@@ -90,7 +90,7 @@ def _run_benchmark(
90
90
 
91
91
  def producer(index: int) -> None:
92
92
  try:
93
- queue = PersistentQueue(
93
+ queue: PersistentQueue[dict[str, Any]] = PersistentQueue(
94
94
  queue_name, store_path=store_path, lease_timeout=lease_timeout
95
95
  )
96
96
  start.wait()
@@ -105,7 +105,7 @@ def _run_benchmark(
105
105
 
106
106
  def consumer(index: int) -> None:
107
107
  try:
108
- queue = PersistentQueue(
108
+ queue: PersistentQueue[dict[str, Any]] = PersistentQueue(
109
109
  queue_name, store_path=store_path, lease_timeout=lease_timeout
110
110
  )
111
111
  start.wait()
@@ -154,7 +154,7 @@ def _run_benchmark(
154
154
  record_error(TimeoutError("consumer thread did not finish"))
155
155
 
156
156
  elapsed = time.perf_counter() - started_at
157
- queue = PersistentQueue(
157
+ queue: PersistentQueue[dict[str, Any]] = PersistentQueue(
158
158
  queue_name, store_path=store_path, lease_timeout=lease_timeout
159
159
  )
160
160
  stats = queue.stats().as_dict()
@@ -8,13 +8,15 @@ import os
8
8
  import sqlite3
9
9
  import time
10
10
  from collections import Counter
11
- from collections.abc import Sequence
12
11
  from pathlib import Path
13
12
  from queue import Empty
14
- from typing import Any
13
+ from typing import Any, TYPE_CHECKING
15
14
 
16
15
  from localqueue import PersistentQueue
17
16
 
17
+ if TYPE_CHECKING:
18
+ from collections.abc import Sequence
19
+
18
20
 
19
21
  def main() -> int:
20
22
  args = _parse_args()
@@ -8,13 +8,11 @@ import subprocess
8
8
  import sys
9
9
  import time
10
10
  from collections import Counter
11
- from collections.abc import Callable
12
11
  from contextlib import contextmanager
13
12
  from dataclasses import dataclass
14
13
  from pathlib import Path
15
14
  from queue import Empty
16
- from types import FrameType
17
- from typing import Any, Iterator
15
+ from typing import Any, Iterator, TYPE_CHECKING
18
16
 
19
17
  from tenacity import wait_none
20
18
 
@@ -35,6 +33,10 @@ from .worker import (
35
33
  _sleep_for_policy,
36
34
  )
37
35
 
36
+ if TYPE_CHECKING:
37
+ from types import FrameType
38
+ from collections.abc import Callable
39
+
38
40
  DEFAULT_STORE_PATH = str(default_queue_store_path())
39
41
  DEFAULT_RETRY_STORE_PATH = str(default_retry_store_path())
40
42
  CONFIG_FILENAME = "config.yaml"
@@ -114,7 +116,20 @@ def _build_app(typer: Any, yaml: Any, console: Any, err_console: Any) -> Any:
114
116
  app.add_typer(queue_app, name="queue")
115
117
  app.add_typer(retry_app, name="retry")
116
118
  app.add_typer(config_app, name="config")
119
+ _register_config_commands(config_app, typer, yaml, console, err_console, config)
120
+ _register_queue_commands(queue_app, typer, console, err_console, config)
121
+ _register_retry_commands(retry_app, typer, console, err_console, config)
122
+ return app
117
123
 
124
+
125
+ def _register_config_commands(
126
+ config_app: Any,
127
+ typer: Any,
128
+ yaml: Any,
129
+ console: Any,
130
+ err_console: Any,
131
+ config: dict[str, Any],
132
+ ) -> None:
118
133
  @config_app.command("path")
119
134
  def config_path() -> None:
120
135
  console.print(str(_config_path()))
@@ -181,6 +196,26 @@ def _build_app(typer: Any, yaml: Any, console: Any, err_console: Any) -> Any:
181
196
  config.update(updated)
182
197
  _print_json(console, config)
183
198
 
199
+
200
+ def _register_queue_commands(
201
+ queue_app: Any,
202
+ typer: Any,
203
+ console: Any,
204
+ err_console: Any,
205
+ config: dict[str, Any],
206
+ ) -> None:
207
+ _register_queue_basic_commands(queue_app, typer, console, err_console, config)
208
+ _register_queue_admin_commands(queue_app, typer, console, err_console, config)
209
+ _register_queue_worker_commands(queue_app, typer, console, err_console, config)
210
+
211
+
212
+ def _register_queue_basic_commands(
213
+ queue_app: Any,
214
+ typer: Any,
215
+ console: Any,
216
+ err_console: Any,
217
+ config: dict[str, Any],
218
+ ) -> None:
184
219
  @queue_app.command("add")
185
220
  def queue_add(
186
221
  queue: str,
@@ -210,6 +245,17 @@ def _build_app(typer: Any, yaml: Any, console: Any, err_console: Any) -> Any:
210
245
  )
211
246
  _print_json(console, _message_payload(message))
212
247
 
248
+ _register_queue_message_commands(queue_app, typer, console, err_console, config)
249
+ _register_queue_maintenance_commands(queue_app, typer, console, err_console, config)
250
+
251
+
252
+ def _register_queue_message_commands(
253
+ queue_app: Any,
254
+ typer: Any,
255
+ console: Any,
256
+ err_console: Any,
257
+ config: dict[str, Any],
258
+ ) -> None:
213
259
  @queue_app.command("pop")
214
260
  def queue_pop(
215
261
  queue: str,
@@ -319,6 +365,27 @@ def _build_app(typer: Any, yaml: Any, console: Any, err_console: Any) -> Any:
319
365
  ) -> None:
320
366
  console.print(_queue(queue, _resolve_store_path(store_path, config)).qsize())
321
367
 
368
+
369
+ def _register_queue_maintenance_commands(
370
+ queue_app: Any,
371
+ typer: Any,
372
+ console: Any,
373
+ err_console: Any,
374
+ config: dict[str, Any],
375
+ ) -> None:
376
+ _register_queue_stats_commands(queue_app, typer, console, err_console, config)
377
+ _register_queue_dead_commands(queue_app, typer, console, err_console, config)
378
+ _register_queue_requeue_commands(queue_app, typer, console, err_console, config)
379
+ _register_queue_purge_commands(queue_app, typer, console, err_console, config)
380
+
381
+
382
+ def _register_queue_stats_commands(
383
+ queue_app: Any,
384
+ typer: Any,
385
+ console: Any,
386
+ err_console: Any,
387
+ config: dict[str, Any],
388
+ ) -> None:
322
389
  @queue_app.command("stats")
323
390
  def queue_stats(
324
391
  queue: str,
@@ -362,6 +429,14 @@ def _build_app(typer: Any, yaml: Any, console: Any, err_console: Any) -> Any:
362
429
  },
363
430
  )
364
431
 
432
+
433
+ def _register_queue_dead_commands(
434
+ queue_app: Any,
435
+ typer: Any,
436
+ console: Any,
437
+ err_console: Any,
438
+ config: dict[str, Any],
439
+ ) -> None:
365
440
  @queue_app.command("dead")
366
441
  def queue_dead(
367
442
  queue: str,
@@ -434,39 +509,14 @@ def _build_app(typer: Any, yaml: Any, console: Any, err_console: Any) -> Any:
434
509
  failed_within=failed_within,
435
510
  )
436
511
 
437
- @retry_app.command("prune")
438
- def retry_prune(
439
- older_than: float | None = typer.Option(None, "--older-than", min=0.0),
440
- retry_store_path: str | None = typer.Option(None, "--retry-store-path"),
441
- dry_run: bool = typer.Option(False, "--dry-run"),
442
- ) -> None:
443
- store = SQLiteAttemptStore(_resolve_retry_store_path(retry_store_path, config))
444
- try:
445
- resolved_older_than = _resolve_retry_record_ttl(older_than, config)
446
- if resolved_older_than is None:
447
- err_console.print(
448
- "[red]pass --older-than or configure retry_record_ttl_seconds[/red]"
449
- )
450
- raise typer.Exit(1)
451
- now = time.time()
452
- deleted = (
453
- store.count_exhausted_older_than(
454
- older_than=resolved_older_than, now=now
455
- )
456
- if dry_run
457
- else store.prune_exhausted(older_than=resolved_older_than, now=now)
458
- )
459
- finally:
460
- store.close()
461
- _print_json(
462
- console,
463
- {
464
- "dry_run": dry_run,
465
- "older_than": resolved_older_than,
466
- "would_delete" if dry_run else "deleted": deleted,
467
- },
468
- )
469
512
 
513
+ def _register_queue_requeue_commands(
514
+ queue_app: Any,
515
+ typer: Any,
516
+ console: Any,
517
+ err_console: Any,
518
+ config: dict[str, Any],
519
+ ) -> None:
470
520
  @queue_app.command("requeue-dead")
471
521
  def queue_requeue_dead(
472
522
  queue: str,
@@ -518,6 +568,14 @@ def _build_app(typer: Any, yaml: Any, console: Any, err_console: Any) -> Any:
518
568
  )
519
569
  _print_json(console, {"id": message_id, "state": "requeued"})
520
570
 
571
+
572
+ def _register_queue_purge_commands(
573
+ queue_app: Any,
574
+ typer: Any,
575
+ console: Any,
576
+ err_console: Any,
577
+ config: dict[str, Any],
578
+ ) -> None:
521
579
  @queue_app.command("purge")
522
580
  def queue_purge(
523
581
  queue: str,
@@ -529,6 +587,55 @@ def _build_app(typer: Any, yaml: Any, console: Any, err_console: Any) -> Any:
529
587
  _emit_event(err_console, "queue.purge", queue=queue, deleted=deleted)
530
588
  console.print(deleted)
531
589
 
590
+
591
+ def _register_retry_commands(
592
+ retry_app: Any,
593
+ typer: Any,
594
+ console: Any,
595
+ err_console: Any,
596
+ config: dict[str, Any],
597
+ ) -> None:
598
+ @retry_app.command("prune")
599
+ def retry_prune(
600
+ older_than: float | None = typer.Option(None, "--older-than", min=0.0),
601
+ retry_store_path: str | None = typer.Option(None, "--retry-store-path"),
602
+ dry_run: bool = typer.Option(False, "--dry-run"),
603
+ ) -> None:
604
+ store = SQLiteAttemptStore(_resolve_retry_store_path(retry_store_path, config))
605
+ try:
606
+ resolved_older_than = _resolve_retry_record_ttl(older_than, config)
607
+ if resolved_older_than is None:
608
+ err_console.print(
609
+ "[red]pass --older-than or configure retry_record_ttl_seconds[/red]"
610
+ )
611
+ raise typer.Exit(1)
612
+ now = time.time()
613
+ deleted = (
614
+ store.count_exhausted_older_than(
615
+ older_than=resolved_older_than, now=now
616
+ )
617
+ if dry_run
618
+ else store.prune_exhausted(older_than=resolved_older_than, now=now)
619
+ )
620
+ finally:
621
+ store.close()
622
+ _print_json(
623
+ console,
624
+ {
625
+ "dry_run": dry_run,
626
+ "older_than": resolved_older_than,
627
+ "would_delete" if dry_run else "deleted": deleted,
628
+ },
629
+ )
630
+
631
+
632
+ def _register_queue_admin_commands(
633
+ queue_app: Any,
634
+ typer: Any,
635
+ console: Any,
636
+ err_console: Any,
637
+ config: dict[str, Any],
638
+ ) -> None:
532
639
  @queue_app.command("process")
533
640
  def queue_process(
534
641
  queue: str,
@@ -606,6 +713,14 @@ def _build_app(typer: Any, yaml: Any, console: Any, err_console: Any) -> Any:
606
713
  if exit_code:
607
714
  raise typer.Exit(exit_code)
608
715
 
716
+
717
+ def _register_queue_worker_commands(
718
+ queue_app: Any,
719
+ typer: Any,
720
+ console: Any,
721
+ err_console: Any,
722
+ config: dict[str, Any],
723
+ ) -> None:
609
724
  @queue_app.command("exec")
610
725
  def queue_exec(
611
726
  queue: str,
@@ -681,8 +796,6 @@ def _build_app(typer: Any, yaml: Any, console: Any, err_console: Any) -> Any:
681
796
  if exit_code:
682
797
  raise typer.Exit(exit_code)
683
798
 
684
- return app
685
-
686
799
 
687
800
  def _queue(
688
801
  name: str, store_path: str, *, lease_timeout: float = 30.0
@@ -869,86 +982,53 @@ def _process_queue_messages(
869
982
  _print_json(console, {"state": "stopped", "processed": processed})
870
983
  return 0
871
984
 
872
- if worker_id is not None:
873
- queue.record_worker_heartbeat(worker_id)
874
- _sleep_for_policy(policy_state, resolved_policy)
875
-
876
- try:
877
- message = queue.get_message(
878
- block=block,
879
- timeout=_poll_timeout(forever, block, timeout),
880
- leased_by=worker_id,
881
- )
882
- except Empty:
883
- if forever:
884
- if not block:
885
- time.sleep(idle_sleep)
886
- continue
887
- if processed == 0:
888
- if err_console is not None:
889
- err_console.print("[yellow]queue is empty[/yellow]")
890
- return 1
891
- break
892
-
893
- if log_events and err_console is not None:
894
- _emit_event(
895
- err_console,
896
- f"{mode}.lease",
897
- queue=message.queue,
898
- message_id=message.id,
899
- leased_by=message.leased_by,
900
- attempts=message.attempts,
901
- leased_until=message.leased_until,
902
- )
903
-
904
- if retry_store_path is not None and owned_retry_store is None:
905
- owned_retry_store = SQLiteAttemptStore(retry_store_path)
906
-
907
- result = _process_message(
985
+ iteration, owned_retry_store, empty_queue = _process_queue_iteration(
908
986
  queue,
909
- message,
910
987
  handler,
911
- retry_store=owned_retry_store,
912
- retry_store_path=None
913
- if owned_retry_store is not None
914
- else retry_store_path,
988
+ console=console,
989
+ err_console=err_console,
990
+ policy_state=policy_state,
991
+ resolved_policy=resolved_policy,
992
+ retry_store_path=retry_store_path,
993
+ owned_retry_store=owned_retry_store,
915
994
  max_tries=max_tries,
995
+ worker_id=worker_id,
996
+ block=block,
997
+ timeout=timeout,
998
+ idle_sleep=idle_sleep,
916
999
  release_delay=release_delay,
917
1000
  dead_letter_on_exhaustion=dead_letter_on_exhaustion,
918
1001
  log_events=log_events,
919
1002
  mode=mode,
920
- err_console=err_console,
1003
+ forever=forever,
921
1004
  )
922
- if result.processed:
1005
+ if empty_queue:
1006
+ if processed == 0 and err_console is not None:
1007
+ err_console.print("[yellow]queue is empty[/yellow]")
1008
+ if processed == 0:
1009
+ return 1
1010
+ break
1011
+ assert iteration is not None
1012
+ if _handle_processed_queue_result(
1013
+ queue,
1014
+ iteration.message,
1015
+ iteration.process_result,
1016
+ console=console,
1017
+ worker_id=worker_id,
1018
+ policy_state=policy_state,
1019
+ ):
923
1020
  processed += 1
924
- _record_success(policy_state)
925
- if worker_id is not None:
926
- queue.record_worker_heartbeat(worker_id)
927
- _print_json(console, {"id": message.id, "state": "acked"})
928
1021
  continue
929
-
930
- _record_failure(
931
- policy_state,
932
- resolved_policy,
933
- permanent=result.permanent_failure,
934
- )
935
-
936
- payload = (
937
- _message_payload(current_message)
938
- if (current_message := queue.inspect(message.id)) is not None
939
- else {"id": message.id, "queue": message.queue}
940
- )
941
- _print_json(
942
- console,
943
- {
944
- **payload,
945
- "state": "failed",
946
- "last_error": result.last_error,
947
- },
948
- )
949
- if worker_id is not None:
950
- queue.record_worker_heartbeat(worker_id)
951
- if forever:
1022
+ if _handle_failed_queue_result(
1023
+ queue,
1024
+ iteration.message,
1025
+ iteration.process_result,
1026
+ console=console,
1027
+ worker_id=worker_id,
1028
+ policy_state=policy_state,
1029
+ resolved_policy=resolved_policy,
1030
+ forever=forever,
1031
+ ):
952
1032
  continue
953
1033
  return 1
954
1034
 
@@ -958,6 +1038,137 @@ def _process_queue_messages(
958
1038
  owned_retry_store.close()
959
1039
 
960
1040
 
1041
+ @dataclass(slots=True)
1042
+ class _QueueIterationResult:
1043
+ message: QueueMessage
1044
+ process_result: _ProcessResult
1045
+
1046
+
1047
+ def _process_queue_iteration(
1048
+ queue: PersistentQueue,
1049
+ handler: Callable[[Any], Any],
1050
+ *,
1051
+ console: Any,
1052
+ err_console: Any,
1053
+ policy_state: WorkerPolicyState,
1054
+ resolved_policy: PersistentWorkerConfig,
1055
+ retry_store_path: str | None,
1056
+ owned_retry_store: SQLiteAttemptStore | None,
1057
+ max_tries: int,
1058
+ worker_id: str | None,
1059
+ block: bool,
1060
+ timeout: float | None,
1061
+ idle_sleep: float,
1062
+ release_delay: float,
1063
+ dead_letter_on_exhaustion: bool,
1064
+ log_events: bool,
1065
+ mode: str,
1066
+ forever: bool,
1067
+ ) -> tuple[_QueueIterationResult | None, SQLiteAttemptStore | None, bool]:
1068
+ if worker_id is not None:
1069
+ queue.record_worker_heartbeat(worker_id)
1070
+ _sleep_for_policy(policy_state, resolved_policy)
1071
+
1072
+ try:
1073
+ message = queue.get_message(
1074
+ block=block,
1075
+ timeout=_poll_timeout(forever, block, timeout),
1076
+ leased_by=worker_id,
1077
+ )
1078
+ except Empty:
1079
+ if forever:
1080
+ if not block:
1081
+ time.sleep(idle_sleep)
1082
+ return None, owned_retry_store, True
1083
+ return None, owned_retry_store, True
1084
+
1085
+ if log_events and err_console is not None:
1086
+ _emit_event(
1087
+ err_console,
1088
+ f"{mode}.lease",
1089
+ queue=message.queue,
1090
+ message_id=message.id,
1091
+ leased_by=message.leased_by,
1092
+ attempts=message.attempts,
1093
+ leased_until=message.leased_until,
1094
+ )
1095
+
1096
+ if retry_store_path is not None and owned_retry_store is None:
1097
+ owned_retry_store = SQLiteAttemptStore(retry_store_path)
1098
+
1099
+ result = _process_message(
1100
+ queue,
1101
+ message,
1102
+ handler,
1103
+ retry_store=owned_retry_store,
1104
+ retry_store_path=None if owned_retry_store is not None else retry_store_path,
1105
+ max_tries=max_tries,
1106
+ release_delay=release_delay,
1107
+ dead_letter_on_exhaustion=dead_letter_on_exhaustion,
1108
+ log_events=log_events,
1109
+ mode=mode,
1110
+ err_console=err_console,
1111
+ )
1112
+ return (
1113
+ _QueueIterationResult(message=message, process_result=result),
1114
+ owned_retry_store,
1115
+ False,
1116
+ )
1117
+
1118
+
1119
+ def _handle_processed_queue_result(
1120
+ queue: PersistentQueue,
1121
+ message: QueueMessage,
1122
+ result: _ProcessResult,
1123
+ *,
1124
+ console: Any,
1125
+ worker_id: str | None,
1126
+ policy_state: WorkerPolicyState,
1127
+ ) -> bool:
1128
+ if not result.processed:
1129
+ return False
1130
+ _record_success(policy_state)
1131
+ if worker_id is not None:
1132
+ queue.record_worker_heartbeat(worker_id)
1133
+ _print_json(console, {"id": message.id, "state": "acked"})
1134
+ return True
1135
+
1136
+
1137
+ def _handle_failed_queue_result(
1138
+ queue: PersistentQueue,
1139
+ message: QueueMessage,
1140
+ result: _ProcessResult,
1141
+ *,
1142
+ console: Any,
1143
+ worker_id: str | None,
1144
+ policy_state: WorkerPolicyState,
1145
+ resolved_policy: PersistentWorkerConfig,
1146
+ forever: bool,
1147
+ ) -> bool:
1148
+ _record_failure(
1149
+ policy_state,
1150
+ resolved_policy,
1151
+ permanent=result.permanent_failure,
1152
+ )
1153
+
1154
+ payload = (
1155
+ _message_payload(current_message)
1156
+ if (current_message := queue.inspect(message.id)) is not None
1157
+ else {"id": message.id, "queue": message.queue}
1158
+ )
1159
+ _print_json(
1160
+ console,
1161
+ {
1162
+ **payload,
1163
+ "state": "failed",
1164
+ "last_error": result.last_error,
1165
+ },
1166
+ )
1167
+ if worker_id is not None:
1168
+ queue.record_worker_heartbeat(worker_id)
1169
+ return forever
1170
+
1171
+
961
1172
  def _print_dead_letters(
962
1173
  queue: PersistentQueue,
963
1174
  *,