localqueue 0.1.0__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.
@@ -0,0 +1,20 @@
1
+ # Python-generated files
2
+ __pycache__/
3
+ *.py[oc]
4
+ build/
5
+ dist/
6
+ wheels/
7
+ *.egg-info
8
+ site/
9
+
10
+ # Virtual environments
11
+ .venv
12
+ /talk.md
13
+ /.coverage
14
+ /.cache
15
+ /.codex
16
+ /localqueue_queue
17
+ /localqueue_queue.sqlite3
18
+ /localqueue_retries
19
+ /localqueue_retries.sqlite3
20
+ feedback.md
@@ -0,0 +1,22 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ - Add persistent retry wrappers for sync and async Tenacity retryers.
6
+ - Add SQLite-backed durable local queues with leases, delayed delivery,
7
+ acknowledgements, release, dead-letter records, and requeue from dead-letter
8
+ storage. LMDB remains available as an optional backend.
9
+ - Add CLI commands for config, queue add/pop/ack/release/dead-letter/stats,
10
+ inspect, dead-letter listing, dead-letter requeue, and continuous processing.
11
+ - Add `queue exec` for processing messages with external commands that receive
12
+ the message value as JSON on stdin.
13
+ - Add worker identity metadata with `--worker-id` and `leased_by` for inflight
14
+ message inspection.
15
+ - Document local-worker operational boundaries, at-least-once delivery, and
16
+ idempotency guidance.
17
+ - Add focused examples for enqueueing and processing email jobs locally.
18
+ - Add MIT license metadata for package distribution.
19
+ - Add `dead_letter_on_failure` as the preferred worker failure policy option,
20
+ keeping `dead_letter_on_exhaustion` as a compatibility alias.
21
+ - Enable SQLite WAL journal mode and normal synchronous mode for improved
22
+ concurrency in `SQLiteAttemptStore`.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Bruno Portis
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,356 @@
1
+ Metadata-Version: 2.4
2
+ Name: localqueue
3
+ Version: 0.1.0
4
+ Summary: Durable local queues for Python, with persistent retry state powered by Tenacity.
5
+ Author: Bruno Portis
6
+ License-Expression: MIT
7
+ License-File: LICENSE
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
16
+ Requires-Python: >=3.11
17
+ Requires-Dist: tenacity>=9.1.4
18
+ Provides-Extra: all
19
+ Requires-Dist: lmdb>=2.2.0; extra == 'all'
20
+ Requires-Dist: pyyaml>=6.0.3; extra == 'all'
21
+ Requires-Dist: rich>=15.0.0; extra == 'all'
22
+ Requires-Dist: typer>=0.16.0; extra == 'all'
23
+ Provides-Extra: cli
24
+ Requires-Dist: pyyaml>=6.0.3; extra == 'cli'
25
+ Requires-Dist: rich>=15.0.0; extra == 'cli'
26
+ Requires-Dist: typer>=0.16.0; extra == 'cli'
27
+ Provides-Extra: lmdb
28
+ Requires-Dist: lmdb>=2.2.0; extra == 'lmdb'
29
+ Provides-Extra: sqlite
30
+ Description-Content-Type: text/markdown
31
+
32
+ # `localqueue`
33
+
34
+ [![Tests](https://github.com/brunoportis/localqueue/actions/workflows/tests.yml/badge.svg)](https://github.com/brunoportis/localqueue/actions/workflows/tests.yml)
35
+ ![Coverage](https://img.shields.io/badge/coverage-%E2%89%A595%25-brightgreen)
36
+
37
+ Durable local queues for Python workers, with persistent retry state powered by
38
+ Tenacity.
39
+
40
+ `localqueue` provides two small building blocks for reliable job processing in
41
+ scripts, CLIs, cron jobs, and small worker processes:
42
+
43
+ - a SQLite-backed queue with at-least-once delivery, leases, delayed delivery,
44
+ acknowledgements, release, and dead-letter records
45
+ - `localqueue.retry`: a Tenacity-backed retry adapter that persists retry
46
+ budgets across process restarts
47
+
48
+ Use the queue when one machine needs to persist jobs and process them later. Use
49
+ the retry layer directly when another system already delivers work and you only
50
+ need durable retry state.
51
+
52
+ ## Why this exists
53
+
54
+ Python has good retry tools and in-memory queues, but many worker scripts need a
55
+ small durable local queue without bringing in Celery, Redis, RabbitMQ, SQS, or
56
+ another broker. This project focuses on that local worker shape:
57
+
58
+ ```text
59
+ enqueue job -> lease message -> run handler with retry -> ack or dead-letter
60
+ ```
61
+
62
+ Tenacity already provides the right retry model:
63
+
64
+ - `Retrying` and `AsyncRetrying`
65
+ - configurable `stop`, `wait`, and `retry` strategies
66
+ - callback hooks such as `before`, `after`, and `before_sleep`
67
+ - decorator wrappers with `retry_with`
68
+
69
+ This library keeps that model and uses it as the retry engine for queue workers
70
+ and lower-level retry wrappers.
71
+
72
+ `localqueue` is not distributed coordination. The default store is a local
73
+ SQLite file, so it is best suited to workers running on the same host or against
74
+ the same local filesystem. If the workload needs multi-host scheduling, high
75
+ write throughput, broker-managed retention, stream processing, or hard
76
+ cross-service ordering guarantees, use a broker or database designed for that
77
+ operating model.
78
+
79
+ ## Install
80
+
81
+ ```bash
82
+ pip install localqueue
83
+ ```
84
+
85
+ `localqueue` requires Python 3.11 or newer.
86
+
87
+ Install the optional CLI dependencies with:
88
+
89
+ ```bash
90
+ pip install "localqueue[cli]"
91
+ ```
92
+
93
+ Install `localqueue[lmdb]` when you want the optional LMDB queue or retry
94
+ stores.
95
+
96
+ ## CLI
97
+
98
+ The CLI reads YAML configuration from
99
+ `~/.config/localqueue/config.yaml`. `XDG_CONFIG_HOME` is respected when set.
100
+
101
+ ```bash
102
+ localqueue config init --store-path ./localqueue_queue.sqlite3
103
+ localqueue config show
104
+ localqueue config set retry_store_path ./localqueue_retries.sqlite3
105
+ ```
106
+
107
+ Example config:
108
+
109
+ ```yaml
110
+ store_path: ./localqueue_queue.sqlite3
111
+ retry_store_path: ./localqueue_retries.sqlite3
112
+ ```
113
+
114
+ `store_path` is the SQLite queue file. `retry_store_path` is the SQLite file
115
+ used by `queue process` to persist retry attempts.
116
+
117
+ The CLI starts with queue management commands. Values are JSON by default.
118
+
119
+ ```bash
120
+ localqueue queue add emails --value '{"to":"user@example.com"}'
121
+ echo '{"to":"user@example.com"}' | localqueue queue add emails
122
+ localqueue queue size emails
123
+ localqueue queue stats emails
124
+ localqueue queue inspect emails <message-id>
125
+ localqueue queue dead emails
126
+ localqueue queue requeue-dead emails <message-id>
127
+ localqueue queue pop emails --worker-id worker-1
128
+ localqueue queue ack emails <message-id>
129
+ localqueue queue exec emails -- python scripts/send_email.py
130
+ ```
131
+
132
+ Use `--raw` when the queue value should be stored as a string:
133
+
134
+ ```bash
135
+ localqueue queue add emails --value user@example.com --raw
136
+ ```
137
+
138
+ Queue values are stored as JSON in the queue store, so values must be
139
+ JSON-serializable.
140
+
141
+ To process queued messages, pass an importable handler in `module:function`
142
+ format. The handler receives the message value as its first argument. Successful
143
+ handlers ack the message. Failing handlers release the message unless the
144
+ persistent retry budget is exhausted, in which case the message is moved to
145
+ dead-letter storage by default.
146
+
147
+ ```bash
148
+ localqueue queue process emails myapp.workers:send_email --max-tries 5
149
+ ```
150
+
151
+ Use `queue exec` when the handler is an external command. The message value is
152
+ written to the command's stdin as JSON. Exit code `0` acknowledges the message;
153
+ any other exit code is treated as a processing failure and follows the same
154
+ retry, release, and dead-letter rules as `queue process`.
155
+
156
+ ```bash
157
+ localqueue queue exec emails -- python scripts/send_email.py
158
+ localqueue queue exec webhooks -- curl -X POST https://example.com/hook -d @-
159
+ localqueue queue exec emails -- sh -c 'jq -r .to | xargs -I{} curl https://example.com/{}'
160
+ ```
161
+
162
+ Use `--forever` for a long-running worker. When interrupted with `SIGINT` or
163
+ `SIGTERM`, the CLI finishes the current message before stopping.
164
+
165
+ ```bash
166
+ localqueue queue process emails myapp.workers:send_email \
167
+ --forever \
168
+ --block \
169
+ --worker-id worker-1 \
170
+ --max-tries 5
171
+ ```
172
+
173
+ `--worker-id` is recorded on leased messages as `leased_by`, which makes
174
+ `queue inspect` useful when multiple workers consume the same queue.
175
+
176
+ ## Quickstart
177
+
178
+ Run this from the repository root after installing the optional CLI
179
+ dependencies. It enqueues one email job, processes it with the example handler,
180
+ and leaves no external services running.
181
+
182
+ ```bash
183
+ localqueue queue add emails \
184
+ --store-path /tmp/localqueue-demo \
185
+ --value '{"to":"user@example.com"}'
186
+
187
+ localqueue queue process emails examples.email_worker:send_email \
188
+ --store-path /tmp/localqueue-demo \
189
+ --retry-store-path /tmp/localqueue-demo-retries.sqlite3 \
190
+ --worker-id worker-1 \
191
+ --max-tries 3
192
+ ```
193
+
194
+ Inspect worker ownership while a message is leased:
195
+
196
+ ```bash
197
+ localqueue queue inspect emails <message-id> \
198
+ --store-path /tmp/localqueue-demo
199
+ ```
200
+
201
+ ## Queue worker
202
+
203
+ ```python
204
+ from localqueue import PersistentQueue, PersistentWorkerConfig, persistent_worker
205
+ from tenacity import retry_if_exception_type, stop_after_attempt, wait_exponential
206
+
207
+
208
+ queue = PersistentQueue("emails", store_path="./localqueue_queue.sqlite3")
209
+ queue.put({"to": "user@example.com"})
210
+
211
+ worker_config = PersistentWorkerConfig(
212
+ max_tries=5,
213
+ wait=wait_exponential(multiplier=1, min=1, max=30),
214
+ retry=retry_if_exception_type(ConnectionError),
215
+ )
216
+
217
+
218
+ @persistent_worker(queue, config=worker_config)
219
+ def send_email_job(job: dict[str, str]) -> None:
220
+ send_email(job["to"])
221
+
222
+
223
+ send_email_job()
224
+ ```
225
+
226
+ The worker leases one message, runs the handler with persistent retry state, and
227
+ acknowledges the message on success. If processing keeps failing until the retry
228
+ budget is exhausted, the message is moved to dead-letter storage by default.
229
+ Worker handlers receive `message.value` as their first argument.
230
+ Pass `dead_letter_on_failure=False` to `PersistentWorkerConfig` or
231
+ `persistent_worker()` when final handler failures should release the message
232
+ instead.
233
+
234
+ Use `persistent_async_worker()` for async handlers.
235
+
236
+ ## Manual queue control
237
+
238
+ ```python
239
+ from localqueue import PersistentQueue
240
+
241
+ queue = PersistentQueue("emails", store_path="./localqueue_queue.sqlite3")
242
+
243
+ queue.put({"to": "user@example.com"})
244
+
245
+ message = queue.get_message()
246
+ try:
247
+ send_email(message.value["to"])
248
+ except RetryLater as exc:
249
+ queue.release(message, delay=30, error=exc)
250
+ except Exception as exc:
251
+ queue.dead_letter(message, error=exc)
252
+ raise
253
+ else:
254
+ queue.ack(message)
255
+ ```
256
+
257
+ `localqueue` uses at-least-once delivery:
258
+
259
+ - `put()` persists before returning
260
+ - `get_message()` leases a message and moves it to `inflight`
261
+ - `ack()` removes the message permanently
262
+ - `release()` makes it available again, optionally after a delay
263
+ - expired leases are returned to the ready queue
264
+ - `dead_letter()` moves a message out of normal delivery
265
+ - `requeue_dead()` returns a dead-letter message to ready delivery
266
+
267
+ Handlers must be safe to run more than once. A worker can crash after an
268
+ external side effect succeeds but before `ack()` is persisted, and the message
269
+ will be delivered again after the lease expires. Store an idempotency key in the
270
+ payload, pass it to external APIs when possible, or check your own database
271
+ before repeating non-idempotent side effects.
272
+
273
+ When a worker fails, `last_error` and `failed_at` are stored on the message so
274
+ CLI failures and redelivered messages show why processing failed.
275
+ Use `queue stats` when you need ready, delayed, inflight, dead-letter, and total
276
+ counts instead of only ready messages. Use `queue inspect` to inspect one
277
+ message by id, `queue dead` to list dead-letter messages, and
278
+ `queue requeue-dead` to retry one after the failure cause is fixed.
279
+
280
+ ## Operational boundaries
281
+
282
+ The default SQLite backend is intentionally simple. It is practical for local
283
+ worker processes, small teams, development tools, and operational scripts, but
284
+ it is not a drop-in replacement for a distributed queue.
285
+
286
+ - Run producers and consumers where they can safely share the same SQLite file.
287
+ - Use small JSON payloads; store large files externally and enqueue references.
288
+ - Treat ordering as best effort once multiple producers or consumers are active.
289
+ - Keep handlers idempotent because delivery is at least once.
290
+ - Monitor `queue stats`, `queue dead`, and store file growth for long-running
291
+ deployments.
292
+ - Back up the SQLite files if queued work or retry state matters after host
293
+ loss.
294
+ - Move to Postgres, Redis, SQS, RabbitMQ, Kafka, or a similar system when you
295
+ need multi-host coordination, high concurrency, retention controls, metrics,
296
+ or managed operations.
297
+
298
+ The [Operational maturity](docs/operational-maturity.md) checklist tracks the
299
+ remaining hardening work before describing this as a mature production queue
300
+ system.
301
+
302
+ ## Persistent retry layer
303
+
304
+ Use `localqueue.retry` directly when the job source is not `localqueue`.
305
+
306
+ ```python
307
+ from localqueue.retry import key_from_argument, persistent_retry
308
+ from tenacity import retry_if_exception_type, stop_after_attempt, wait_fixed
309
+
310
+
311
+ @persistent_retry(
312
+ key_fn=key_from_argument("email_id"),
313
+ stop=stop_after_attempt(3),
314
+ wait=wait_fixed(1),
315
+ retry=retry_if_exception_type(ConnectionError),
316
+ )
317
+ def send_email(email_id: str, address: str) -> None:
318
+ deliver(address)
319
+ ```
320
+
321
+ Every persistent retry needs an explicit key. Use one of these forms:
322
+
323
+ - `key="email:42"` for a retryer bound to one logical job
324
+ - `key_fn=key_from_argument("email_id")` for scalar IDs
325
+ - `key_fn=key_from_attr("task", "id", prefix="process-video")` for object attributes
326
+ - `key_fn=idempotency_key_from_id("task", prefix="process-video")` as shorthand for `task.id`
327
+
328
+ When a retry budget is exhausted, later calls with the same key raise
329
+ `PersistentRetryExhausted` before the wrapped function is called again.
330
+
331
+ ## Stores
332
+
333
+ The default queue store is SQLite at `./localqueue_queue.sqlite3`.
334
+
335
+ The default retry store is SQLite at `./localqueue_retries.sqlite3`.
336
+ The CLI `retry_store_path` setting also uses SQLite. In the Python retry API,
337
+ `PersistentRetrying(store_path=...)` selects an optional LMDB attempt-store
338
+ directory; pass `store=SQLiteAttemptStore("retries.sqlite3")` when you want a
339
+ SQLite file explicitly.
340
+
341
+ LMDB remains available as an optional backend through `localqueue[lmdb]`
342
+ and explicit `LMDBQueueStore` or `LMDBAttemptStore` usage.
343
+
344
+ For tests, use `MemoryQueueStore` and `MemoryAttemptStore`.
345
+
346
+ ## Documentation
347
+
348
+ - [Persistent queues](docs/queues.md): message lifecycle, workers, leases, delay, and dead-letter behavior.
349
+ - [Persistent retries](docs/retries.md): decorators, low-level retryers, keys, stores, and exhaustion behavior.
350
+ - [API reference](docs/api.md): exported classes, functions, and protocols.
351
+ - [Operational maturity](docs/operational-maturity.md): documented limits and checklist for future production-hardening work.
352
+ - [Release checklist](docs/release.md): manual versioning, build, smoke test, and publish steps.
353
+
354
+ ## License
355
+
356
+ `localqueue` is distributed under the MIT license. See [LICENSE](LICENSE).