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.
- {localqueue-0.3.1 → localqueue-0.3.3}/.gitignore +1 -0
- {localqueue-0.3.1 → localqueue-0.3.3}/CHANGELOG.md +11 -0
- {localqueue-0.3.1 → localqueue-0.3.3}/PKG-INFO +12 -2
- {localqueue-0.3.1 → localqueue-0.3.3}/README.md +11 -1
- {localqueue-0.3.1 → localqueue-0.3.3}/docs/release.md +18 -14
- {localqueue-0.3.1 → localqueue-0.3.3}/examples/enqueue_email.py +2 -1
- {localqueue-0.3.1 → localqueue-0.3.3}/examples/retry_demo.py +48 -37
- localqueue-0.3.3/examples/sqlite_concurrency_benchmark.py +313 -0
- {localqueue-0.3.1 → localqueue-0.3.3}/examples/sqlite_process_harness.py +33 -16
- {localqueue-0.3.1 → localqueue-0.3.3}/localqueue/__init__.py +1 -1
- localqueue-0.3.3/localqueue/cli.py +295 -0
- localqueue-0.3.3/localqueue/cli_commands.py +583 -0
- localqueue-0.3.3/localqueue/cli_support.py +242 -0
- localqueue-0.3.3/localqueue/cli_worker_commands.py +318 -0
- {localqueue-0.3.1 → localqueue-0.3.3}/localqueue/failure.py +6 -2
- {localqueue-0.3.1 → localqueue-0.3.3}/localqueue/queue.py +24 -21
- {localqueue-0.3.1 → localqueue-0.3.3}/localqueue/retry/__init__.py +1 -1
- localqueue-0.3.3/localqueue/retry/stores/__init__.py +13 -0
- localqueue-0.3.3/localqueue/retry/stores/_shared.py +22 -0
- localqueue-0.3.3/localqueue/retry/stores/base.py +41 -0
- localqueue-0.3.3/localqueue/retry/stores/lmdb.py +80 -0
- localqueue-0.3.3/localqueue/retry/stores/memory.py +51 -0
- localqueue-0.3.1/localqueue/retry/store.py → localqueue-0.3.3/localqueue/retry/stores/sqlite.py +3 -168
- {localqueue-0.3.1 → localqueue-0.3.3}/localqueue/retry/tenacity.py +122 -39
- localqueue-0.3.3/localqueue/services/__init__.py +1 -0
- localqueue-0.3.3/localqueue/services/queue_worker.py +620 -0
- localqueue-0.3.3/localqueue/stores/__init__.py +14 -0
- localqueue-0.3.3/localqueue/stores/_shared.py +404 -0
- localqueue-0.3.3/localqueue/stores/base.py +137 -0
- localqueue-0.3.3/localqueue/stores/lmdb.py +555 -0
- localqueue-0.3.3/localqueue/stores/memory.py +318 -0
- localqueue-0.3.3/localqueue/stores/sqlite.py +715 -0
- {localqueue-0.3.1 → localqueue-0.3.3}/localqueue/worker.py +42 -15
- {localqueue-0.3.1 → localqueue-0.3.3}/pyproject.toml +42 -1
- {localqueue-0.3.1 → localqueue-0.3.3}/tests/test_cli.py +744 -95
- {localqueue-0.3.1 → localqueue-0.3.3}/tests/test_queue.py +1191 -157
- {localqueue-0.3.1 → localqueue-0.3.3}/tests/test_retry.py +500 -12
- {localqueue-0.3.1 → localqueue-0.3.3}/uv.lock +1 -1
- localqueue-0.3.1/examples/sqlite_concurrency_benchmark.py +0 -181
- localqueue-0.3.1/localqueue/cli.py +0 -1367
- localqueue-0.3.1/localqueue/store.py +0 -2012
- {localqueue-0.3.1 → localqueue-0.3.3}/LICENSE +0 -0
- {localqueue-0.3.1 → localqueue-0.3.3}/docs/api.md +0 -0
- {localqueue-0.3.1 → localqueue-0.3.3}/docs/compare.md +0 -0
- {localqueue-0.3.1 → localqueue-0.3.3}/docs/index.md +0 -0
- {localqueue-0.3.1 → localqueue-0.3.3}/docs/operational-maturity.md +0 -0
- {localqueue-0.3.1 → localqueue-0.3.3}/docs/queues.md +0 -0
- {localqueue-0.3.1 → localqueue-0.3.3}/docs/retries.md +0 -0
- {localqueue-0.3.1 → localqueue-0.3.3}/docs/stability.md +0 -0
- {localqueue-0.3.1 → localqueue-0.3.3}/examples/email_worker.py +0 -0
- {localqueue-0.3.1 → localqueue-0.3.3}/examples/process_webhook.sh +0 -0
- {localqueue-0.3.1 → localqueue-0.3.3}/localqueue/paths.py +0 -0
- {localqueue-0.3.1 → localqueue-0.3.3}/tests/test_process_harness.py +0 -0
|
@@ -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.
|
|
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
|
[](https://github.com/brunoportis/localqueue/actions/workflows/tests.yml)
|
|
39
39
|
[](https://github.com/brunoportis/localqueue/actions/workflows/github-code-scanning/codeql)
|
|
40
|
-
](https://pypi.org/project/localqueue/)
|
|
41
|
+

|
|
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
|
[](https://github.com/brunoportis/localqueue/actions/workflows/tests.yml)
|
|
4
4
|
[](https://github.com/brunoportis/localqueue/actions/workflows/github-code-scanning/codeql)
|
|
5
|
-
](https://pypi.org/project/localqueue/)
|
|
6
|
+

|
|
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.
|
|
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.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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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())
|