leanqueue 0.1.0__cp38-abi3-win_amd64.whl
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.
- leanqueue/__init__.py +5 -0
- leanqueue/leanqueue.pyd +0 -0
- leanqueue-0.1.0.dist-info/METADATA +16 -0
- leanqueue-0.1.0.dist-info/RECORD +7 -0
- leanqueue-0.1.0.dist-info/WHEEL +4 -0
- leanqueue-0.1.0.dist-info/sboms/leanqueue.cyclonedx.json +971 -0
- leanqueuedb.py +411 -0
leanqueuedb.py
ADDED
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
"""LeanQueue — embedded, durable, crash-resistant message queue.
|
|
2
|
+
|
|
3
|
+
Thin Python orchestration over the Rust core (``leanqueue.LeanQueueCore``):
|
|
4
|
+
a friendly ``Queue`` object, a ``Message`` wrapper with ack/nack, JSON/text
|
|
5
|
+
helpers, blocking consume loops, and a ``Broker`` that manages many named
|
|
6
|
+
queues under one directory. All durability, crash recovery, and multi-process /
|
|
7
|
+
multi-thread coordination live in Rust; this layer only adds ergonomics.
|
|
8
|
+
|
|
9
|
+
Quick start
|
|
10
|
+
-----------
|
|
11
|
+
from leanqueuedb import Queue
|
|
12
|
+
|
|
13
|
+
q = Queue("./jobs")
|
|
14
|
+
q.put(b"hello")
|
|
15
|
+
q.put_json({"task": "resize", "id": 42})
|
|
16
|
+
|
|
17
|
+
for msg in q.consume(timeout=1.0): # auto-acks each message on success
|
|
18
|
+
print(msg.text(), msg.json() if msg.is_json() else None)
|
|
19
|
+
|
|
20
|
+
# or manual control / at-least-once with redelivery on crash:
|
|
21
|
+
msg = q.get(block=False)
|
|
22
|
+
if msg:
|
|
23
|
+
try:
|
|
24
|
+
handle(msg)
|
|
25
|
+
msg.ack()
|
|
26
|
+
except Exception:
|
|
27
|
+
msg.nack(requeue=True)
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
import atexit
|
|
31
|
+
import os
|
|
32
|
+
import threading
|
|
33
|
+
import time
|
|
34
|
+
import weakref
|
|
35
|
+
from contextlib import contextmanager
|
|
36
|
+
from datetime import datetime, timezone
|
|
37
|
+
from typing import Any, Dict, Iterator, List, Optional, Union
|
|
38
|
+
|
|
39
|
+
import leanqueue
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
import orjson
|
|
43
|
+
|
|
44
|
+
def _json_dumps(obj: Any) -> bytes:
|
|
45
|
+
return orjson.dumps(obj)
|
|
46
|
+
|
|
47
|
+
def _json_loads(data: bytes) -> Any:
|
|
48
|
+
return orjson.loads(data)
|
|
49
|
+
except ImportError: # pragma: no cover - orjson is a declared dependency
|
|
50
|
+
import json
|
|
51
|
+
|
|
52
|
+
def _json_dumps(obj: Any) -> bytes:
|
|
53
|
+
return json.dumps(obj, separators=(",", ":")).encode("utf-8")
|
|
54
|
+
|
|
55
|
+
def _json_loads(data: bytes) -> Any:
|
|
56
|
+
return json.loads(data.decode("utf-8"))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
__version__ = getattr(leanqueue, "__version__", "0.1.0")
|
|
60
|
+
|
|
61
|
+
BodyLike = Union[bytes, bytearray, memoryview, str]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _to_bytes(body: BodyLike) -> bytes:
|
|
65
|
+
if isinstance(body, str):
|
|
66
|
+
return body.encode("utf-8")
|
|
67
|
+
if isinstance(body, (bytearray, memoryview)):
|
|
68
|
+
return bytes(body)
|
|
69
|
+
if isinstance(body, bytes):
|
|
70
|
+
return body
|
|
71
|
+
raise TypeError(
|
|
72
|
+
f"message body must be bytes/str, got {type(body).__name__}; "
|
|
73
|
+
"use put_json() for objects"
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# Track every live Queue so we can fsync on interpreter shutdown.
|
|
78
|
+
_OPEN_QUEUES: "weakref.WeakSet[Queue]" = weakref.WeakSet()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@atexit.register
|
|
82
|
+
def _flush_all_on_exit() -> None:
|
|
83
|
+
for q in list(_OPEN_QUEUES):
|
|
84
|
+
try:
|
|
85
|
+
q.flush()
|
|
86
|
+
except Exception:
|
|
87
|
+
pass
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class Message:
|
|
91
|
+
"""A leased message. Settle it with :meth:`ack` (done) or :meth:`nack`
|
|
92
|
+
(failed → redeliver or dead-letter). If never settled, the lease expires and
|
|
93
|
+
the message is redelivered automatically."""
|
|
94
|
+
|
|
95
|
+
__slots__ = ("seq", "body", "deliveries", "enqueued_at_ms", "_queue", "_settled")
|
|
96
|
+
|
|
97
|
+
def __init__(self, seq: int, body: bytes, deliveries: int, enqueued_at_ms: int, queue: "Queue"):
|
|
98
|
+
self.seq = seq
|
|
99
|
+
self.body = body
|
|
100
|
+
self.deliveries = deliveries
|
|
101
|
+
self.enqueued_at_ms = enqueued_at_ms
|
|
102
|
+
self._queue = queue
|
|
103
|
+
self._settled = False
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def enqueued_at(self) -> datetime:
|
|
107
|
+
return datetime.fromtimestamp(self.enqueued_at_ms / 1000, tz=timezone.utc)
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def settled(self) -> bool:
|
|
111
|
+
return self._settled
|
|
112
|
+
|
|
113
|
+
def text(self, encoding: str = "utf-8") -> str:
|
|
114
|
+
return self.body.decode(encoding)
|
|
115
|
+
|
|
116
|
+
def json(self) -> Any:
|
|
117
|
+
return _json_loads(self.body)
|
|
118
|
+
|
|
119
|
+
def is_json(self) -> bool:
|
|
120
|
+
try:
|
|
121
|
+
_json_loads(self.body)
|
|
122
|
+
return True
|
|
123
|
+
except Exception:
|
|
124
|
+
return False
|
|
125
|
+
|
|
126
|
+
def ack(self) -> bool:
|
|
127
|
+
"""Mark processing complete; the message is removed permanently."""
|
|
128
|
+
self._settled = True
|
|
129
|
+
return self._queue.ack(self.seq)
|
|
130
|
+
|
|
131
|
+
def nack(self, requeue: bool = True) -> bool:
|
|
132
|
+
"""Mark processing failed. ``requeue=True`` redelivers it later;
|
|
133
|
+
``requeue=False`` drops (dead-letters) it."""
|
|
134
|
+
self._settled = True
|
|
135
|
+
return self._queue.nack(self.seq, requeue=requeue)
|
|
136
|
+
|
|
137
|
+
def extend_lease(self, lease_ms: Optional[int] = None) -> bool:
|
|
138
|
+
"""Renew the lease while still working on a long task."""
|
|
139
|
+
return self._queue.extend_lease(self.seq, lease_ms=lease_ms)
|
|
140
|
+
|
|
141
|
+
def __repr__(self) -> str:
|
|
142
|
+
preview = self.body[:32]
|
|
143
|
+
return (
|
|
144
|
+
f"Message(seq={self.seq}, deliveries={self.deliveries}, "
|
|
145
|
+
f"body={preview!r}{'...' if len(self.body) > 32 else ''})"
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class Queue:
|
|
150
|
+
"""A single durable queue backed by one directory (or ``':memory:'``).
|
|
151
|
+
|
|
152
|
+
Parameters
|
|
153
|
+
----------
|
|
154
|
+
path: directory for this queue, or ``":memory:"`` for a non-persistent one.
|
|
155
|
+
sync_interval_ms: ``0`` (default) fsyncs after every write — strongest
|
|
156
|
+
durability. A positive value coalesces fsyncs, trading a small crash
|
|
157
|
+
window for higher ingest throughput.
|
|
158
|
+
default_lease_ms: how long a dequeued message stays invisible before
|
|
159
|
+
redelivery if not acked (default 30s).
|
|
160
|
+
auto_compact_bytes: rewrite the log to reclaim acked space once it grows
|
|
161
|
+
past this size with mostly-dead content (default 64 MiB; ``0`` disables).
|
|
162
|
+
"""
|
|
163
|
+
|
|
164
|
+
def __init__(
|
|
165
|
+
self,
|
|
166
|
+
path: str,
|
|
167
|
+
sync_interval_ms: int = 0,
|
|
168
|
+
default_lease_ms: int = 30_000,
|
|
169
|
+
auto_compact_bytes: int = 64 * 1024 * 1024,
|
|
170
|
+
):
|
|
171
|
+
self.path = path
|
|
172
|
+
self._core = leanqueue.LeanQueueCore(
|
|
173
|
+
path,
|
|
174
|
+
sync_interval_ms=sync_interval_ms,
|
|
175
|
+
default_lease_ms=default_lease_ms,
|
|
176
|
+
auto_compact_bytes=auto_compact_bytes,
|
|
177
|
+
)
|
|
178
|
+
self._closed = False
|
|
179
|
+
_OPEN_QUEUES.add(self)
|
|
180
|
+
|
|
181
|
+
# --- producing ---
|
|
182
|
+
|
|
183
|
+
def put(self, body: BodyLike) -> int:
|
|
184
|
+
"""Enqueue one message; returns its sequence id."""
|
|
185
|
+
return self._core.enqueue(_to_bytes(body))
|
|
186
|
+
|
|
187
|
+
def put_json(self, obj: Any) -> int:
|
|
188
|
+
"""Enqueue a JSON-serializable object."""
|
|
189
|
+
return self._core.enqueue(_json_dumps(obj))
|
|
190
|
+
|
|
191
|
+
def put_many(self, bodies: List[BodyLike]) -> List[int]:
|
|
192
|
+
"""Enqueue many messages under a single lock and fsync."""
|
|
193
|
+
return self._core.enqueue_batch([_to_bytes(b) for b in bodies])
|
|
194
|
+
|
|
195
|
+
def put_many_json(self, objs: List[Any]) -> List[int]:
|
|
196
|
+
return self._core.enqueue_batch([_json_dumps(o) for o in objs])
|
|
197
|
+
|
|
198
|
+
# --- consuming ---
|
|
199
|
+
|
|
200
|
+
def _wrap(self, raw) -> Optional[Message]:
|
|
201
|
+
if raw is None:
|
|
202
|
+
return None
|
|
203
|
+
seq, body, deliveries, ts = raw
|
|
204
|
+
return Message(seq, body, deliveries, ts, self)
|
|
205
|
+
|
|
206
|
+
def get(
|
|
207
|
+
self,
|
|
208
|
+
block: bool = False,
|
|
209
|
+
timeout: Optional[float] = None,
|
|
210
|
+
poll_interval: float = 0.05,
|
|
211
|
+
lease_ms: Optional[int] = None,
|
|
212
|
+
) -> Optional[Message]:
|
|
213
|
+
"""Lease the next ready message. With ``block=True`` it polls until one
|
|
214
|
+
is available or ``timeout`` seconds elapse (``None`` = forever).
|
|
215
|
+
Returns ``None`` if empty/timed out."""
|
|
216
|
+
msg = self._wrap(self._core.dequeue(lease_ms))
|
|
217
|
+
if msg is not None or not block:
|
|
218
|
+
return msg
|
|
219
|
+
deadline = None if timeout is None else time.monotonic() + timeout
|
|
220
|
+
while True:
|
|
221
|
+
time.sleep(poll_interval)
|
|
222
|
+
msg = self._wrap(self._core.dequeue(lease_ms))
|
|
223
|
+
if msg is not None:
|
|
224
|
+
return msg
|
|
225
|
+
if deadline is not None and time.monotonic() >= deadline:
|
|
226
|
+
return None
|
|
227
|
+
|
|
228
|
+
def get_many(self, max_messages: int, lease_ms: Optional[int] = None) -> List[Message]:
|
|
229
|
+
"""Lease up to ``max_messages`` ready messages at once."""
|
|
230
|
+
return [self._wrap(r) for r in self._core.dequeue_batch(max_messages, lease_ms)]
|
|
231
|
+
|
|
232
|
+
def consume(
|
|
233
|
+
self,
|
|
234
|
+
lease_ms: Optional[int] = None,
|
|
235
|
+
block: bool = True,
|
|
236
|
+
timeout: Optional[float] = None,
|
|
237
|
+
poll_interval: float = 0.05,
|
|
238
|
+
auto_ack: bool = True,
|
|
239
|
+
) -> Iterator[Message]:
|
|
240
|
+
"""Yield messages until the queue drains (or ``timeout`` elapses with
|
|
241
|
+
``block=True``). Each message is auto-acked once the loop body finishes
|
|
242
|
+
cleanly, or nacked-for-redelivery if the body raises."""
|
|
243
|
+
while True:
|
|
244
|
+
msg = self.get(block=block, timeout=timeout, poll_interval=poll_interval, lease_ms=lease_ms)
|
|
245
|
+
if msg is None:
|
|
246
|
+
return
|
|
247
|
+
try:
|
|
248
|
+
yield msg
|
|
249
|
+
except GeneratorExit:
|
|
250
|
+
if not msg.settled:
|
|
251
|
+
msg.nack(requeue=True)
|
|
252
|
+
raise
|
|
253
|
+
except Exception:
|
|
254
|
+
if not msg.settled:
|
|
255
|
+
msg.nack(requeue=True)
|
|
256
|
+
raise
|
|
257
|
+
else:
|
|
258
|
+
if auto_ack and not msg.settled:
|
|
259
|
+
msg.ack()
|
|
260
|
+
|
|
261
|
+
@contextmanager
|
|
262
|
+
def transaction(self, lease_ms: Optional[int] = None, requeue_on_error: bool = True):
|
|
263
|
+
"""Context manager that leases one message, acks on success, and nacks
|
|
264
|
+
(requeue by default) on error. Yields ``None`` if the queue is empty."""
|
|
265
|
+
msg = self.get(block=False, lease_ms=lease_ms)
|
|
266
|
+
if msg is None:
|
|
267
|
+
yield None
|
|
268
|
+
return
|
|
269
|
+
try:
|
|
270
|
+
yield msg
|
|
271
|
+
except Exception:
|
|
272
|
+
if not msg.settled:
|
|
273
|
+
msg.nack(requeue=requeue_on_error)
|
|
274
|
+
raise
|
|
275
|
+
else:
|
|
276
|
+
if not msg.settled:
|
|
277
|
+
msg.ack()
|
|
278
|
+
|
|
279
|
+
# --- settling (also reachable directly via Message) ---
|
|
280
|
+
|
|
281
|
+
def ack(self, seq: int) -> bool:
|
|
282
|
+
return self._core.ack(seq)
|
|
283
|
+
|
|
284
|
+
def ack_many(self, seqs: List[int]) -> int:
|
|
285
|
+
return self._core.ack_batch(seqs)
|
|
286
|
+
|
|
287
|
+
def nack(self, seq: int, requeue: bool = True) -> bool:
|
|
288
|
+
return self._core.nack(seq, requeue)
|
|
289
|
+
|
|
290
|
+
def extend_lease(self, seq: int, lease_ms: Optional[int] = None) -> bool:
|
|
291
|
+
return self._core.extend_lease(seq, lease_ms)
|
|
292
|
+
|
|
293
|
+
# --- introspection / maintenance ---
|
|
294
|
+
|
|
295
|
+
def available(self) -> int:
|
|
296
|
+
"""Messages ready for immediate delivery."""
|
|
297
|
+
return self._core.available()
|
|
298
|
+
|
|
299
|
+
def in_flight(self) -> int:
|
|
300
|
+
"""Leased messages awaiting ack."""
|
|
301
|
+
return self._core.in_flight()
|
|
302
|
+
|
|
303
|
+
def __len__(self) -> int:
|
|
304
|
+
return self._core.available()
|
|
305
|
+
|
|
306
|
+
def stats(self) -> Dict[str, Any]:
|
|
307
|
+
return dict(self._core.stats())
|
|
308
|
+
|
|
309
|
+
def flush(self) -> None:
|
|
310
|
+
"""Force an fsync of any buffered records."""
|
|
311
|
+
if not self._closed:
|
|
312
|
+
self._core.flush()
|
|
313
|
+
|
|
314
|
+
def compact(self) -> None:
|
|
315
|
+
"""Rewrite the log, reclaiming space from acked messages."""
|
|
316
|
+
self._core.compact()
|
|
317
|
+
|
|
318
|
+
def purge(self) -> None:
|
|
319
|
+
"""Delete every message and reset the log."""
|
|
320
|
+
self._core.purge()
|
|
321
|
+
|
|
322
|
+
def verify_integrity(self) -> Dict[str, Any]:
|
|
323
|
+
"""CRC-verify the whole on-disk log. Returns ``{ok, records, bytes, message}``."""
|
|
324
|
+
return dict(self._core.verify_integrity())
|
|
325
|
+
|
|
326
|
+
def close(self) -> None:
|
|
327
|
+
if not self._closed:
|
|
328
|
+
try:
|
|
329
|
+
self._core.flush()
|
|
330
|
+
finally:
|
|
331
|
+
self._closed = True
|
|
332
|
+
|
|
333
|
+
def __enter__(self) -> "Queue":
|
|
334
|
+
return self
|
|
335
|
+
|
|
336
|
+
def __exit__(self, *exc) -> None:
|
|
337
|
+
self.close()
|
|
338
|
+
|
|
339
|
+
def __repr__(self) -> str:
|
|
340
|
+
return f"Queue(path={self.path!r}, available={self.available()})"
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
class Broker:
|
|
344
|
+
"""Manages many named queues under a single root directory.
|
|
345
|
+
|
|
346
|
+
broker = Broker("./broker")
|
|
347
|
+
broker.queue("emails").put_json({"to": "a@b.c"})
|
|
348
|
+
broker.queue("thumbnails").put(b"...")
|
|
349
|
+
"""
|
|
350
|
+
|
|
351
|
+
_SAFE = set("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.")
|
|
352
|
+
|
|
353
|
+
def __init__(self, root: str, **queue_defaults: Any):
|
|
354
|
+
self.root = root
|
|
355
|
+
self._defaults = queue_defaults
|
|
356
|
+
self._queues: Dict[str, Queue] = {}
|
|
357
|
+
self._lock = threading.RLock()
|
|
358
|
+
if root != ":memory:":
|
|
359
|
+
os.makedirs(root, exist_ok=True)
|
|
360
|
+
|
|
361
|
+
def _safe_name(self, name: str) -> str:
|
|
362
|
+
if not name or any(c not in self._SAFE for c in name) or name in (".", ".."):
|
|
363
|
+
raise ValueError(
|
|
364
|
+
f"invalid queue name {name!r}: use letters, digits, '-', '_', '.'"
|
|
365
|
+
)
|
|
366
|
+
return name
|
|
367
|
+
|
|
368
|
+
def queue(self, name: str, **opts: Any) -> Queue:
|
|
369
|
+
"""Get (creating on first use) the named queue. Cached per name."""
|
|
370
|
+
name = self._safe_name(name)
|
|
371
|
+
with self._lock:
|
|
372
|
+
q = self._queues.get(name)
|
|
373
|
+
if q is None:
|
|
374
|
+
path = ":memory:" if self.root == ":memory:" else os.path.join(self.root, name)
|
|
375
|
+
merged = {**self._defaults, **opts}
|
|
376
|
+
q = Queue(path, **merged)
|
|
377
|
+
self._queues[name] = q
|
|
378
|
+
return q
|
|
379
|
+
|
|
380
|
+
def queue_names(self) -> List[str]:
|
|
381
|
+
"""List on-disk queue names (directories containing a queue.log)."""
|
|
382
|
+
if self.root == ":memory:":
|
|
383
|
+
return list(self._queues.keys())
|
|
384
|
+
names = []
|
|
385
|
+
try:
|
|
386
|
+
for entry in os.scandir(self.root):
|
|
387
|
+
if entry.is_dir() and os.path.exists(os.path.join(entry.path, "queue.log")):
|
|
388
|
+
names.append(entry.name)
|
|
389
|
+
except FileNotFoundError:
|
|
390
|
+
pass
|
|
391
|
+
return sorted(names)
|
|
392
|
+
|
|
393
|
+
def flush(self) -> None:
|
|
394
|
+
with self._lock:
|
|
395
|
+
for q in self._queues.values():
|
|
396
|
+
q.flush()
|
|
397
|
+
|
|
398
|
+
def close(self) -> None:
|
|
399
|
+
with self._lock:
|
|
400
|
+
for q in self._queues.values():
|
|
401
|
+
q.close()
|
|
402
|
+
self._queues.clear()
|
|
403
|
+
|
|
404
|
+
def __enter__(self) -> "Broker":
|
|
405
|
+
return self
|
|
406
|
+
|
|
407
|
+
def __exit__(self, *exc) -> None:
|
|
408
|
+
self.close()
|
|
409
|
+
|
|
410
|
+
def __repr__(self) -> str:
|
|
411
|
+
return f"Broker(root={self.root!r}, queues={self.queue_names()})"
|