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.
- {localqueue-0.3.2 → localqueue-0.3.4}/CHANGELOG.md +11 -0
- {localqueue-0.3.2 → localqueue-0.3.4}/PKG-INFO +10 -7
- {localqueue-0.3.2 → localqueue-0.3.4}/README.md +9 -6
- {localqueue-0.3.2 → localqueue-0.3.4}/docs/release.md +18 -14
- {localqueue-0.3.2 → localqueue-0.3.4}/examples/retry_demo.py +46 -36
- localqueue-0.3.4/examples/sqlite_concurrency_benchmark.py +313 -0
- {localqueue-0.3.2 → localqueue-0.3.4}/examples/sqlite_process_harness.py +29 -14
- {localqueue-0.3.2 → localqueue-0.3.4}/localqueue/__init__.py +1 -1
- localqueue-0.3.4/localqueue/cli.py +295 -0
- localqueue-0.3.4/localqueue/cli_commands.py +583 -0
- localqueue-0.3.4/localqueue/cli_support.py +242 -0
- localqueue-0.3.4/localqueue/cli_worker_commands.py +318 -0
- {localqueue-0.3.2 → localqueue-0.3.4}/localqueue/queue.py +15 -14
- {localqueue-0.3.2 → localqueue-0.3.4}/localqueue/retry/__init__.py +1 -1
- localqueue-0.3.4/localqueue/retry/stores/__init__.py +13 -0
- localqueue-0.3.4/localqueue/retry/stores/_shared.py +22 -0
- localqueue-0.3.4/localqueue/retry/stores/base.py +41 -0
- localqueue-0.3.4/localqueue/retry/stores/lmdb.py +80 -0
- localqueue-0.3.4/localqueue/retry/stores/memory.py +51 -0
- localqueue-0.3.4/localqueue/retry/stores/sqlite.py +148 -0
- {localqueue-0.3.2 → localqueue-0.3.4}/localqueue/retry/tenacity.py +51 -22
- localqueue-0.3.4/localqueue/services/__init__.py +1 -0
- localqueue-0.3.4/localqueue/services/queue_worker.py +622 -0
- localqueue-0.3.4/localqueue/stores/__init__.py +14 -0
- localqueue-0.3.4/localqueue/stores/_shared.py +404 -0
- localqueue-0.3.4/localqueue/stores/base.py +137 -0
- localqueue-0.3.4/localqueue/stores/lmdb.py +555 -0
- localqueue-0.3.4/localqueue/stores/memory.py +318 -0
- localqueue-0.3.4/localqueue/stores/sqlite.py +715 -0
- {localqueue-0.3.2 → localqueue-0.3.4}/localqueue/worker.py +30 -11
- {localqueue-0.3.2 → localqueue-0.3.4}/pyproject.toml +1 -1
- {localqueue-0.3.2 → localqueue-0.3.4}/tests/test_cli.py +261 -127
- {localqueue-0.3.2 → localqueue-0.3.4}/tests/test_queue.py +228 -125
- {localqueue-0.3.2 → localqueue-0.3.4}/tests/test_retry.py +88 -49
- {localqueue-0.3.2 → localqueue-0.3.4}/uv.lock +1 -1
- localqueue-0.3.2/examples/sqlite_concurrency_benchmark.py +0 -181
- localqueue-0.3.2/localqueue/cli.py +0 -1578
- localqueue-0.3.2/localqueue/retry/store.py +0 -314
- localqueue-0.3.2/localqueue/store.py +0 -2013
- {localqueue-0.3.2 → localqueue-0.3.4}/.gitignore +0 -0
- {localqueue-0.3.2 → localqueue-0.3.4}/LICENSE +0 -0
- {localqueue-0.3.2 → localqueue-0.3.4}/docs/api.md +0 -0
- {localqueue-0.3.2 → localqueue-0.3.4}/docs/compare.md +0 -0
- {localqueue-0.3.2 → localqueue-0.3.4}/docs/index.md +0 -0
- {localqueue-0.3.2 → localqueue-0.3.4}/docs/operational-maturity.md +0 -0
- {localqueue-0.3.2 → localqueue-0.3.4}/docs/queues.md +0 -0
- {localqueue-0.3.2 → localqueue-0.3.4}/docs/retries.md +0 -0
- {localqueue-0.3.2 → localqueue-0.3.4}/docs/stability.md +0 -0
- {localqueue-0.3.2 → localqueue-0.3.4}/examples/email_worker.py +0 -0
- {localqueue-0.3.2 → localqueue-0.3.4}/examples/enqueue_email.py +0 -0
- {localqueue-0.3.2 → localqueue-0.3.4}/examples/process_webhook.sh +0 -0
- {localqueue-0.3.2 → localqueue-0.3.4}/localqueue/failure.py +0 -0
- {localqueue-0.3.2 → localqueue-0.3.4}/localqueue/paths.py +0 -0
- {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.
|
|
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.
|
|
31
|
-
3.
|
|
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
|
-
|
|
34
|
+
5. Run the test suite:
|
|
34
35
|
|
|
35
36
|
```bash
|
|
36
37
|
uv run pytest
|
|
37
38
|
```
|
|
38
39
|
|
|
39
|
-
|
|
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
|
-
|
|
47
|
+
7. Build the docs:
|
|
47
48
|
|
|
48
49
|
```bash
|
|
49
50
|
uv run zensical build --clean
|
|
50
51
|
```
|
|
51
52
|
|
|
52
|
-
|
|
53
|
+
8. Confirm the lockfile is current:
|
|
53
54
|
|
|
54
55
|
```bash
|
|
55
56
|
uv lock --check
|
|
56
57
|
```
|
|
57
58
|
|
|
58
|
-
|
|
59
|
+
9. Build the release artifacts:
|
|
59
60
|
|
|
60
61
|
```bash
|
|
61
62
|
uv build
|
|
62
63
|
```
|
|
63
64
|
|
|
64
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
94
|
+
13. Create the release tag:
|
|
94
95
|
|
|
95
96
|
```bash
|
|
96
97
|
git tag v<version>
|
|
97
98
|
```
|
|
98
99
|
|
|
99
|
-
|
|
100
|
+
14. Publish the artifacts.
|
|
100
101
|
|
|
101
|
-
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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())
|