localqueue 0.3.1__tar.gz → 0.3.3__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 (53) hide show
  1. {localqueue-0.3.1 → localqueue-0.3.3}/.gitignore +1 -0
  2. {localqueue-0.3.1 → localqueue-0.3.3}/CHANGELOG.md +11 -0
  3. {localqueue-0.3.1 → localqueue-0.3.3}/PKG-INFO +12 -2
  4. {localqueue-0.3.1 → localqueue-0.3.3}/README.md +11 -1
  5. {localqueue-0.3.1 → localqueue-0.3.3}/docs/release.md +18 -14
  6. {localqueue-0.3.1 → localqueue-0.3.3}/examples/enqueue_email.py +2 -1
  7. {localqueue-0.3.1 → localqueue-0.3.3}/examples/retry_demo.py +48 -37
  8. localqueue-0.3.3/examples/sqlite_concurrency_benchmark.py +313 -0
  9. {localqueue-0.3.1 → localqueue-0.3.3}/examples/sqlite_process_harness.py +33 -16
  10. {localqueue-0.3.1 → localqueue-0.3.3}/localqueue/__init__.py +1 -1
  11. localqueue-0.3.3/localqueue/cli.py +295 -0
  12. localqueue-0.3.3/localqueue/cli_commands.py +583 -0
  13. localqueue-0.3.3/localqueue/cli_support.py +242 -0
  14. localqueue-0.3.3/localqueue/cli_worker_commands.py +318 -0
  15. {localqueue-0.3.1 → localqueue-0.3.3}/localqueue/failure.py +6 -2
  16. {localqueue-0.3.1 → localqueue-0.3.3}/localqueue/queue.py +24 -21
  17. {localqueue-0.3.1 → localqueue-0.3.3}/localqueue/retry/__init__.py +1 -1
  18. localqueue-0.3.3/localqueue/retry/stores/__init__.py +13 -0
  19. localqueue-0.3.3/localqueue/retry/stores/_shared.py +22 -0
  20. localqueue-0.3.3/localqueue/retry/stores/base.py +41 -0
  21. localqueue-0.3.3/localqueue/retry/stores/lmdb.py +80 -0
  22. localqueue-0.3.3/localqueue/retry/stores/memory.py +51 -0
  23. localqueue-0.3.1/localqueue/retry/store.py → localqueue-0.3.3/localqueue/retry/stores/sqlite.py +3 -168
  24. {localqueue-0.3.1 → localqueue-0.3.3}/localqueue/retry/tenacity.py +122 -39
  25. localqueue-0.3.3/localqueue/services/__init__.py +1 -0
  26. localqueue-0.3.3/localqueue/services/queue_worker.py +620 -0
  27. localqueue-0.3.3/localqueue/stores/__init__.py +14 -0
  28. localqueue-0.3.3/localqueue/stores/_shared.py +404 -0
  29. localqueue-0.3.3/localqueue/stores/base.py +137 -0
  30. localqueue-0.3.3/localqueue/stores/lmdb.py +555 -0
  31. localqueue-0.3.3/localqueue/stores/memory.py +318 -0
  32. localqueue-0.3.3/localqueue/stores/sqlite.py +715 -0
  33. {localqueue-0.3.1 → localqueue-0.3.3}/localqueue/worker.py +42 -15
  34. {localqueue-0.3.1 → localqueue-0.3.3}/pyproject.toml +42 -1
  35. {localqueue-0.3.1 → localqueue-0.3.3}/tests/test_cli.py +744 -95
  36. {localqueue-0.3.1 → localqueue-0.3.3}/tests/test_queue.py +1191 -157
  37. {localqueue-0.3.1 → localqueue-0.3.3}/tests/test_retry.py +500 -12
  38. {localqueue-0.3.1 → localqueue-0.3.3}/uv.lock +1 -1
  39. localqueue-0.3.1/examples/sqlite_concurrency_benchmark.py +0 -181
  40. localqueue-0.3.1/localqueue/cli.py +0 -1367
  41. localqueue-0.3.1/localqueue/store.py +0 -2012
  42. {localqueue-0.3.1 → localqueue-0.3.3}/LICENSE +0 -0
  43. {localqueue-0.3.1 → localqueue-0.3.3}/docs/api.md +0 -0
  44. {localqueue-0.3.1 → localqueue-0.3.3}/docs/compare.md +0 -0
  45. {localqueue-0.3.1 → localqueue-0.3.3}/docs/index.md +0 -0
  46. {localqueue-0.3.1 → localqueue-0.3.3}/docs/operational-maturity.md +0 -0
  47. {localqueue-0.3.1 → localqueue-0.3.3}/docs/queues.md +0 -0
  48. {localqueue-0.3.1 → localqueue-0.3.3}/docs/retries.md +0 -0
  49. {localqueue-0.3.1 → localqueue-0.3.3}/docs/stability.md +0 -0
  50. {localqueue-0.3.1 → localqueue-0.3.3}/examples/email_worker.py +0 -0
  51. {localqueue-0.3.1 → localqueue-0.3.3}/examples/process_webhook.sh +0 -0
  52. {localqueue-0.3.1 → localqueue-0.3.3}/localqueue/paths.py +0 -0
  53. {localqueue-0.3.1 → localqueue-0.3.3}/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,16 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.3
4
+
5
+ - Split queue and retry store implementations into dedicated modules.
6
+ - Refactor the CLI worker option wiring and centralize worker helpers.
7
+ - Add a SonarCloud workflow and stabilize the SQLite concurrency queue test.
8
+
9
+ ## 0.3.2
10
+
11
+ - Add a CLI Docker image and publish it to GHCR on version tags.
12
+ - Raise the coverage gate to 100% and align the README badge.
13
+
3
14
  ## 0.3.1
4
15
 
5
16
  - 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.3
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
 
@@ -78,6 +79,15 @@ For the optional LMDB backend:
78
79
  pip install "localqueue[lmdb]"
79
80
  ```
80
81
 
82
+ ## Running with Docker
83
+
84
+ The image is also published on GitHub Container Registry:
85
+
86
+ ```bash
87
+ docker pull ghcr.io/brunoportis/localqueue:latest
88
+ docker run --rm ghcr.io/brunoportis/localqueue:latest --help
89
+ ```
90
+
81
91
  ## When not to use
82
92
 
83
93
  `localqueue` is not a distributed broker. If you need multi-host coordination, high write concurrency, managed retention, or strict cross-service ordering, use a system designed for that operating model.
@@ -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
 
@@ -43,6 +44,15 @@ For the optional LMDB backend:
43
44
  pip install "localqueue[lmdb]"
44
45
  ```
45
46
 
47
+ ## Running with Docker
48
+
49
+ The image is also published on GitHub Container Registry:
50
+
51
+ ```bash
52
+ docker pull ghcr.io/brunoportis/localqueue:latest
53
+ docker run --rm ghcr.io/brunoportis/localqueue:latest --help
54
+ ```
55
+
46
56
  ## When not to use
47
57
 
48
58
  `localqueue` is not a distributed broker. If you need multi-host coordination, high write concurrency, managed retention, or strict cross-service ordering, use a system designed for that operating model.
@@ -27,48 +27,49 @@ Before `1.0.0`, use this convention:
27
27
  ## Checklist
28
28
 
29
29
  1. Update `version` in `pyproject.toml`.
30
- 2. Update `CHANGELOG.md`.
31
- 3. Confirm `requires-python`, license metadata, and PyPI classifiers still match
30
+ 2. Run `uv lock` and commit the updated `uv.lock`.
31
+ 3. Update `CHANGELOG.md`.
32
+ 4. Confirm `requires-python`, license metadata, and PyPI classifiers still match
32
33
  supported Python versions and the intended release policy.
33
- 4. Run the test suite:
34
+ 5. Run the test suite:
34
35
 
35
36
  ```bash
36
37
  uv run pytest
37
38
  ```
38
39
 
39
- 5. Run lint and typecheck:
40
+ 6. Run lint and typecheck:
40
41
 
41
42
  ```bash
42
43
  uv run ruff check .
43
44
  uv run basedpyright
44
45
  ```
45
46
 
46
- 6. Build the docs:
47
+ 7. Build the docs:
47
48
 
48
49
  ```bash
49
50
  uv run zensical build --clean
50
51
  ```
51
52
 
52
- 7. Confirm the lockfile is current:
53
+ 8. Confirm the lockfile is current:
53
54
 
54
55
  ```bash
55
56
  uv lock --check
56
57
  ```
57
58
 
58
- 8. Build the release artifacts:
59
+ 9. Build the release artifacts:
59
60
 
60
61
  ```bash
61
62
  uv build
62
63
  ```
63
64
 
64
- 9. Inspect the artifacts:
65
+ 10. Inspect the artifacts:
65
66
 
66
67
  ```bash
67
68
  tar -tzf dist/localqueue-<version>.tar.gz | less
68
69
  python -m zipfile -l dist/localqueue-<version>-py3-none-any.whl
69
70
  ```
70
71
 
71
- 10. Install the wheel in a clean virtual environment:
72
+ 11. Install the wheel in a clean virtual environment:
72
73
 
73
74
  ```bash
74
75
  uv venv /tmp/localqueue-release-venv
@@ -77,7 +78,7 @@ Before `1.0.0`, use this convention:
77
78
  /tmp/localqueue-release-venv/bin/localqueue --help
78
79
  ```
79
80
 
80
- 11. Run a local smoke test:
81
+ 12. Run a local smoke test:
81
82
 
82
83
  ```bash
83
84
  uv run python examples/enqueue_email.py user@example.com \
@@ -90,18 +91,21 @@ Before `1.0.0`, use this convention:
90
91
  --max-tries 3
91
92
  ```
92
93
 
93
- 12. Create the release tag:
94
+ 13. Create the release tag:
94
95
 
95
96
  ```bash
96
97
  git tag v<version>
97
98
  ```
98
99
 
99
- 13. Publish the artifacts.
100
+ 14. Publish the artifacts.
100
101
 
101
- 14. Confirm the docs deployment finished successfully in the `Documentation`
102
+ This release workflow also publishes the CLI image to GitHub Container
103
+ Registry as `ghcr.io/brunoportis/localqueue:<tag>` and `:latest`.
104
+
105
+ 15. Confirm the docs deployment finished successfully in the `Documentation`
102
106
  workflow. GitHub Pages must be configured to deploy from GitHub Actions in
103
107
  the repository settings.
104
108
 
105
109
  ## Current Release
106
110
 
107
- The current planned release is `0.3.0`.
111
+ The current planned release is `0.3.3`.
@@ -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")
@@ -1,8 +1,9 @@
1
1
  import argparse
2
- import random
3
2
  import time
4
3
  from queue import Empty
4
+ from secrets import SystemRandom
5
5
  from typing import Callable
6
+ from typing import Any
6
7
 
7
8
  from localqueue.retry import (
8
9
  AttemptStoreLockedError,
@@ -21,6 +22,7 @@ WorkerJobFn = Callable[[str, dict[str, str]], None]
21
22
  DEFAULT_EMAIL_FAILURE_RATE = 0.8
22
23
  DEFAULT_VIDEO_FAILURE_RATE = 0.7
23
24
  DEFAULT_WORKER_FAILURE_RATE = 0.65
25
+ DEMO_RANDOM = SystemRandom()
24
26
 
25
27
 
26
28
  def build_fast_examples(
@@ -37,7 +39,7 @@ def build_fast_examples(
37
39
  )
38
40
  def send_email_fast(task_id: str, email_address: str) -> None:
39
41
  print(f"[send_email_fast] task={task_id} email={email_address}")
40
- if random.random() < email_failure_rate:
42
+ if DEMO_RANDOM.random() < email_failure_rate:
41
43
  raise ConnectionError("mail server down")
42
44
  print(f"[send_email_fast] task={task_id} sent")
43
45
 
@@ -49,7 +51,7 @@ def build_fast_examples(
49
51
  )
50
52
  def process_video_fast(task_id: str, file_path: str) -> None:
51
53
  print(f"[process_video_fast] task={task_id} file={file_path}")
52
- if random.random() < video_failure_rate:
54
+ if DEMO_RANDOM.random() < video_failure_rate:
53
55
  raise ValueError("transcoder busy")
54
56
  print(f"[process_video_fast] task={task_id} processed")
55
57
 
@@ -70,7 +72,7 @@ def build_slow_examples(
70
72
  )
71
73
  def send_email_slow(task_id: str, email_address: str) -> None:
72
74
  print(f"[send_email_slow] task={task_id} email={email_address}")
73
- if random.random() < email_failure_rate:
75
+ if DEMO_RANDOM.random() < email_failure_rate:
74
76
  raise ConnectionError("mail server down")
75
77
  print(f"[send_email_slow] task={task_id} sent")
76
78
 
@@ -82,7 +84,7 @@ def build_slow_examples(
82
84
  )
83
85
  def process_video_slow(task_id: str, file_path: str) -> None:
84
86
  print(f"[process_video_slow] task={task_id} file={file_path}")
85
- if random.random() < video_failure_rate:
87
+ if DEMO_RANDOM.random() < video_failure_rate:
86
88
  raise ValueError("transcoder busy")
87
89
  print(f"[process_video_slow] task={task_id} processed")
88
90
 
@@ -102,7 +104,7 @@ def build_worker_example(
102
104
  print(
103
105
  f"[worker] job={task_id} kind={payload['kind']} target={payload['target']}"
104
106
  )
105
- if random.random() < worker_failure_rate:
107
+ if DEMO_RANDOM.random() < worker_failure_rate:
106
108
  raise RuntimeError("transient worker failure")
107
109
  print(f"[worker] job={task_id} completed")
108
110
 
@@ -156,7 +158,7 @@ def run_worker_demo(
156
158
  db_path: str, *, worker_failure_rate: float = DEFAULT_WORKER_FAILURE_RATE
157
159
  ) -> None:
158
160
  print("\n=== persistent queue worker ===")
159
- queue = PersistentQueue(
161
+ queue: PersistentQueue[dict[str, Any]] = PersistentQueue(
160
162
  "worker-jobs",
161
163
  store_path=f"{db_path}_queue",
162
164
  lease_timeout=2,
@@ -182,40 +184,49 @@ def run_worker_demo(
182
184
  f"[worker] message={task_id} kind={payload['kind']} "
183
185
  + f"target={payload['target']}"
184
186
  )
185
- if random.random() < worker_failure_rate:
187
+ if DEMO_RANDOM.random() < worker_failure_rate:
186
188
  raise RuntimeError("transient worker failure")
187
189
  print(f"[worker] message={task_id} completed")
188
190
 
189
191
  for cycle in range(1, 8):
190
- print(f"\n[worker] poll cycle {cycle}")
191
- processed = 0
192
-
193
- while True:
194
- try:
195
- message = queue.get_message(block=False)
196
- except Empty:
197
- break
198
-
199
- payload = message.value
200
- try:
201
- _ = retryer(process_worker_job, message.id, payload)
202
- except PersistentRetryExhausted as exc:
203
- _ = queue.dead_letter(message)
204
- print(
205
- f"[queue] message={exc.key} moved to dead letter "
206
- + f"after {exc.attempts} attempts"
207
- )
208
- except Exception as exc:
209
- _ = queue.release(message, delay=1)
210
- print(f"[queue] message={message.id} released for retry: {exc}")
211
- else:
212
- _ = queue.ack(message)
213
- print(f"[queue] message={message.id} acked")
214
- processed += 1
215
-
216
- if processed == 0:
217
- print("[worker] no tasks available")
218
- time.sleep(1)
192
+ _run_worker_cycle(queue, retryer, cycle, process_worker_job)
193
+
194
+
195
+ def _run_worker_cycle(
196
+ queue: PersistentQueue[dict[str, Any]],
197
+ retryer: PersistentRetrying,
198
+ cycle: int,
199
+ process_worker_job: Any,
200
+ ) -> None:
201
+ print(f"\n[worker] poll cycle {cycle}")
202
+ processed = 0
203
+
204
+ while True:
205
+ try:
206
+ message = queue.get_message(block=False)
207
+ except Empty:
208
+ break
209
+
210
+ payload = message.value
211
+ try:
212
+ _ = retryer(process_worker_job, message.id, payload)
213
+ except PersistentRetryExhausted as exc:
214
+ _ = queue.dead_letter(message)
215
+ print(
216
+ f"[queue] message={exc.key} moved to dead letter "
217
+ + f"after {exc.attempts} attempts"
218
+ )
219
+ except Exception as exc:
220
+ _ = queue.release(message, delay=1)
221
+ print(f"[queue] message={message.id} released for retry: {exc}")
222
+ else:
223
+ _ = queue.ack(message)
224
+ print(f"[queue] message={message.id} acked")
225
+ processed += 1
226
+
227
+ if processed == 0:
228
+ print("[worker] no tasks available")
229
+ time.sleep(1)
219
230
 
220
231
 
221
232
  def parse_args() -> argparse.Namespace:
@@ -0,0 +1,313 @@
1
+ #!/usr/bin/env python3
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import json
6
+ import sqlite3
7
+ import threading
8
+ import time
9
+ from collections import Counter
10
+ from collections.abc import Callable
11
+ from pathlib import Path
12
+ from queue import Empty
13
+ from typing import Any
14
+
15
+ from localqueue import PersistentQueue
16
+
17
+
18
+ def main() -> int:
19
+ args = _parse_args()
20
+ summary = _run_benchmark(
21
+ queue_name=args.queue,
22
+ store_path=args.store_path,
23
+ producers=args.producers,
24
+ consumers=args.consumers,
25
+ messages=args.messages,
26
+ lease_timeout=args.lease_timeout,
27
+ )
28
+ print(json.dumps(summary, indent=2, sort_keys=True))
29
+ return 0 if summary["errors"] == 0 else 1
30
+
31
+
32
+ def _parse_args() -> argparse.Namespace:
33
+ parser = argparse.ArgumentParser(
34
+ description="Measure concurrent SQLite queue throughput locally."
35
+ )
36
+ parser.add_argument("--queue", default="jobs")
37
+ parser.add_argument(
38
+ "--store-path",
39
+ default="localqueue_queue_benchmark.sqlite3",
40
+ help="SQLite queue file to use for the benchmark.",
41
+ )
42
+ parser.add_argument("--messages", type=int, default=200)
43
+ parser.add_argument("--producers", type=int, default=4)
44
+ parser.add_argument("--consumers", type=int, default=4)
45
+ parser.add_argument("--lease-timeout", type=float, default=0.2)
46
+ return parser.parse_args()
47
+
48
+
49
+ def _run_benchmark(
50
+ *,
51
+ queue_name: str,
52
+ store_path: str,
53
+ producers: int,
54
+ consumers: int,
55
+ messages: int,
56
+ lease_timeout: float,
57
+ ) -> dict[str, Any]:
58
+ if producers <= 0 or consumers <= 0 or messages <= 0:
59
+ raise ValueError("producers, consumers, and messages must be greater than zero")
60
+
61
+ _remove_sqlite_files(store_path)
62
+
63
+ start = threading.Event()
64
+ producers_done = threading.Event()
65
+ consumed = Counter[str]()
66
+ consumed_lock = threading.Lock()
67
+ errors: list[BaseException] = []
68
+ errors_lock = threading.Lock()
69
+ lock_retries = Counter[str]()
70
+ per_producer, remainder = divmod(messages, producers)
71
+
72
+ producer_threads = [
73
+ threading.Thread(
74
+ target=_producer_thread,
75
+ args=(
76
+ index,
77
+ queue_name,
78
+ store_path,
79
+ lease_timeout,
80
+ start,
81
+ per_producer + (1 if index < remainder else 0),
82
+ lock_retries,
83
+ errors,
84
+ errors_lock,
85
+ ),
86
+ )
87
+ for index in range(producers)
88
+ ]
89
+ consumer_threads = [
90
+ threading.Thread(
91
+ target=_consumer_thread,
92
+ args=(
93
+ index,
94
+ queue_name,
95
+ store_path,
96
+ lease_timeout,
97
+ start,
98
+ producers_done,
99
+ consumed,
100
+ consumed_lock,
101
+ messages,
102
+ lock_retries,
103
+ errors,
104
+ errors_lock,
105
+ ),
106
+ )
107
+ for index in range(consumers)
108
+ ]
109
+ threads = producer_threads + consumer_threads
110
+
111
+ started_at = time.perf_counter()
112
+ _start_threads(threads)
113
+ start.set()
114
+
115
+ _join_benchmark_threads(
116
+ threads,
117
+ producer_count=producers,
118
+ producers_done=producers_done,
119
+ errors=errors,
120
+ errors_lock=errors_lock,
121
+ )
122
+ elapsed = time.perf_counter() - started_at
123
+ return _benchmark_summary(
124
+ queue_name=queue_name,
125
+ store_path=store_path,
126
+ lease_timeout=lease_timeout,
127
+ messages=messages,
128
+ producers=producers,
129
+ consumers=consumers,
130
+ consumed=consumed,
131
+ errors=errors,
132
+ lock_retries=lock_retries,
133
+ elapsed=elapsed,
134
+ )
135
+
136
+
137
+ def _remove_sqlite_files(store_path: str) -> None:
138
+ store_file = Path(store_path)
139
+ for suffix in ("", "-wal", "-shm"):
140
+ candidate = Path(f"{store_file}{suffix}")
141
+ if candidate.exists():
142
+ candidate.unlink()
143
+
144
+
145
+ def _record_error(
146
+ exc: BaseException, errors: list[BaseException], errors_lock: threading.Lock
147
+ ) -> None:
148
+ with errors_lock:
149
+ errors.append(exc)
150
+
151
+
152
+ def _retry_sqlite_locked(
153
+ action: str, fn: Callable[[], Any], lock_retries: Counter[str]
154
+ ) -> Any:
155
+ attempts = 0
156
+ while True:
157
+ try:
158
+ return fn()
159
+ except sqlite3.OperationalError as exc:
160
+ if "database is locked" not in str(exc).lower():
161
+ raise
162
+ attempts += 1
163
+ lock_retries[action] += 1
164
+ time.sleep(min(0.001 * attempts, 0.05))
165
+
166
+
167
+ def _producer_thread(
168
+ index: int,
169
+ queue_name: str,
170
+ store_path: str,
171
+ lease_timeout: float,
172
+ start: threading.Event,
173
+ message_count: int,
174
+ lock_retries: Counter[str],
175
+ errors: list[BaseException],
176
+ errors_lock: threading.Lock,
177
+ ) -> None:
178
+ try:
179
+ queue: PersistentQueue[dict[str, Any]] = PersistentQueue(
180
+ queue_name, store_path=store_path, lease_timeout=lease_timeout
181
+ )
182
+ start.wait()
183
+ for sequence in range(message_count):
184
+ _retry_sqlite_locked(
185
+ "put",
186
+ lambda sequence=sequence: queue.put(
187
+ {"producer": index, "sequence": sequence}
188
+ ),
189
+ lock_retries,
190
+ )
191
+ except Exception as exc: # pragma: no cover - defensive
192
+ _record_error(exc, errors, errors_lock)
193
+
194
+
195
+ def _consumer_thread(
196
+ index: int,
197
+ queue_name: str,
198
+ store_path: str,
199
+ lease_timeout: float,
200
+ start: threading.Event,
201
+ producers_done: threading.Event,
202
+ consumed: Counter[str],
203
+ consumed_lock: threading.Lock,
204
+ messages: int,
205
+ lock_retries: Counter[str],
206
+ errors: list[BaseException],
207
+ errors_lock: threading.Lock,
208
+ ) -> None:
209
+ try:
210
+ queue: PersistentQueue[dict[str, Any]] = PersistentQueue(
211
+ queue_name, store_path=store_path, lease_timeout=lease_timeout
212
+ )
213
+ start.wait()
214
+ while not _all_messages_consumed(
215
+ producers_done, consumed, consumed_lock, messages
216
+ ):
217
+ try:
218
+ message = _retry_sqlite_locked(
219
+ "get",
220
+ lambda: queue.get_message(
221
+ block=False, leased_by=f"consumer-{index}"
222
+ ),
223
+ lock_retries,
224
+ )
225
+ except Empty:
226
+ time.sleep(0.001)
227
+ continue
228
+ if not _retry_sqlite_locked(
229
+ "ack", lambda message=message: queue.ack(message), lock_retries
230
+ ):
231
+ _record_error(
232
+ RuntimeError(f"ack failed for {message.id}"), errors, errors_lock
233
+ )
234
+ return
235
+ with consumed_lock:
236
+ consumed[message.id] += 1
237
+ except Exception as exc: # pragma: no cover - defensive
238
+ _record_error(exc, errors, errors_lock)
239
+
240
+
241
+ def _all_messages_consumed(
242
+ producers_done: threading.Event,
243
+ consumed: Counter[str],
244
+ consumed_lock: threading.Lock,
245
+ messages: int,
246
+ ) -> bool:
247
+ with consumed_lock:
248
+ return producers_done.is_set() and sum(consumed.values()) >= messages
249
+
250
+
251
+ def _start_threads(threads: list[threading.Thread]) -> None:
252
+ for thread in threads:
253
+ thread.start()
254
+
255
+
256
+ def _join_benchmark_threads(
257
+ threads: list[threading.Thread],
258
+ *,
259
+ producer_count: int,
260
+ producers_done: threading.Event,
261
+ errors: list[BaseException],
262
+ errors_lock: threading.Lock,
263
+ ) -> None:
264
+ for thread in threads[:producer_count]:
265
+ thread.join()
266
+ producers_done.set()
267
+
268
+ for thread in threads[producer_count:]:
269
+ thread.join(timeout=10.0)
270
+ if thread.is_alive():
271
+ _record_error(
272
+ TimeoutError("consumer thread did not finish"), errors, errors_lock
273
+ )
274
+
275
+
276
+ def _benchmark_summary(
277
+ *,
278
+ queue_name: str,
279
+ store_path: str,
280
+ lease_timeout: float,
281
+ messages: int,
282
+ producers: int,
283
+ consumers: int,
284
+ consumed: Counter[str],
285
+ errors: list[BaseException],
286
+ lock_retries: Counter[str],
287
+ elapsed: float,
288
+ ) -> dict[str, Any]:
289
+ queue: PersistentQueue[dict[str, Any]] = PersistentQueue(
290
+ queue_name, store_path=store_path, lease_timeout=lease_timeout
291
+ )
292
+ stats = queue.stats().as_dict()
293
+ consumed_total = sum(consumed.values())
294
+ throughput = consumed_total / elapsed if elapsed > 0 else 0.0
295
+ return {
296
+ "queue": queue_name,
297
+ "store_path": str(Path(store_path).resolve()),
298
+ "messages": messages,
299
+ "producers": producers,
300
+ "consumers": consumers,
301
+ "consumed": consumed_total,
302
+ "unique_messages": len(consumed),
303
+ "errors": len(errors),
304
+ "error_messages": [f"{type(error).__name__}: {error}" for error in errors],
305
+ "lock_retries": dict(lock_retries),
306
+ "elapsed_seconds": round(elapsed, 6),
307
+ "throughput_messages_per_second": round(throughput, 2),
308
+ "stats": stats,
309
+ }
310
+
311
+
312
+ if __name__ == "__main__":
313
+ raise SystemExit(main())