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