localqueue 0.3.0__tar.gz → 0.3.1__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.0 → localqueue-0.3.1}/.gitignore +2 -0
- {localqueue-0.3.0 → localqueue-0.3.1}/CHANGELOG.md +12 -1
- {localqueue-0.3.0 → localqueue-0.3.1}/PKG-INFO +1 -1
- {localqueue-0.3.0 → localqueue-0.3.1}/docs/api.md +12 -6
- {localqueue-0.3.0 → localqueue-0.3.1}/docs/operational-maturity.md +1 -1
- {localqueue-0.3.0 → localqueue-0.3.1}/docs/queues.md +13 -7
- {localqueue-0.3.0 → localqueue-0.3.1}/docs/retries.md +16 -10
- {localqueue-0.3.0 → localqueue-0.3.1}/examples/enqueue_email.py +6 -2
- localqueue-0.3.1/examples/retry_demo.py +278 -0
- {localqueue-0.3.0 → localqueue-0.3.1}/localqueue/__init__.py +2 -0
- {localqueue-0.3.0 → localqueue-0.3.1}/localqueue/cli.py +92 -80
- {localqueue-0.3.0 → localqueue-0.3.1}/localqueue/failure.py +0 -12
- localqueue-0.3.1/localqueue/paths.py +23 -0
- {localqueue-0.3.0 → localqueue-0.3.1}/localqueue/queue.py +10 -9
- {localqueue-0.3.0 → localqueue-0.3.1}/localqueue/retry/__init__.py +2 -0
- {localqueue-0.3.0 → localqueue-0.3.1}/localqueue/retry/store.py +81 -30
- {localqueue-0.3.0 → localqueue-0.3.1}/localqueue/retry/tenacity.py +35 -9
- {localqueue-0.3.0 → localqueue-0.3.1}/localqueue/store.py +211 -95
- {localqueue-0.3.0 → localqueue-0.3.1}/pyproject.toml +1 -1
- {localqueue-0.3.0 → localqueue-0.3.1}/tests/test_cli.py +79 -0
- {localqueue-0.3.0 → localqueue-0.3.1}/tests/test_queue.py +125 -14
- {localqueue-0.3.0 → localqueue-0.3.1}/tests/test_retry.py +172 -2
- {localqueue-0.3.0 → localqueue-0.3.1}/LICENSE +0 -0
- {localqueue-0.3.0 → localqueue-0.3.1}/README.md +0 -0
- {localqueue-0.3.0 → localqueue-0.3.1}/docs/compare.md +0 -0
- {localqueue-0.3.0 → localqueue-0.3.1}/docs/index.md +0 -0
- {localqueue-0.3.0 → localqueue-0.3.1}/docs/release.md +0 -0
- {localqueue-0.3.0 → localqueue-0.3.1}/docs/stability.md +0 -0
- {localqueue-0.3.0 → localqueue-0.3.1}/examples/email_worker.py +0 -0
- {localqueue-0.3.0 → localqueue-0.3.1}/examples/process_webhook.sh +0 -0
- {localqueue-0.3.0 → localqueue-0.3.1}/examples/sqlite_concurrency_benchmark.py +0 -0
- {localqueue-0.3.0 → localqueue-0.3.1}/examples/sqlite_process_harness.py +0 -0
- {localqueue-0.3.0 → localqueue-0.3.1}/localqueue/worker.py +0 -0
- {localqueue-0.3.0 → localqueue-0.3.1}/tests/test_process_harness.py +0 -0
- {localqueue-0.3.0 → localqueue-0.3.1}/uv.lock +0 -0
|
@@ -1,6 +1,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
##
|
|
3
|
+
## 0.3.1
|
|
4
|
+
|
|
5
|
+
- Use XDG data directories for default SQLite queue and retry store files.
|
|
6
|
+
- Move the exploratory retry demo from `main.py` to `examples/retry_demo.py`.
|
|
7
|
+
- Make `PersistentQueue` unfinished-message tracking O(1) by message id.
|
|
8
|
+
- Add an end-to-end SQLite worker test covering queue ack and retry cleanup.
|
|
9
|
+
- Make `store_path=` for persistent retries open a SQLite attempt store.
|
|
10
|
+
- Tighten permanent-failure classification to avoid dead-lettering common
|
|
11
|
+
transient application errors such as `ValueError` and `KeyError`.
|
|
12
|
+
- Optimize SQLite queue stats and dead-letter retention queries with
|
|
13
|
+
materialized columns and schema migration support.
|
|
14
|
+
- Add indexed SQLite retry-store retention for exhausted records.
|
|
4
15
|
|
|
5
16
|
## 0.3.0
|
|
6
17
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: localqueue
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.1
|
|
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/
|
|
@@ -21,7 +21,7 @@ Constructor options:
|
|
|
21
21
|
| --- | --- |
|
|
22
22
|
| `name` | queue name inside the store |
|
|
23
23
|
| `store` | queue store instance |
|
|
24
|
-
| `store_path` | path for the
|
|
24
|
+
| `store_path` | explicit path for the SQLite queue store |
|
|
25
25
|
| `lease_timeout` | seconds before an inflight message is redelivered |
|
|
26
26
|
| `maxsize` | maximum number of ready messages; `0` means unbounded |
|
|
27
27
|
| `retry_defaults` | Tenacity retry keyword defaults inherited by workers |
|
|
@@ -183,8 +183,8 @@ SQLite-backed queue store. This is the default backend. Records are serialized
|
|
|
183
183
|
as versioned JSON; values must be JSON-serializable.
|
|
184
184
|
|
|
185
185
|
The SQLite store tracks its on-disk schema version with `PRAGMA user_version`.
|
|
186
|
-
Current releases
|
|
187
|
-
|
|
186
|
+
Current releases migrate older compatible versions and reject future versions
|
|
187
|
+
they do not know how to migrate yet.
|
|
188
188
|
|
|
189
189
|
#### `LMDBQueueStore`
|
|
190
190
|
|
|
@@ -237,7 +237,7 @@ Both decorators require `key=` or `key_fn=` and accept the persistent options be
|
|
|
237
237
|
| Option | Meaning |
|
|
238
238
|
| --- | --- |
|
|
239
239
|
| `store` | attempt store instance |
|
|
240
|
-
| `store_path` | path for
|
|
240
|
+
| `store_path` | explicit path for a SQLite attempt-store file |
|
|
241
241
|
| `key` | fixed retry key |
|
|
242
242
|
| `key_fn` | function that derives a retry key from the call |
|
|
243
243
|
| `clear_on_success` | delete the attempt record after success |
|
|
@@ -341,6 +341,11 @@ Dataclass stored per retry key.
|
|
|
341
341
|
| `first_attempt_at` | `float` | Unix timestamp of the first persisted attempt |
|
|
342
342
|
| `exhausted` | `bool` | whether the retry budget is exhausted |
|
|
343
343
|
|
|
344
|
+
#### `close_default_store(all_threads=False)`
|
|
345
|
+
|
|
346
|
+
Close the default retry store for the current thread. Pass `all_threads=True`
|
|
347
|
+
at process shutdown to close known factory-created default stores.
|
|
348
|
+
|
|
344
349
|
#### `AttemptStore`
|
|
345
350
|
|
|
346
351
|
Protocol for custom attempt stores.
|
|
@@ -365,8 +370,9 @@ store = LMDBAttemptStore("/var/lib/my-worker/retries")
|
|
|
365
370
|
|
|
366
371
|
#### `SQLiteAttemptStore`
|
|
367
372
|
|
|
368
|
-
SQLite-backed attempt store. This is the default backend and does not require
|
|
369
|
-
native dependency.
|
|
373
|
+
SQLite-backed attempt store. This is the default backend and does not require
|
|
374
|
+
LMDB's native dependency. Retention cleanup uses indexed `exhausted` and
|
|
375
|
+
`first_attempt_at` columns instead of scanning every serialized retry record.
|
|
370
376
|
|
|
371
377
|
```python
|
|
372
378
|
from localqueue.retry import SQLiteAttemptStore
|
|
@@ -107,7 +107,7 @@ inspection tools for that local model, not a distributed liveness protocol.
|
|
|
107
107
|
versions.
|
|
108
108
|
|
|
109
109
|
The SQLite store keeps a schema version in `PRAGMA user_version`. Current
|
|
110
|
-
releases
|
|
110
|
+
releases migrate older compatible versions and reject future versions they do
|
|
111
111
|
not know how to migrate yet. That keeps upgrades explicit and leaves a clear
|
|
112
112
|
path for future schema changes.
|
|
113
113
|
|
|
@@ -23,7 +23,6 @@ from localqueue import PersistentQueue
|
|
|
23
23
|
|
|
24
24
|
queue = PersistentQueue(
|
|
25
25
|
"emails",
|
|
26
|
-
store_path="./localqueue_queue.sqlite3",
|
|
27
26
|
lease_timeout=30.0,
|
|
28
27
|
retry_defaults={
|
|
29
28
|
"max_tries": 3,
|
|
@@ -75,10 +74,12 @@ For shared queue-wide retry defaults, attach `retry_defaults` to the
|
|
|
75
74
|
`PersistentQueue` and let workers inherit those Tenacity arguments unless the
|
|
76
75
|
worker passes explicit overrides.
|
|
77
76
|
|
|
78
|
-
`localqueue`
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
77
|
+
`localqueue` keeps built-in permanent-failure classification conservative.
|
|
78
|
+
Import/name-resolution failures and missing command execution are dead-lettered
|
|
79
|
+
even when the worker is configured to release on final failure. Runtime
|
|
80
|
+
validation errors such as `ValueError`, `TypeError`, and `KeyError` follow the
|
|
81
|
+
configured worker policy because they can represent either bad input or a
|
|
82
|
+
recoverable transient condition.
|
|
82
83
|
|
|
83
84
|
Change that behavior with `dead_letter_on_failure=False`. The older
|
|
84
85
|
`dead_letter_on_exhaustion` name is still accepted as a compatibility alias.
|
|
@@ -332,7 +333,12 @@ Negative delays raise `ValueError`.
|
|
|
332
333
|
|
|
333
334
|
## Stores
|
|
334
335
|
|
|
335
|
-
The default queue store is SQLite at
|
|
336
|
+
The default queue store is SQLite at
|
|
337
|
+
`$XDG_DATA_HOME/localqueue/queue.sqlite3`. When `XDG_DATA_HOME` is not set,
|
|
338
|
+
the fallback is `~/.local/share/localqueue/queue.sqlite3`.
|
|
339
|
+
|
|
340
|
+
Pass `store_path=` when the queue file must live somewhere explicit, such as a
|
|
341
|
+
service data directory.
|
|
336
342
|
|
|
337
343
|
```python
|
|
338
344
|
from localqueue import PersistentQueue, SQLiteQueueStore
|
|
@@ -404,7 +410,7 @@ SQLite files can keep running for a long time, but they still need normal file
|
|
|
404
410
|
maintenance.
|
|
405
411
|
|
|
406
412
|
The SQLite store keeps a schema version in `PRAGMA user_version`. Current
|
|
407
|
-
releases
|
|
413
|
+
releases migrate older compatible versions and reject future versions they do
|
|
408
414
|
not know how to migrate yet.
|
|
409
415
|
|
|
410
416
|
- Keep the main `*.sqlite3` file and its retry store together when you back up
|
|
@@ -222,29 +222,29 @@ except PersistentRetryExhausted as exc:
|
|
|
222
222
|
|
|
223
223
|
## Stores
|
|
224
224
|
|
|
225
|
-
The default attempt store is SQLite at
|
|
225
|
+
The default attempt store is SQLite at
|
|
226
|
+
`$XDG_DATA_HOME/localqueue/retries.sqlite3`. When `XDG_DATA_HOME` is not set,
|
|
227
|
+
the fallback is `~/.local/share/localqueue/retries.sqlite3`.
|
|
226
228
|
|
|
227
|
-
`store_path=` creates
|
|
228
|
-
`localqueue[lmdb]` and use it when you want the retry API to manage an LMDB
|
|
229
|
-
backend directly.
|
|
229
|
+
`store_path=` creates a SQLite attempt-store file, matching the default backend.
|
|
230
230
|
|
|
231
231
|
```python
|
|
232
232
|
from localqueue.retry import PersistentRetrying, key_from_argument
|
|
233
233
|
|
|
234
234
|
retryer = PersistentRetrying(
|
|
235
|
-
store_path="/var/lib/my-worker/retries",
|
|
235
|
+
store_path="/var/lib/my-worker/retries.sqlite3",
|
|
236
236
|
max_tries=5,
|
|
237
237
|
key_fn=key_from_argument("job_id"),
|
|
238
238
|
)
|
|
239
239
|
```
|
|
240
240
|
|
|
241
|
-
Provide a store instance with `store=` when you need
|
|
242
|
-
in-memory tests.
|
|
241
|
+
Provide a store instance with `store=` when you need LMDB, full control, or
|
|
242
|
+
in-memory tests. Install `localqueue[lmdb]` when you want the LMDB backend.
|
|
243
243
|
|
|
244
244
|
```python
|
|
245
|
-
from localqueue.retry import
|
|
245
|
+
from localqueue.retry import LMDBAttemptStore, key_from_argument, persistent_retry
|
|
246
246
|
|
|
247
|
-
store =
|
|
247
|
+
store = LMDBAttemptStore("/var/lib/my-worker/retries")
|
|
248
248
|
|
|
249
249
|
|
|
250
250
|
@persistent_retry(
|
|
@@ -257,7 +257,8 @@ def flaky(job_id: str) -> str:
|
|
|
257
257
|
```
|
|
258
258
|
|
|
259
259
|
The CLI `retry_store_path` setting and `queue process --retry-store-path` use a
|
|
260
|
-
SQLite file path.
|
|
260
|
+
SQLite file path. `queue process` and `queue exec` keep one retry-store
|
|
261
|
+
connection open for the worker loop and close it when the loop exits.
|
|
261
262
|
|
|
262
263
|
```python
|
|
263
264
|
from localqueue.retry import MemoryAttemptStore, key_from_argument, persistent_retry
|
|
@@ -277,6 +278,11 @@ def flaky(job_id: str) -> str:
|
|
|
277
278
|
|
|
278
279
|
`store=` and `store_path=` are mutually exclusive.
|
|
279
280
|
|
|
281
|
+
Default retry stores are thread-local. Call `close_default_store()` when a
|
|
282
|
+
long-lived thread is shutting down and you want to close its default SQLite
|
|
283
|
+
connection explicitly. At process shutdown, `close_default_store(all_threads=True)`
|
|
284
|
+
closes known factory-created default stores.
|
|
285
|
+
|
|
280
286
|
## State and callbacks
|
|
281
287
|
|
|
282
288
|
Tenacity callbacks and strategies receive a state object that behaves like Tenacity's `RetryCallState`, with persistent attempt numbering.
|
|
@@ -8,11 +8,15 @@ from localqueue import PersistentQueue
|
|
|
8
8
|
def main() -> None:
|
|
9
9
|
parser = argparse.ArgumentParser()
|
|
10
10
|
parser.add_argument("address")
|
|
11
|
-
parser.add_argument("--store-path"
|
|
11
|
+
parser.add_argument("--store-path")
|
|
12
12
|
parser.add_argument("--fail", action="store_true")
|
|
13
13
|
args = parser.parse_args()
|
|
14
14
|
|
|
15
|
-
queue =
|
|
15
|
+
queue = (
|
|
16
|
+
PersistentQueue("emails", store_path=args.store_path)
|
|
17
|
+
if args.store_path is not None
|
|
18
|
+
else PersistentQueue("emails")
|
|
19
|
+
)
|
|
16
20
|
message = queue.put({"to": args.address, "fail": args.fail})
|
|
17
21
|
print(message.id)
|
|
18
22
|
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import random
|
|
3
|
+
import time
|
|
4
|
+
from queue import Empty
|
|
5
|
+
from typing import Callable
|
|
6
|
+
|
|
7
|
+
from localqueue.retry import (
|
|
8
|
+
AttemptStoreLockedError,
|
|
9
|
+
PersistentRetrying,
|
|
10
|
+
PersistentRetryExhausted,
|
|
11
|
+
key_from_argument,
|
|
12
|
+
persistent_retry,
|
|
13
|
+
)
|
|
14
|
+
from localqueue import PersistentQueue, QueueStoreLockedError
|
|
15
|
+
from tenacity import wait_exponential, wait_none
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
SendEmailFn = Callable[[str, str], None]
|
|
19
|
+
ProcessVideoFn = Callable[[str, str], None]
|
|
20
|
+
WorkerJobFn = Callable[[str, dict[str, str]], None]
|
|
21
|
+
DEFAULT_EMAIL_FAILURE_RATE = 0.8
|
|
22
|
+
DEFAULT_VIDEO_FAILURE_RATE = 0.7
|
|
23
|
+
DEFAULT_WORKER_FAILURE_RATE = 0.65
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def build_fast_examples(
|
|
27
|
+
db_path: str,
|
|
28
|
+
*,
|
|
29
|
+
email_failure_rate: float = DEFAULT_EMAIL_FAILURE_RATE,
|
|
30
|
+
video_failure_rate: float = DEFAULT_VIDEO_FAILURE_RATE,
|
|
31
|
+
) -> tuple[SendEmailFn, ProcessVideoFn]:
|
|
32
|
+
@persistent_retry(
|
|
33
|
+
max_tries=3,
|
|
34
|
+
store_path=db_path,
|
|
35
|
+
wait=wait_none(),
|
|
36
|
+
key_fn=key_from_argument("task_id"),
|
|
37
|
+
)
|
|
38
|
+
def send_email_fast(task_id: str, email_address: str) -> None:
|
|
39
|
+
print(f"[send_email_fast] task={task_id} email={email_address}")
|
|
40
|
+
if random.random() < email_failure_rate:
|
|
41
|
+
raise ConnectionError("mail server down")
|
|
42
|
+
print(f"[send_email_fast] task={task_id} sent")
|
|
43
|
+
|
|
44
|
+
@persistent_retry(
|
|
45
|
+
max_tries=5,
|
|
46
|
+
store_path=db_path,
|
|
47
|
+
wait=wait_none(),
|
|
48
|
+
key_fn=key_from_argument("task_id"),
|
|
49
|
+
)
|
|
50
|
+
def process_video_fast(task_id: str, file_path: str) -> None:
|
|
51
|
+
print(f"[process_video_fast] task={task_id} file={file_path}")
|
|
52
|
+
if random.random() < video_failure_rate:
|
|
53
|
+
raise ValueError("transcoder busy")
|
|
54
|
+
print(f"[process_video_fast] task={task_id} processed")
|
|
55
|
+
|
|
56
|
+
return send_email_fast, process_video_fast
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def build_slow_examples(
|
|
60
|
+
db_path: str,
|
|
61
|
+
*,
|
|
62
|
+
email_failure_rate: float = DEFAULT_EMAIL_FAILURE_RATE,
|
|
63
|
+
video_failure_rate: float = DEFAULT_VIDEO_FAILURE_RATE,
|
|
64
|
+
) -> tuple[SendEmailFn, ProcessVideoFn]:
|
|
65
|
+
@persistent_retry(
|
|
66
|
+
max_tries=3,
|
|
67
|
+
store_path=db_path,
|
|
68
|
+
wait=wait_exponential(multiplier=1, min=2, max=10),
|
|
69
|
+
key_fn=key_from_argument("task_id"),
|
|
70
|
+
)
|
|
71
|
+
def send_email_slow(task_id: str, email_address: str) -> None:
|
|
72
|
+
print(f"[send_email_slow] task={task_id} email={email_address}")
|
|
73
|
+
if random.random() < email_failure_rate:
|
|
74
|
+
raise ConnectionError("mail server down")
|
|
75
|
+
print(f"[send_email_slow] task={task_id} sent")
|
|
76
|
+
|
|
77
|
+
@persistent_retry(
|
|
78
|
+
max_tries=5,
|
|
79
|
+
store_path=db_path,
|
|
80
|
+
wait=wait_exponential(multiplier=1, min=2, max=10),
|
|
81
|
+
key_fn=key_from_argument("task_id"),
|
|
82
|
+
)
|
|
83
|
+
def process_video_slow(task_id: str, file_path: str) -> None:
|
|
84
|
+
print(f"[process_video_slow] task={task_id} file={file_path}")
|
|
85
|
+
if random.random() < video_failure_rate:
|
|
86
|
+
raise ValueError("transcoder busy")
|
|
87
|
+
print(f"[process_video_slow] task={task_id} processed")
|
|
88
|
+
|
|
89
|
+
return send_email_slow, process_video_slow
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def build_worker_example(
|
|
93
|
+
db_path: str, *, worker_failure_rate: float = DEFAULT_WORKER_FAILURE_RATE
|
|
94
|
+
) -> WorkerJobFn:
|
|
95
|
+
@persistent_retry(
|
|
96
|
+
max_tries=4,
|
|
97
|
+
store_path=db_path,
|
|
98
|
+
wait=wait_none(),
|
|
99
|
+
key_fn=key_from_argument("task_id"),
|
|
100
|
+
)
|
|
101
|
+
def process_worker_job(task_id: str, payload: dict[str, str]) -> None:
|
|
102
|
+
print(
|
|
103
|
+
f"[worker] job={task_id} kind={payload['kind']} target={payload['target']}"
|
|
104
|
+
)
|
|
105
|
+
if random.random() < worker_failure_rate:
|
|
106
|
+
raise RuntimeError("transient worker failure")
|
|
107
|
+
print(f"[worker] job={task_id} completed")
|
|
108
|
+
|
|
109
|
+
return process_worker_job
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def run_pair(
|
|
113
|
+
prefix: str, send_email_fn: SendEmailFn, process_video_fn: ProcessVideoFn
|
|
114
|
+
) -> None:
|
|
115
|
+
print(f"\n=== {prefix} ===")
|
|
116
|
+
for i in range(1, 4):
|
|
117
|
+
email_task_id = f"{prefix}:send-email:{i}"
|
|
118
|
+
video_task_id = f"{prefix}:process-video:{i}"
|
|
119
|
+
|
|
120
|
+
try:
|
|
121
|
+
_ = send_email_fn(email_task_id, "user@example.com")
|
|
122
|
+
except PersistentRetryExhausted as exc:
|
|
123
|
+
print(f"[{prefix}] {exc.key} exhausted after {exc.attempts} attempts")
|
|
124
|
+
except Exception as exc:
|
|
125
|
+
print(f"[{prefix}] {email_task_id} failed this run: {exc}")
|
|
126
|
+
|
|
127
|
+
try:
|
|
128
|
+
_ = process_video_fn(video_task_id, f"video_{i}.mp4")
|
|
129
|
+
except PersistentRetryExhausted as exc:
|
|
130
|
+
print(f"[{prefix}] {exc.key} exhausted after {exc.attempts} attempts")
|
|
131
|
+
except Exception as exc:
|
|
132
|
+
print(f"[{prefix}] {video_task_id} failed this run: {exc}")
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def run_demo(
|
|
136
|
+
db_path: str,
|
|
137
|
+
*,
|
|
138
|
+
email_failure_rate: float = DEFAULT_EMAIL_FAILURE_RATE,
|
|
139
|
+
video_failure_rate: float = DEFAULT_VIDEO_FAILURE_RATE,
|
|
140
|
+
) -> None:
|
|
141
|
+
send_email_fast, process_video_fast = build_fast_examples(
|
|
142
|
+
db_path,
|
|
143
|
+
email_failure_rate=email_failure_rate,
|
|
144
|
+
video_failure_rate=video_failure_rate,
|
|
145
|
+
)
|
|
146
|
+
send_email_slow, process_video_slow = build_slow_examples(
|
|
147
|
+
db_path,
|
|
148
|
+
email_failure_rate=email_failure_rate,
|
|
149
|
+
video_failure_rate=video_failure_rate,
|
|
150
|
+
)
|
|
151
|
+
run_pair("fast", send_email_fast, process_video_fast)
|
|
152
|
+
run_pair("slow", send_email_slow, process_video_slow)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def run_worker_demo(
|
|
156
|
+
db_path: str, *, worker_failure_rate: float = DEFAULT_WORKER_FAILURE_RATE
|
|
157
|
+
) -> None:
|
|
158
|
+
print("\n=== persistent queue worker ===")
|
|
159
|
+
queue = PersistentQueue(
|
|
160
|
+
"worker-jobs",
|
|
161
|
+
store_path=f"{db_path}_queue",
|
|
162
|
+
lease_timeout=2,
|
|
163
|
+
)
|
|
164
|
+
retryer = PersistentRetrying(
|
|
165
|
+
store_path=f"{db_path}_queue_retries",
|
|
166
|
+
max_tries=4,
|
|
167
|
+
wait=wait_none(),
|
|
168
|
+
key_fn=key_from_argument("task_id"),
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
if queue.empty():
|
|
172
|
+
print("[queue] enqueue sample jobs")
|
|
173
|
+
_ = queue.put({"kind": "email", "target": "alpha@example.com"})
|
|
174
|
+
_ = queue.put({"kind": "video", "target": "video_2.mp4"})
|
|
175
|
+
_ = queue.put({"kind": "webhook", "target": "customer-3"}, delay=1)
|
|
176
|
+
_ = queue.put({"kind": "invoice", "target": "invoice-4"}, delay=2)
|
|
177
|
+
else:
|
|
178
|
+
print(f"[queue] resuming {queue.qsize()} ready job(s)")
|
|
179
|
+
|
|
180
|
+
def process_worker_job(task_id: str, payload: dict[str, str]) -> None:
|
|
181
|
+
print(
|
|
182
|
+
f"[worker] message={task_id} kind={payload['kind']} "
|
|
183
|
+
+ f"target={payload['target']}"
|
|
184
|
+
)
|
|
185
|
+
if random.random() < worker_failure_rate:
|
|
186
|
+
raise RuntimeError("transient worker failure")
|
|
187
|
+
print(f"[worker] message={task_id} completed")
|
|
188
|
+
|
|
189
|
+
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)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def parse_args() -> argparse.Namespace:
|
|
222
|
+
parser = argparse.ArgumentParser(
|
|
223
|
+
description="localqueue demo CLI; queue code lives in localqueue/ and retry code in localqueue/retry/"
|
|
224
|
+
)
|
|
225
|
+
_ = parser.add_argument(
|
|
226
|
+
"--worker",
|
|
227
|
+
action="store_true",
|
|
228
|
+
help="run a worker-style example instead of the fast/slow demo",
|
|
229
|
+
)
|
|
230
|
+
_ = parser.add_argument(
|
|
231
|
+
"--db-path",
|
|
232
|
+
default="examples/localqueue_retry_demo.sqlite3",
|
|
233
|
+
help="SQLite file for this demo's persisted retry state",
|
|
234
|
+
)
|
|
235
|
+
_ = parser.add_argument(
|
|
236
|
+
"--email-failure-rate",
|
|
237
|
+
type=float,
|
|
238
|
+
default=DEFAULT_EMAIL_FAILURE_RATE,
|
|
239
|
+
help="demo-only probability that email jobs fail",
|
|
240
|
+
)
|
|
241
|
+
_ = parser.add_argument(
|
|
242
|
+
"--video-failure-rate",
|
|
243
|
+
type=float,
|
|
244
|
+
default=DEFAULT_VIDEO_FAILURE_RATE,
|
|
245
|
+
help="demo-only probability that video jobs fail",
|
|
246
|
+
)
|
|
247
|
+
_ = parser.add_argument(
|
|
248
|
+
"--worker-failure-rate",
|
|
249
|
+
type=float,
|
|
250
|
+
default=DEFAULT_WORKER_FAILURE_RATE,
|
|
251
|
+
help="demo-only probability that worker jobs fail",
|
|
252
|
+
)
|
|
253
|
+
return parser.parse_args()
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def main() -> int:
|
|
257
|
+
args = parse_args()
|
|
258
|
+
try:
|
|
259
|
+
if args.worker:
|
|
260
|
+
run_worker_demo(args.db_path, worker_failure_rate=args.worker_failure_rate)
|
|
261
|
+
else:
|
|
262
|
+
run_demo(
|
|
263
|
+
args.db_path,
|
|
264
|
+
email_failure_rate=args.email_failure_rate,
|
|
265
|
+
video_failure_rate=args.video_failure_rate,
|
|
266
|
+
)
|
|
267
|
+
except (AttemptStoreLockedError, QueueStoreLockedError) as exc:
|
|
268
|
+
print(exc)
|
|
269
|
+
print(
|
|
270
|
+
"Tip: rerun with --db-path pointing at a different file, "
|
|
271
|
+
+ "for example 'examples/localqueue_retry_demo_2.sqlite3'."
|
|
272
|
+
)
|
|
273
|
+
return 1
|
|
274
|
+
return 0
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
if __name__ == "__main__":
|
|
278
|
+
raise SystemExit(main())
|
|
@@ -16,6 +16,7 @@ from .retry import (
|
|
|
16
16
|
PersistentRetrying,
|
|
17
17
|
RetryRecord,
|
|
18
18
|
SQLiteAttemptStore,
|
|
19
|
+
close_default_store,
|
|
19
20
|
configure_default_store,
|
|
20
21
|
configure_default_store_factory,
|
|
21
22
|
idempotency_key_from_id,
|
|
@@ -55,6 +56,7 @@ __all__ = [
|
|
|
55
56
|
"SQLiteAttemptStore",
|
|
56
57
|
"SQLiteQueueStore",
|
|
57
58
|
"__version__",
|
|
59
|
+
"close_default_store",
|
|
58
60
|
"configure_default_store",
|
|
59
61
|
"configure_default_store_factory",
|
|
60
62
|
"idempotency_key_from_id",
|