localqueue 0.3.2__tar.gz → 0.3.4__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 (54) hide show
  1. {localqueue-0.3.2 → localqueue-0.3.4}/CHANGELOG.md +11 -0
  2. {localqueue-0.3.2 → localqueue-0.3.4}/PKG-INFO +10 -7
  3. {localqueue-0.3.2 → localqueue-0.3.4}/README.md +9 -6
  4. {localqueue-0.3.2 → localqueue-0.3.4}/docs/release.md +18 -14
  5. {localqueue-0.3.2 → localqueue-0.3.4}/examples/retry_demo.py +46 -36
  6. localqueue-0.3.4/examples/sqlite_concurrency_benchmark.py +313 -0
  7. {localqueue-0.3.2 → localqueue-0.3.4}/examples/sqlite_process_harness.py +29 -14
  8. {localqueue-0.3.2 → localqueue-0.3.4}/localqueue/__init__.py +1 -1
  9. localqueue-0.3.4/localqueue/cli.py +295 -0
  10. localqueue-0.3.4/localqueue/cli_commands.py +583 -0
  11. localqueue-0.3.4/localqueue/cli_support.py +242 -0
  12. localqueue-0.3.4/localqueue/cli_worker_commands.py +318 -0
  13. {localqueue-0.3.2 → localqueue-0.3.4}/localqueue/queue.py +15 -14
  14. {localqueue-0.3.2 → localqueue-0.3.4}/localqueue/retry/__init__.py +1 -1
  15. localqueue-0.3.4/localqueue/retry/stores/__init__.py +13 -0
  16. localqueue-0.3.4/localqueue/retry/stores/_shared.py +22 -0
  17. localqueue-0.3.4/localqueue/retry/stores/base.py +41 -0
  18. localqueue-0.3.4/localqueue/retry/stores/lmdb.py +80 -0
  19. localqueue-0.3.4/localqueue/retry/stores/memory.py +51 -0
  20. localqueue-0.3.4/localqueue/retry/stores/sqlite.py +148 -0
  21. {localqueue-0.3.2 → localqueue-0.3.4}/localqueue/retry/tenacity.py +51 -22
  22. localqueue-0.3.4/localqueue/services/__init__.py +1 -0
  23. localqueue-0.3.4/localqueue/services/queue_worker.py +622 -0
  24. localqueue-0.3.4/localqueue/stores/__init__.py +14 -0
  25. localqueue-0.3.4/localqueue/stores/_shared.py +404 -0
  26. localqueue-0.3.4/localqueue/stores/base.py +137 -0
  27. localqueue-0.3.4/localqueue/stores/lmdb.py +555 -0
  28. localqueue-0.3.4/localqueue/stores/memory.py +318 -0
  29. localqueue-0.3.4/localqueue/stores/sqlite.py +715 -0
  30. {localqueue-0.3.2 → localqueue-0.3.4}/localqueue/worker.py +30 -11
  31. {localqueue-0.3.2 → localqueue-0.3.4}/pyproject.toml +1 -1
  32. {localqueue-0.3.2 → localqueue-0.3.4}/tests/test_cli.py +261 -127
  33. {localqueue-0.3.2 → localqueue-0.3.4}/tests/test_queue.py +228 -125
  34. {localqueue-0.3.2 → localqueue-0.3.4}/tests/test_retry.py +88 -49
  35. {localqueue-0.3.2 → localqueue-0.3.4}/uv.lock +1 -1
  36. localqueue-0.3.2/examples/sqlite_concurrency_benchmark.py +0 -181
  37. localqueue-0.3.2/localqueue/cli.py +0 -1578
  38. localqueue-0.3.2/localqueue/retry/store.py +0 -314
  39. localqueue-0.3.2/localqueue/store.py +0 -2013
  40. {localqueue-0.3.2 → localqueue-0.3.4}/.gitignore +0 -0
  41. {localqueue-0.3.2 → localqueue-0.3.4}/LICENSE +0 -0
  42. {localqueue-0.3.2 → localqueue-0.3.4}/docs/api.md +0 -0
  43. {localqueue-0.3.2 → localqueue-0.3.4}/docs/compare.md +0 -0
  44. {localqueue-0.3.2 → localqueue-0.3.4}/docs/index.md +0 -0
  45. {localqueue-0.3.2 → localqueue-0.3.4}/docs/operational-maturity.md +0 -0
  46. {localqueue-0.3.2 → localqueue-0.3.4}/docs/queues.md +0 -0
  47. {localqueue-0.3.2 → localqueue-0.3.4}/docs/retries.md +0 -0
  48. {localqueue-0.3.2 → localqueue-0.3.4}/docs/stability.md +0 -0
  49. {localqueue-0.3.2 → localqueue-0.3.4}/examples/email_worker.py +0 -0
  50. {localqueue-0.3.2 → localqueue-0.3.4}/examples/enqueue_email.py +0 -0
  51. {localqueue-0.3.2 → localqueue-0.3.4}/examples/process_webhook.sh +0 -0
  52. {localqueue-0.3.2 → localqueue-0.3.4}/localqueue/failure.py +0 -0
  53. {localqueue-0.3.2 → localqueue-0.3.4}/localqueue/paths.py +0 -0
  54. {localqueue-0.3.2 → localqueue-0.3.4}/tests/test_process_harness.py +0 -0
@@ -1,5 +1,16 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.4
4
+
5
+ - Keep `queue exec` and `queue process` alive in `--forever --block` mode when the queue starts empty.
6
+ - Preserve the batch empty-queue exit behavior for non-forever workers.
7
+
8
+ ## 0.3.3
9
+
10
+ - Split queue and retry store implementations into dedicated modules.
11
+ - Refactor the CLI worker option wiring and centralize worker helpers.
12
+ - Add a SonarCloud workflow and stabilize the SQLite concurrency queue test.
13
+
3
14
  ## 0.3.2
4
15
 
5
16
  - Add a CLI Docker image and publish it to GHCR on version tags.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: localqueue
3
- Version: 0.3.2
3
+ Version: 0.3.4
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/
@@ -48,12 +48,6 @@ Use it for scripts, CLI tools, cron jobs, and small Python workers that share on
48
48
  localqueue queue exec emails -- python scripts/send_email.py
49
49
  ```
50
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
-
57
51
  ```python
58
52
  from localqueue import PersistentQueue, persistent_worker
59
53
 
@@ -85,6 +79,15 @@ For the optional LMDB backend:
85
79
  pip install "localqueue[lmdb]"
86
80
  ```
87
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
+
88
91
  ## When not to use
89
92
 
90
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.
@@ -13,12 +13,6 @@ Use it for scripts, CLI tools, cron jobs, and small Python workers that share on
13
13
  localqueue queue exec emails -- python scripts/send_email.py
14
14
  ```
15
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
-
22
16
  ```python
23
17
  from localqueue import PersistentQueue, persistent_worker
24
18
 
@@ -50,6 +44,15 @@ For the optional LMDB backend:
50
44
  pip install "localqueue[lmdb]"
51
45
  ```
52
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
+
53
56
  ## When not to use
54
57
 
55
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.2`.
111
+ The current planned release is `0.3.4`.
@@ -1,7 +1,7 @@
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
6
  from typing import Any
7
7
 
@@ -22,6 +22,7 @@ WorkerJobFn = Callable[[str, dict[str, str]], None]
22
22
  DEFAULT_EMAIL_FAILURE_RATE = 0.8
23
23
  DEFAULT_VIDEO_FAILURE_RATE = 0.7
24
24
  DEFAULT_WORKER_FAILURE_RATE = 0.65
25
+ DEMO_RANDOM = SystemRandom()
25
26
 
26
27
 
27
28
  def build_fast_examples(
@@ -38,7 +39,7 @@ def build_fast_examples(
38
39
  )
39
40
  def send_email_fast(task_id: str, email_address: str) -> None:
40
41
  print(f"[send_email_fast] task={task_id} email={email_address}")
41
- if random.random() < email_failure_rate:
42
+ if DEMO_RANDOM.random() < email_failure_rate:
42
43
  raise ConnectionError("mail server down")
43
44
  print(f"[send_email_fast] task={task_id} sent")
44
45
 
@@ -50,7 +51,7 @@ def build_fast_examples(
50
51
  )
51
52
  def process_video_fast(task_id: str, file_path: str) -> None:
52
53
  print(f"[process_video_fast] task={task_id} file={file_path}")
53
- if random.random() < video_failure_rate:
54
+ if DEMO_RANDOM.random() < video_failure_rate:
54
55
  raise ValueError("transcoder busy")
55
56
  print(f"[process_video_fast] task={task_id} processed")
56
57
 
@@ -71,7 +72,7 @@ def build_slow_examples(
71
72
  )
72
73
  def send_email_slow(task_id: str, email_address: str) -> None:
73
74
  print(f"[send_email_slow] task={task_id} email={email_address}")
74
- if random.random() < email_failure_rate:
75
+ if DEMO_RANDOM.random() < email_failure_rate:
75
76
  raise ConnectionError("mail server down")
76
77
  print(f"[send_email_slow] task={task_id} sent")
77
78
 
@@ -83,7 +84,7 @@ def build_slow_examples(
83
84
  )
84
85
  def process_video_slow(task_id: str, file_path: str) -> None:
85
86
  print(f"[process_video_slow] task={task_id} file={file_path}")
86
- if random.random() < video_failure_rate:
87
+ if DEMO_RANDOM.random() < video_failure_rate:
87
88
  raise ValueError("transcoder busy")
88
89
  print(f"[process_video_slow] task={task_id} processed")
89
90
 
@@ -103,7 +104,7 @@ def build_worker_example(
103
104
  print(
104
105
  f"[worker] job={task_id} kind={payload['kind']} target={payload['target']}"
105
106
  )
106
- if random.random() < worker_failure_rate:
107
+ if DEMO_RANDOM.random() < worker_failure_rate:
107
108
  raise RuntimeError("transient worker failure")
108
109
  print(f"[worker] job={task_id} completed")
109
110
 
@@ -183,40 +184,49 @@ def run_worker_demo(
183
184
  f"[worker] message={task_id} kind={payload['kind']} "
184
185
  + f"target={payload['target']}"
185
186
  )
186
- if random.random() < worker_failure_rate:
187
+ if DEMO_RANDOM.random() < worker_failure_rate:
187
188
  raise RuntimeError("transient worker failure")
188
189
  print(f"[worker] message={task_id} completed")
189
190
 
190
191
  for cycle in range(1, 8):
191
- print(f"\n[worker] poll cycle {cycle}")
192
- processed = 0
193
-
194
- while True:
195
- try:
196
- message = queue.get_message(block=False)
197
- except Empty:
198
- break
199
-
200
- payload = message.value
201
- try:
202
- _ = retryer(process_worker_job, message.id, payload)
203
- except PersistentRetryExhausted as exc:
204
- _ = queue.dead_letter(message)
205
- print(
206
- f"[queue] message={exc.key} moved to dead letter "
207
- + f"after {exc.attempts} attempts"
208
- )
209
- except Exception as exc:
210
- _ = queue.release(message, delay=1)
211
- print(f"[queue] message={message.id} released for retry: {exc}")
212
- else:
213
- _ = queue.ack(message)
214
- print(f"[queue] message={message.id} acked")
215
- processed += 1
216
-
217
- if processed == 0:
218
- print("[worker] no tasks available")
219
- 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)
220
230
 
221
231
 
222
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())