localqueue 0.1.0__py3-none-any.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.
localqueue/store.py ADDED
@@ -0,0 +1,1197 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import sqlite3
5
+ import threading
6
+ import time
7
+ import uuid
8
+ from contextlib import contextmanager, suppress
9
+ from dataclasses import dataclass, replace
10
+ from pathlib import Path
11
+ from collections.abc import Iterable
12
+ from typing import TYPE_CHECKING, Any, Iterator, Protocol
13
+
14
+ if TYPE_CHECKING:
15
+ import lmdb
16
+
17
+ _ENVS: dict[tuple[str, int], Any] = {}
18
+ _ENVS_LOCK = threading.Lock()
19
+ _READY = "ready"
20
+ _INFLIGHT = "inflight"
21
+ _DEAD = "dead"
22
+ _QUEUE_RECORD_VERSION = 1
23
+
24
+
25
+ def _import_lmdb() -> Any:
26
+ try:
27
+ import lmdb
28
+ except ModuleNotFoundError as exc:
29
+ raise RuntimeError(
30
+ "LMDB support requires the optional dependency; "
31
+ 'install with `pip install "localqueue[lmdb]"`'
32
+ ) from exc
33
+ return lmdb
34
+
35
+
36
+ @dataclass(frozen=True, slots=True)
37
+ class QueueMessage:
38
+ id: str
39
+ value: Any
40
+ queue: str
41
+ state: str = _READY
42
+ attempts: int = 0
43
+ created_at: float = 0.0
44
+ available_at: float = 0.0
45
+ leased_until: float | None = None
46
+ leased_by: str | None = None
47
+ last_error: dict[str, Any] | None = None
48
+ failed_at: float | None = None
49
+
50
+
51
+ @dataclass(frozen=True, slots=True)
52
+ class QueueStats:
53
+ ready: int = 0
54
+ delayed: int = 0
55
+ inflight: int = 0
56
+ dead: int = 0
57
+ total: int = 0
58
+
59
+ def as_dict(self) -> dict[str, int]:
60
+ return {
61
+ "ready": self.ready,
62
+ "delayed": self.delayed,
63
+ "inflight": self.inflight,
64
+ "dead": self.dead,
65
+ "total": self.total,
66
+ }
67
+
68
+
69
+ @dataclass(frozen=True, slots=True)
70
+ class _QueueRecord:
71
+ id: str
72
+ value: Any
73
+ queue: str
74
+ attempts: int
75
+ created_at: float
76
+ available_at: float
77
+ leased_until: float | None
78
+ leased_by: str | None
79
+ last_error: dict[str, Any] | None
80
+ failed_at: float | None
81
+ state: str
82
+ index_key: bytes | None
83
+
84
+ @classmethod
85
+ def new(cls, queue: str, value: Any, available_at: float) -> "_QueueRecord":
86
+ return cls(
87
+ id=uuid.uuid4().hex,
88
+ value=value,
89
+ queue=queue,
90
+ attempts=0,
91
+ created_at=time.time(),
92
+ available_at=available_at,
93
+ leased_until=None,
94
+ leased_by=None,
95
+ last_error=None,
96
+ failed_at=None,
97
+ state=_READY,
98
+ index_key=None,
99
+ )
100
+
101
+ def to_message(self) -> QueueMessage:
102
+ return QueueMessage(
103
+ id=self.id,
104
+ value=self.value,
105
+ queue=self.queue,
106
+ state=self.state,
107
+ attempts=self.attempts,
108
+ created_at=self.created_at,
109
+ available_at=self.available_at,
110
+ leased_until=self.leased_until,
111
+ leased_by=self.leased_by,
112
+ last_error=self.last_error,
113
+ failed_at=self.failed_at,
114
+ )
115
+
116
+
117
+ class QueueStore(Protocol):
118
+ def enqueue(
119
+ self, queue: str, value: Any, *, available_at: float
120
+ ) -> QueueMessage: ...
121
+
122
+ def dequeue(
123
+ self,
124
+ queue: str,
125
+ *,
126
+ lease_timeout: float,
127
+ now: float,
128
+ leased_by: str | None = None,
129
+ ) -> QueueMessage | None: ...
130
+
131
+ def get(self, queue: str, message_id: str) -> QueueMessage | None: ...
132
+
133
+ def ack(self, queue: str, message_id: str) -> bool: ...
134
+
135
+ def release(
136
+ self,
137
+ queue: str,
138
+ message_id: str,
139
+ *,
140
+ available_at: float,
141
+ last_error: dict[str, Any] | None = None,
142
+ failed_at: float | None = None,
143
+ ) -> bool: ...
144
+
145
+ def dead_letter(
146
+ self,
147
+ queue: str,
148
+ message_id: str,
149
+ *,
150
+ last_error: dict[str, Any] | None = None,
151
+ failed_at: float | None = None,
152
+ ) -> bool: ...
153
+
154
+ def qsize(self, queue: str, *, now: float) -> int: ...
155
+
156
+ def stats(self, queue: str, *, now: float) -> QueueStats: ...
157
+
158
+ def dead_letters(
159
+ self, queue: str, *, limit: int | None = None
160
+ ) -> list[QueueMessage]: ...
161
+
162
+ def requeue_dead(
163
+ self, queue: str, message_id: str, *, available_at: float
164
+ ) -> bool: ...
165
+
166
+ def empty(self, queue: str, *, now: float) -> bool: ...
167
+
168
+ def purge(self, queue: str) -> int: ...
169
+
170
+
171
+ class QueueStoreLockedError(RuntimeError):
172
+ path: str
173
+
174
+ def __init__(self, path: str | Path) -> None:
175
+ resolved = str(Path(path).resolve())
176
+ super().__init__(
177
+ f"LMDB queue store at {resolved!r} is locked by another process; "
178
+ + "use a different store_path/db path or stop the competing process"
179
+ )
180
+ self.path = resolved
181
+
182
+
183
+ class MemoryQueueStore:
184
+ _records: dict[str, dict[str, _QueueRecord]]
185
+ _seq: dict[str, int]
186
+ _lock: threading.Lock
187
+
188
+ def __init__(self) -> None:
189
+ self._records = {}
190
+ self._seq = {}
191
+ self._lock = threading.Lock()
192
+
193
+ def enqueue(self, queue: str, value: Any, *, available_at: float) -> QueueMessage:
194
+ with self._lock:
195
+ record = _QueueRecord.new(queue, value, available_at)
196
+ seq = self._next_seq(queue)
197
+ record = replace(
198
+ record, index_key=self._ready_key(queue, available_at, seq, record.id)
199
+ )
200
+ self._records.setdefault(queue, {})[record.id] = record
201
+ return record.to_message()
202
+
203
+ def dequeue(
204
+ self,
205
+ queue: str,
206
+ *,
207
+ lease_timeout: float,
208
+ now: float,
209
+ leased_by: str | None = None,
210
+ ) -> QueueMessage | None:
211
+ with self._lock:
212
+ self._reclaim_expired(queue, now)
213
+ ready = [
214
+ record
215
+ for record in self._records.get(queue, {}).values()
216
+ if record.state == _READY and record.available_at <= now
217
+ ]
218
+ if not ready:
219
+ return None
220
+ record = min(ready, key=lambda item: item.index_key or b"")
221
+ leased_until = now + lease_timeout
222
+ updated = replace(
223
+ record,
224
+ attempts=record.attempts + 1,
225
+ leased_until=leased_until,
226
+ leased_by=leased_by,
227
+ state=_INFLIGHT,
228
+ index_key=self._inflight_key(queue, leased_until, record.id),
229
+ )
230
+ self._records[queue][record.id] = updated
231
+ return updated.to_message()
232
+
233
+ def get(self, queue: str, message_id: str) -> QueueMessage | None:
234
+ with self._lock:
235
+ record = self._records.get(queue, {}).get(message_id)
236
+ if record is None:
237
+ return None
238
+ return record.to_message()
239
+
240
+ def ack(self, queue: str, message_id: str) -> bool:
241
+ with self._lock:
242
+ return self._records.get(queue, {}).pop(message_id, None) is not None
243
+
244
+ def release(
245
+ self,
246
+ queue: str,
247
+ message_id: str,
248
+ *,
249
+ available_at: float,
250
+ last_error: dict[str, Any] | None = None,
251
+ failed_at: float | None = None,
252
+ ) -> bool:
253
+ with self._lock:
254
+ record = self._records.get(queue, {}).get(message_id)
255
+ if record is None:
256
+ return False
257
+ seq = self._next_seq(queue)
258
+ self._records[queue][message_id] = replace(
259
+ record,
260
+ available_at=available_at,
261
+ leased_until=None,
262
+ leased_by=None,
263
+ last_error=last_error if last_error is not None else record.last_error,
264
+ failed_at=failed_at if failed_at is not None else record.failed_at,
265
+ state=_READY,
266
+ index_key=self._ready_key(queue, available_at, seq, message_id),
267
+ )
268
+ return True
269
+
270
+ def dead_letter(
271
+ self,
272
+ queue: str,
273
+ message_id: str,
274
+ *,
275
+ last_error: dict[str, Any] | None = None,
276
+ failed_at: float | None = None,
277
+ ) -> bool:
278
+ with self._lock:
279
+ record = self._records.get(queue, {}).get(message_id)
280
+ if record is None:
281
+ return False
282
+ self._records[queue][message_id] = replace(
283
+ record,
284
+ leased_until=None,
285
+ leased_by=None,
286
+ last_error=last_error if last_error is not None else record.last_error,
287
+ failed_at=failed_at if failed_at is not None else record.failed_at,
288
+ state=_DEAD,
289
+ index_key=self._dead_key(queue, message_id),
290
+ )
291
+ return True
292
+
293
+ def qsize(self, queue: str, *, now: float) -> int:
294
+ with self._lock:
295
+ self._reclaim_expired(queue, now)
296
+ return sum(
297
+ 1
298
+ for record in self._records.get(queue, {}).values()
299
+ if record.state == _READY and record.available_at <= now
300
+ )
301
+
302
+ def stats(self, queue: str, *, now: float) -> QueueStats:
303
+ with self._lock:
304
+ self._reclaim_expired(queue, now)
305
+ return _stats_from_records(self._records.get(queue, {}).values(), now=now)
306
+
307
+ def dead_letters(
308
+ self, queue: str, *, limit: int | None = None
309
+ ) -> list[QueueMessage]:
310
+ _validate_limit(limit)
311
+ with self._lock:
312
+ records = [
313
+ record
314
+ for record in self._records.get(queue, {}).values()
315
+ if record.state == _DEAD
316
+ ]
317
+ records.sort(key=lambda record: record.index_key or b"")
318
+ if limit is not None:
319
+ records = records[:limit]
320
+ return [record.to_message() for record in records]
321
+
322
+ def requeue_dead(self, queue: str, message_id: str, *, available_at: float) -> bool:
323
+ with self._lock:
324
+ record = self._records.get(queue, {}).get(message_id)
325
+ if record is None or record.state != _DEAD:
326
+ return False
327
+ seq = self._next_seq(queue)
328
+ self._records[queue][message_id] = replace(
329
+ record,
330
+ available_at=available_at,
331
+ leased_until=None,
332
+ leased_by=None,
333
+ state=_READY,
334
+ index_key=self._ready_key(queue, available_at, seq, message_id),
335
+ )
336
+ return True
337
+
338
+ def empty(self, queue: str, *, now: float) -> bool:
339
+ return self.qsize(queue, now=now) == 0
340
+
341
+ def purge(self, queue: str) -> int:
342
+ with self._lock:
343
+ count = len(self._records.get(queue, {}))
344
+ self._records[queue] = {}
345
+ return count
346
+
347
+ def _next_seq(self, queue: str) -> int:
348
+ value = self._seq.get(queue, 0) + 1
349
+ self._seq[queue] = value
350
+ return value
351
+
352
+ def _reclaim_expired(self, queue: str, now: float) -> None:
353
+ for record in list(self._records.get(queue, {}).values()):
354
+ if (
355
+ record.state == _INFLIGHT
356
+ and record.leased_until is not None
357
+ and record.leased_until <= now
358
+ ):
359
+ seq = self._next_seq(queue)
360
+ self._records[queue][record.id] = replace(
361
+ record,
362
+ available_at=now,
363
+ leased_until=None,
364
+ leased_by=None,
365
+ state=_READY,
366
+ index_key=self._ready_key(queue, now, seq, record.id),
367
+ )
368
+
369
+ @staticmethod
370
+ def _ready_key(queue: str, available_at: float, seq: int, message_id: str) -> bytes:
371
+ return _ready_key(queue, available_at, seq, message_id)
372
+
373
+ @staticmethod
374
+ def _inflight_key(queue: str, leased_until: float, message_id: str) -> bytes:
375
+ return _inflight_key(queue, leased_until, message_id)
376
+
377
+ @staticmethod
378
+ def _dead_key(queue: str, message_id: str) -> bytes:
379
+ return _dead_key(queue, message_id)
380
+
381
+
382
+ class SQLiteQueueStore:
383
+ path: Path
384
+ _connection: sqlite3.Connection
385
+ _lock: threading.Lock
386
+
387
+ def __init__(self, path: str | Path, timeout: float = 15.0) -> None:
388
+ self.path = Path(path)
389
+ if self.path.parent != Path("."):
390
+ self.path.parent.mkdir(parents=True, exist_ok=True)
391
+ self._connection = sqlite3.connect(
392
+ self.path, timeout=timeout, check_same_thread=False
393
+ )
394
+ self._connection.execute("PRAGMA journal_mode=WAL;")
395
+ self._connection.execute("PRAGMA synchronous=NORMAL;")
396
+ self._connection.execute(
397
+ "CREATE TABLE IF NOT EXISTS queue_messages ("
398
+ "queue TEXT NOT NULL, "
399
+ "id TEXT NOT NULL, "
400
+ "record_json TEXT NOT NULL, "
401
+ "state TEXT NOT NULL, "
402
+ "available_at REAL NOT NULL, "
403
+ "leased_until REAL, "
404
+ "sequence INTEGER NOT NULL, "
405
+ "PRIMARY KEY(queue, id)"
406
+ ")"
407
+ )
408
+ self._connection.execute(
409
+ "CREATE INDEX IF NOT EXISTS queue_messages_ready_idx "
410
+ "ON queue_messages(queue, state, available_at, sequence)"
411
+ )
412
+ self._connection.execute(
413
+ "CREATE INDEX IF NOT EXISTS queue_messages_inflight_idx "
414
+ "ON queue_messages(queue, state, leased_until)"
415
+ )
416
+ self._connection.execute(
417
+ "CREATE INDEX IF NOT EXISTS queue_messages_dead_idx "
418
+ "ON queue_messages(queue, state, id)"
419
+ )
420
+ self._connection.execute(
421
+ "CREATE TABLE IF NOT EXISTS queue_sequences ("
422
+ "queue TEXT PRIMARY KEY, "
423
+ "value INTEGER NOT NULL"
424
+ ")"
425
+ )
426
+ self._connection.commit()
427
+ self._lock = threading.Lock()
428
+
429
+ def enqueue(self, queue: str, value: Any, *, available_at: float) -> QueueMessage:
430
+ _validate_json_serializable(value)
431
+ with self._transaction() as connection:
432
+ record = _QueueRecord.new(queue, value, available_at)
433
+ seq = self._next_seq(connection, queue)
434
+ record = replace(
435
+ record, index_key=_ready_key(queue, available_at, seq, record.id)
436
+ )
437
+ self._upsert_record(connection, record, sequence=seq)
438
+ return record.to_message()
439
+
440
+ def dequeue(
441
+ self,
442
+ queue: str,
443
+ *,
444
+ lease_timeout: float,
445
+ now: float,
446
+ leased_by: str | None = None,
447
+ ) -> QueueMessage | None:
448
+ with self._transaction() as connection:
449
+ self._reclaim_expired(connection, queue, now)
450
+ cursor = connection.execute(
451
+ "SELECT id, record_json FROM queue_messages "
452
+ "WHERE queue = ? AND state = ? AND available_at <= ? "
453
+ "ORDER BY available_at, sequence LIMIT 1",
454
+ (queue, _READY, now),
455
+ )
456
+ row = cursor.fetchone()
457
+ if row is None:
458
+ return None
459
+ record = _decode_record(row[1])
460
+ leased_until = now + lease_timeout
461
+ updated = replace(
462
+ record,
463
+ attempts=record.attempts + 1,
464
+ leased_until=leased_until,
465
+ leased_by=leased_by,
466
+ state=_INFLIGHT,
467
+ index_key=_inflight_key(queue, leased_until, record.id),
468
+ )
469
+ self._upsert_record(connection, updated, sequence=self._sequence(row[1]))
470
+ return updated.to_message()
471
+
472
+ def get(self, queue: str, message_id: str) -> QueueMessage | None:
473
+ with self._lock:
474
+ record = self._get_record(self._connection, queue, message_id)
475
+ if record is None:
476
+ return None
477
+ return record.to_message()
478
+
479
+ def ack(self, queue: str, message_id: str) -> bool:
480
+ with self._transaction() as connection:
481
+ cursor = connection.execute(
482
+ "DELETE FROM queue_messages WHERE queue = ? AND id = ?",
483
+ (queue, message_id),
484
+ )
485
+ return cursor.rowcount > 0
486
+
487
+ def release(
488
+ self,
489
+ queue: str,
490
+ message_id: str,
491
+ *,
492
+ available_at: float,
493
+ last_error: dict[str, Any] | None = None,
494
+ failed_at: float | None = None,
495
+ ) -> bool:
496
+ with self._transaction() as connection:
497
+ record = self._get_record(connection, queue, message_id)
498
+ if record is None:
499
+ return False
500
+ seq = self._next_seq(connection, queue)
501
+ updated = replace(
502
+ record,
503
+ available_at=available_at,
504
+ leased_until=None,
505
+ leased_by=None,
506
+ last_error=last_error if last_error is not None else record.last_error,
507
+ failed_at=failed_at if failed_at is not None else record.failed_at,
508
+ state=_READY,
509
+ index_key=_ready_key(queue, available_at, seq, message_id),
510
+ )
511
+ self._upsert_record(connection, updated, sequence=seq)
512
+ return True
513
+
514
+ def dead_letter(
515
+ self,
516
+ queue: str,
517
+ message_id: str,
518
+ *,
519
+ last_error: dict[str, Any] | None = None,
520
+ failed_at: float | None = None,
521
+ ) -> bool:
522
+ with self._transaction() as connection:
523
+ record = self._get_record(connection, queue, message_id)
524
+ if record is None:
525
+ return False
526
+ updated = replace(
527
+ record,
528
+ leased_until=None,
529
+ leased_by=None,
530
+ last_error=last_error if last_error is not None else record.last_error,
531
+ failed_at=failed_at if failed_at is not None else record.failed_at,
532
+ state=_DEAD,
533
+ index_key=_dead_key(queue, message_id),
534
+ )
535
+ self._upsert_record(
536
+ connection,
537
+ updated,
538
+ sequence=self._sequence_for_id(connection, queue, message_id),
539
+ )
540
+ return True
541
+
542
+ def qsize(self, queue: str, *, now: float) -> int:
543
+ with self._lock:
544
+ cursor = self._connection.execute(
545
+ "SELECT COUNT(*) FROM queue_messages "
546
+ "WHERE queue = ? AND state = ? AND available_at <= ?",
547
+ (queue, _READY, now),
548
+ )
549
+ return int(cursor.fetchone()[0])
550
+
551
+ def stats(self, queue: str, *, now: float) -> QueueStats:
552
+ with self._transaction() as connection:
553
+ self._reclaim_expired(connection, queue, now)
554
+ cursor = connection.execute(
555
+ "SELECT record_json FROM queue_messages WHERE queue = ?",
556
+ (queue,),
557
+ )
558
+ return _stats_from_records(
559
+ (_decode_record(row[0]) for row in cursor.fetchall()), now=now
560
+ )
561
+
562
+ def dead_letters(
563
+ self, queue: str, *, limit: int | None = None
564
+ ) -> list[QueueMessage]:
565
+ _validate_limit(limit)
566
+ query = (
567
+ "SELECT record_json FROM queue_messages "
568
+ "WHERE queue = ? AND state = ? ORDER BY id"
569
+ )
570
+ params: tuple[Any, ...]
571
+ if limit is None:
572
+ params = (queue, _DEAD)
573
+ else:
574
+ query += " LIMIT ?"
575
+ params = (queue, _DEAD, limit)
576
+ with self._lock:
577
+ cursor = self._connection.execute(query, params)
578
+ return [_decode_record(row[0]).to_message() for row in cursor.fetchall()]
579
+
580
+ def requeue_dead(self, queue: str, message_id: str, *, available_at: float) -> bool:
581
+ with self._transaction() as connection:
582
+ record = self._get_record(connection, queue, message_id)
583
+ if record is None or record.state != _DEAD:
584
+ return False
585
+ seq = self._next_seq(connection, queue)
586
+ updated = replace(
587
+ record,
588
+ available_at=available_at,
589
+ leased_until=None,
590
+ leased_by=None,
591
+ state=_READY,
592
+ index_key=_ready_key(queue, available_at, seq, message_id),
593
+ )
594
+ self._upsert_record(connection, updated, sequence=seq)
595
+ return True
596
+
597
+ def empty(self, queue: str, *, now: float) -> bool:
598
+ return self.qsize(queue, now=now) == 0
599
+
600
+ def purge(self, queue: str) -> int:
601
+ with self._transaction() as connection:
602
+ cursor = connection.execute(
603
+ "DELETE FROM queue_messages WHERE queue = ?", (queue,)
604
+ )
605
+ return cursor.rowcount
606
+
607
+ def close(self) -> None:
608
+ with self._lock:
609
+ self._connection.close()
610
+
611
+ def __del__(self) -> None: # pragma: no cover
612
+ with suppress(Exception):
613
+ self.close()
614
+
615
+ @contextmanager
616
+ def _transaction(self) -> Iterator[sqlite3.Connection]:
617
+ with self._lock:
618
+ try:
619
+ self._connection.execute("BEGIN IMMEDIATE")
620
+ yield self._connection
621
+ self._connection.commit()
622
+ except Exception:
623
+ self._connection.rollback()
624
+ raise
625
+
626
+ def _next_seq(self, connection: sqlite3.Connection, queue: str) -> int:
627
+ _ = _safe_queue(queue)
628
+ cursor = connection.execute(
629
+ "SELECT value FROM queue_sequences WHERE queue = ?", (queue,)
630
+ )
631
+ row = cursor.fetchone()
632
+ value = 1 if row is None else int(row[0]) + 1
633
+ connection.execute(
634
+ "INSERT INTO queue_sequences(queue, value) VALUES(?, ?) "
635
+ "ON CONFLICT(queue) DO UPDATE SET value = excluded.value",
636
+ (queue, value),
637
+ )
638
+ return value
639
+
640
+ def _get_record(
641
+ self, connection: sqlite3.Connection, queue: str, message_id: str
642
+ ) -> _QueueRecord | None:
643
+ _ = _safe_queue(queue)
644
+ cursor = connection.execute(
645
+ "SELECT record_json FROM queue_messages WHERE queue = ? AND id = ?",
646
+ (queue, message_id),
647
+ )
648
+ row = cursor.fetchone()
649
+ if row is None:
650
+ return None
651
+ return _decode_record(row[0])
652
+
653
+ def _upsert_record(
654
+ self, connection: sqlite3.Connection, record: _QueueRecord, *, sequence: int
655
+ ) -> None:
656
+ connection.execute(
657
+ "INSERT INTO queue_messages("
658
+ "queue, id, record_json, state, available_at, leased_until, sequence"
659
+ ") VALUES(?, ?, ?, ?, ?, ?, ?) "
660
+ "ON CONFLICT(queue, id) DO UPDATE SET "
661
+ "record_json = excluded.record_json, "
662
+ "state = excluded.state, "
663
+ "available_at = excluded.available_at, "
664
+ "leased_until = excluded.leased_until, "
665
+ "sequence = excluded.sequence",
666
+ (
667
+ record.queue,
668
+ record.id,
669
+ _encode_record(record).decode("utf-8"),
670
+ record.state,
671
+ record.available_at,
672
+ record.leased_until,
673
+ sequence,
674
+ ),
675
+ )
676
+
677
+ def _sequence_for_id(
678
+ self, connection: sqlite3.Connection, queue: str, message_id: str
679
+ ) -> int:
680
+ cursor = connection.execute(
681
+ "SELECT sequence FROM queue_messages WHERE queue = ? AND id = ?",
682
+ (queue, message_id),
683
+ )
684
+ row = cursor.fetchone()
685
+ return 0 if row is None else int(row[0])
686
+
687
+ def _sequence(self, raw_record: str) -> int:
688
+ record = _decode_record(raw_record)
689
+ if record.index_key is None:
690
+ return 0
691
+ return _sequence_from_index_key(record.index_key)
692
+
693
+ def _reclaim_expired(
694
+ self, connection: sqlite3.Connection, queue: str, now: float
695
+ ) -> None:
696
+ cursor = connection.execute(
697
+ "SELECT id, record_json FROM queue_messages "
698
+ "WHERE queue = ? AND state = ? AND leased_until <= ? "
699
+ "ORDER BY leased_until",
700
+ (queue, _INFLIGHT, now),
701
+ )
702
+ for message_id, raw in cursor.fetchall():
703
+ record = _decode_record(raw)
704
+ seq = self._next_seq(connection, queue)
705
+ updated = replace(
706
+ record,
707
+ available_at=now,
708
+ leased_until=None,
709
+ leased_by=None,
710
+ state=_READY,
711
+ index_key=_ready_key(queue, now, seq, message_id),
712
+ )
713
+ self._upsert_record(connection, updated, sequence=seq)
714
+
715
+
716
+ class LMDBQueueStore:
717
+ path: Path
718
+ _env: lmdb.Environment
719
+
720
+ def __init__(self, path: str | Path, *, map_size: int = 10**8) -> None:
721
+ lmdb = _import_lmdb()
722
+ self.path = Path(path)
723
+ self.path.mkdir(parents=True, exist_ok=True)
724
+ key = (str(self.path.resolve()), map_size)
725
+ with _ENVS_LOCK:
726
+ env = _ENVS.get(key)
727
+ if env is None:
728
+ try:
729
+ env = lmdb.open(
730
+ str(self.path),
731
+ map_size=map_size,
732
+ subdir=True,
733
+ lock=True,
734
+ )
735
+ except lmdb.LockError as exc:
736
+ raise QueueStoreLockedError(self.path) from exc
737
+ _ENVS[key] = env
738
+ self._env = env
739
+
740
+ def enqueue(self, queue: str, value: Any, *, available_at: float) -> QueueMessage:
741
+ _validate_json_serializable(value)
742
+ with self._env.begin(write=True) as txn:
743
+ record = _QueueRecord.new(queue, value, available_at)
744
+ seq = self._next_seq(txn, queue)
745
+ record = replace(
746
+ record, index_key=_ready_key(queue, available_at, seq, record.id)
747
+ )
748
+ self._put_record(txn, record)
749
+ assert record.index_key is not None
750
+ _ = txn.put(record.index_key, record.id.encode("utf-8"))
751
+ return record.to_message()
752
+
753
+ def dequeue(
754
+ self,
755
+ queue: str,
756
+ *,
757
+ lease_timeout: float,
758
+ now: float,
759
+ leased_by: str | None = None,
760
+ ) -> QueueMessage | None:
761
+ with self._env.begin(write=True) as txn:
762
+ self._reclaim_expired(txn, queue, now)
763
+ cursor = txn.cursor()
764
+ prefix = _ready_prefix(queue)
765
+ if not cursor.set_range(prefix):
766
+ return None
767
+ item = cursor.item()
768
+ if item is None:
769
+ return None
770
+ key, raw_id = item
771
+ key = bytes(key)
772
+ if not key.startswith(prefix):
773
+ return None
774
+ available_at = _timestamp_from_ready_key(key)
775
+ if available_at > now:
776
+ return None
777
+ record = self._get_record(txn, queue, bytes(raw_id).decode("utf-8"))
778
+ if record is None:
779
+ _ = txn.delete(key)
780
+ return None
781
+ leased_until = now + lease_timeout
782
+ updated = replace(
783
+ record,
784
+ attempts=record.attempts + 1,
785
+ leased_until=leased_until,
786
+ leased_by=leased_by,
787
+ state=_INFLIGHT,
788
+ index_key=_inflight_key(queue, leased_until, record.id),
789
+ )
790
+ _ = txn.delete(key)
791
+ self._put_record(txn, updated)
792
+ assert updated.index_key is not None
793
+ _ = txn.put(updated.index_key, updated.id.encode("utf-8"))
794
+ return updated.to_message()
795
+
796
+ def get(self, queue: str, message_id: str) -> QueueMessage | None:
797
+ with self._env.begin() as txn:
798
+ record = self._get_record(txn, queue, message_id)
799
+ if record is None:
800
+ return None
801
+ return record.to_message()
802
+
803
+ def ack(self, queue: str, message_id: str) -> bool:
804
+ with self._env.begin(write=True) as txn:
805
+ record = self._get_record(txn, queue, message_id)
806
+ if record is None:
807
+ return False
808
+ self._delete_index(txn, record)
809
+ _ = txn.delete(_message_key(queue, message_id))
810
+ return True
811
+
812
+ def release(
813
+ self,
814
+ queue: str,
815
+ message_id: str,
816
+ *,
817
+ available_at: float,
818
+ last_error: dict[str, Any] | None = None,
819
+ failed_at: float | None = None,
820
+ ) -> bool:
821
+ with self._env.begin(write=True) as txn:
822
+ record = self._get_record(txn, queue, message_id)
823
+ if record is None:
824
+ return False
825
+ self._delete_index(txn, record)
826
+ seq = self._next_seq(txn, queue)
827
+ updated = replace(
828
+ record,
829
+ available_at=available_at,
830
+ leased_until=None,
831
+ leased_by=None,
832
+ last_error=last_error if last_error is not None else record.last_error,
833
+ failed_at=failed_at if failed_at is not None else record.failed_at,
834
+ state=_READY,
835
+ index_key=_ready_key(queue, available_at, seq, message_id),
836
+ )
837
+ self._put_record(txn, updated)
838
+ assert updated.index_key is not None
839
+ _ = txn.put(updated.index_key, updated.id.encode("utf-8"))
840
+ return True
841
+
842
+ def dead_letter(
843
+ self,
844
+ queue: str,
845
+ message_id: str,
846
+ *,
847
+ last_error: dict[str, Any] | None = None,
848
+ failed_at: float | None = None,
849
+ ) -> bool:
850
+ with self._env.begin(write=True) as txn:
851
+ record = self._get_record(txn, queue, message_id)
852
+ if record is None:
853
+ return False
854
+ self._delete_index(txn, record)
855
+ updated = replace(
856
+ record,
857
+ leased_until=None,
858
+ leased_by=None,
859
+ last_error=last_error if last_error is not None else record.last_error,
860
+ failed_at=failed_at if failed_at is not None else record.failed_at,
861
+ state=_DEAD,
862
+ index_key=_dead_key(queue, message_id),
863
+ )
864
+ self._put_record(txn, updated)
865
+ assert updated.index_key is not None
866
+ _ = txn.put(updated.index_key, updated.id.encode("utf-8"))
867
+ return True
868
+
869
+ def qsize(self, queue: str, *, now: float) -> int:
870
+ with self._env.begin() as txn:
871
+ count = 0
872
+ cursor = txn.cursor()
873
+ prefix = _ready_prefix(queue)
874
+ if not cursor.set_range(prefix):
875
+ return 0
876
+ for key, _ in cursor:
877
+ key = bytes(key)
878
+ if not key.startswith(prefix):
879
+ break
880
+ if _timestamp_from_ready_key(key) > now:
881
+ break
882
+ count += 1
883
+ return count
884
+
885
+ def stats(self, queue: str, *, now: float) -> QueueStats:
886
+ with self._env.begin(write=True) as txn:
887
+ self._reclaim_expired(txn, queue, now)
888
+ records = []
889
+ cursor = txn.cursor()
890
+ prefix = _message_prefix(queue)
891
+ if cursor.set_range(prefix):
892
+ for key, raw in cursor:
893
+ key = bytes(key)
894
+ if not key.startswith(prefix):
895
+ break
896
+ records.append(_decode_record(bytes(raw)))
897
+ return _stats_from_records(records, now=now)
898
+
899
+ def dead_letters(
900
+ self, queue: str, *, limit: int | None = None
901
+ ) -> list[QueueMessage]:
902
+ _validate_limit(limit)
903
+ with self._env.begin() as txn:
904
+ messages = []
905
+ cursor = txn.cursor()
906
+ prefix = _dead_prefix(queue)
907
+ if not cursor.set_range(prefix):
908
+ return []
909
+ for key, raw_id in cursor:
910
+ key = bytes(key)
911
+ if not key.startswith(prefix):
912
+ break
913
+ record = self._get_record(txn, queue, bytes(raw_id).decode("utf-8"))
914
+ if record is not None:
915
+ messages.append(record.to_message())
916
+ if limit is not None and len(messages) >= limit:
917
+ break
918
+ return messages
919
+
920
+ def requeue_dead(self, queue: str, message_id: str, *, available_at: float) -> bool:
921
+ with self._env.begin(write=True) as txn:
922
+ record = self._get_record(txn, queue, message_id)
923
+ if record is None or record.state != _DEAD:
924
+ return False
925
+ self._delete_index(txn, record)
926
+ seq = self._next_seq(txn, queue)
927
+ updated = replace(
928
+ record,
929
+ available_at=available_at,
930
+ leased_until=None,
931
+ leased_by=None,
932
+ state=_READY,
933
+ index_key=_ready_key(queue, available_at, seq, message_id),
934
+ )
935
+ self._put_record(txn, updated)
936
+ assert updated.index_key is not None
937
+ _ = txn.put(updated.index_key, updated.id.encode("utf-8"))
938
+ return True
939
+
940
+ def empty(self, queue: str, *, now: float) -> bool:
941
+ return self.qsize(queue, now=now) == 0
942
+
943
+ def purge(self, queue: str) -> int:
944
+ with self._env.begin(write=True) as txn:
945
+ count = 0
946
+ cursor = txn.cursor()
947
+ prefix = _queue_prefix(queue)
948
+ if not cursor.set_range(prefix):
949
+ return 0
950
+ keys = []
951
+ for key, _ in cursor:
952
+ key = bytes(key)
953
+ if not key.startswith(prefix):
954
+ break
955
+ keys.append(key)
956
+ for key in keys:
957
+ if key.startswith(_message_prefix(queue)):
958
+ count += 1
959
+ _ = txn.delete(key)
960
+ return count
961
+
962
+ def _reclaim_expired(self, txn: lmdb.Transaction, queue: str, now: float) -> None:
963
+ cursor = txn.cursor()
964
+ prefix = _inflight_prefix(queue)
965
+ if not cursor.set_range(prefix):
966
+ return
967
+ expired: list[tuple[bytes, str]] = []
968
+ for key, raw_id in cursor:
969
+ key = bytes(key)
970
+ if not key.startswith(prefix):
971
+ break
972
+ leased_until = _timestamp_from_inflight_key(key)
973
+ if leased_until > now:
974
+ break
975
+ expired.append((key, bytes(raw_id).decode("utf-8")))
976
+
977
+ for old_key, message_id in expired:
978
+ record = self._get_record(txn, queue, message_id)
979
+ _ = txn.delete(old_key)
980
+ if record is None:
981
+ continue
982
+ seq = self._next_seq(txn, queue)
983
+ updated = replace(
984
+ record,
985
+ available_at=now,
986
+ leased_until=None,
987
+ leased_by=None,
988
+ state=_READY,
989
+ index_key=_ready_key(queue, now, seq, message_id),
990
+ )
991
+ self._put_record(txn, updated)
992
+ assert updated.index_key is not None
993
+ _ = txn.put(updated.index_key, updated.id.encode("utf-8"))
994
+
995
+ def _next_seq(self, txn: lmdb.Transaction, queue: str) -> int:
996
+ key = _seq_key(queue)
997
+ raw = txn.get(key)
998
+ value = 1 if raw is None else int(bytes(raw).decode("ascii")) + 1
999
+ _ = txn.put(key, str(value).encode("ascii"))
1000
+ return value
1001
+
1002
+ def _get_record(
1003
+ self, txn: lmdb.Transaction, queue: str, message_id: str
1004
+ ) -> _QueueRecord | None:
1005
+ raw = txn.get(_message_key(queue, message_id))
1006
+ if raw is None:
1007
+ return None
1008
+ return _decode_record(bytes(raw))
1009
+
1010
+ def _put_record(self, txn: lmdb.Transaction, record: _QueueRecord) -> None:
1011
+ _ = txn.put(_message_key(record.queue, record.id), _encode_record(record))
1012
+
1013
+ def _delete_index(self, txn: lmdb.Transaction, record: _QueueRecord) -> None:
1014
+ if record.index_key is not None:
1015
+ _ = txn.delete(record.index_key)
1016
+
1017
+
1018
+ def _safe_queue(queue: str) -> str:
1019
+ if not queue:
1020
+ raise ValueError("queue name cannot be empty")
1021
+ if ":" in queue:
1022
+ raise ValueError("queue name cannot contain ':'")
1023
+ return queue
1024
+
1025
+
1026
+ def _millis(value: float) -> int:
1027
+ return max(int(value * 1000), 0)
1028
+
1029
+
1030
+ def _timestamp(value: str) -> float:
1031
+ return int(value) / 1000
1032
+
1033
+
1034
+ def _queue_prefix(queue: str) -> bytes:
1035
+ return f"queue:{_safe_queue(queue)}:".encode("utf-8")
1036
+
1037
+
1038
+ def _message_prefix(queue: str) -> bytes:
1039
+ return f"queue:{_safe_queue(queue)}:message:".encode("utf-8")
1040
+
1041
+
1042
+ def _message_key(queue: str, message_id: str) -> bytes:
1043
+ return f"queue:{_safe_queue(queue)}:message:{message_id}".encode("utf-8")
1044
+
1045
+
1046
+ def _seq_key(queue: str) -> bytes:
1047
+ return f"queue:{_safe_queue(queue)}:seq".encode("utf-8")
1048
+
1049
+
1050
+ def _ready_prefix(queue: str) -> bytes:
1051
+ return f"queue:{_safe_queue(queue)}:ready:".encode("utf-8")
1052
+
1053
+
1054
+ def _ready_key(queue: str, available_at: float, seq: int, message_id: str) -> bytes:
1055
+ return (
1056
+ f"queue:{_safe_queue(queue)}:ready:"
1057
+ + f"{_millis(available_at):020d}:{seq:020d}:{message_id}"
1058
+ ).encode("utf-8")
1059
+
1060
+
1061
+ def _timestamp_from_ready_key(key: bytes) -> float:
1062
+ return _timestamp_from_index_key(key, expected_state=_READY)
1063
+
1064
+
1065
+ def _inflight_prefix(queue: str) -> bytes:
1066
+ return f"queue:{_safe_queue(queue)}:inflight:".encode("utf-8")
1067
+
1068
+
1069
+ def _inflight_key(queue: str, leased_until: float, message_id: str) -> bytes:
1070
+ return (
1071
+ f"queue:{_safe_queue(queue)}:inflight:{_millis(leased_until):020d}:{message_id}"
1072
+ ).encode("utf-8")
1073
+
1074
+
1075
+ def _timestamp_from_inflight_key(key: bytes) -> float:
1076
+ return _timestamp_from_index_key(key, expected_state=_INFLIGHT)
1077
+
1078
+
1079
+ def _dead_key(queue: str, message_id: str) -> bytes:
1080
+ return f"queue:{_safe_queue(queue)}:dead:{message_id}".encode("utf-8")
1081
+
1082
+
1083
+ def _dead_prefix(queue: str) -> bytes:
1084
+ return f"queue:{_safe_queue(queue)}:dead:".encode("utf-8")
1085
+
1086
+
1087
+ def _encode_record(record: _QueueRecord) -> bytes:
1088
+ payload = {
1089
+ "version": _QUEUE_RECORD_VERSION,
1090
+ "id": record.id,
1091
+ "value": record.value,
1092
+ "queue": record.queue,
1093
+ "attempts": record.attempts,
1094
+ "created_at": record.created_at,
1095
+ "available_at": record.available_at,
1096
+ "leased_until": record.leased_until,
1097
+ "leased_by": record.leased_by,
1098
+ "last_error": record.last_error,
1099
+ "failed_at": record.failed_at,
1100
+ "state": record.state,
1101
+ "index_key": record.index_key.decode("utf-8")
1102
+ if record.index_key is not None
1103
+ else None,
1104
+ }
1105
+ return json.dumps(payload, separators=(",", ":")).encode("utf-8")
1106
+
1107
+
1108
+ def _validate_json_serializable(value: Any) -> None:
1109
+ try:
1110
+ _ = json.dumps(value)
1111
+ except (TypeError, ValueError) as exc:
1112
+ raise ValueError("queue values must be JSON-serializable") from exc
1113
+
1114
+
1115
+ def _decode_record(raw: bytes | str) -> _QueueRecord:
1116
+ try:
1117
+ payload = json.loads(raw if isinstance(raw, str) else raw.decode("utf-8"))
1118
+ except (UnicodeDecodeError, json.JSONDecodeError) as exc:
1119
+ raise ValueError("queue record is not valid JSON") from exc
1120
+
1121
+ version = payload.get("version")
1122
+ if version != _QUEUE_RECORD_VERSION:
1123
+ raise ValueError(f"unsupported queue record version: {version!r}")
1124
+
1125
+ index_key = payload["index_key"]
1126
+ return _QueueRecord(
1127
+ id=payload["id"],
1128
+ value=payload["value"],
1129
+ queue=payload["queue"],
1130
+ attempts=payload["attempts"],
1131
+ created_at=payload["created_at"],
1132
+ available_at=payload["available_at"],
1133
+ leased_until=payload["leased_until"],
1134
+ leased_by=payload.get("leased_by"),
1135
+ last_error=payload.get("last_error"),
1136
+ failed_at=payload.get("failed_at"),
1137
+ state=payload["state"],
1138
+ index_key=index_key.encode("utf-8") if index_key is not None else None,
1139
+ )
1140
+
1141
+
1142
+ def _stats_from_records(records: Iterable[_QueueRecord], *, now: float) -> QueueStats:
1143
+ ready = 0
1144
+ delayed = 0
1145
+ inflight = 0
1146
+ dead = 0
1147
+ total = 0
1148
+ for record in records:
1149
+ total += 1
1150
+ if record.state == _READY:
1151
+ if record.available_at <= now:
1152
+ ready += 1
1153
+ else:
1154
+ delayed += 1
1155
+ elif record.state == _INFLIGHT:
1156
+ inflight += 1
1157
+ elif record.state == _DEAD:
1158
+ dead += 1
1159
+ return QueueStats(
1160
+ ready=ready,
1161
+ delayed=delayed,
1162
+ inflight=inflight,
1163
+ dead=dead,
1164
+ total=total,
1165
+ )
1166
+
1167
+
1168
+ def _validate_limit(limit: int | None) -> None:
1169
+ if limit is not None and limit < 0:
1170
+ raise ValueError("limit cannot be negative")
1171
+
1172
+
1173
+ def _timestamp_from_index_key(key: bytes, *, expected_state: str) -> float:
1174
+ try:
1175
+ decoded = key.decode("utf-8")
1176
+ except UnicodeDecodeError as exc:
1177
+ raise ValueError(f"malformed LMDB queue index key {key!r}: not UTF-8") from exc
1178
+
1179
+ parts = decoded.split(":")
1180
+ if len(parts) < 5 or parts[0] != "queue" or parts[2] != expected_state:
1181
+ raise ValueError(
1182
+ f"malformed LMDB queue index key {decoded!r}: "
1183
+ + f"expected queue:<name>:{expected_state}:<timestamp>:..."
1184
+ )
1185
+ return _timestamp(parts[3])
1186
+
1187
+
1188
+ def _sequence_from_index_key(key: bytes) -> int:
1189
+ try:
1190
+ decoded = key.decode("utf-8")
1191
+ except UnicodeDecodeError:
1192
+ return 0
1193
+
1194
+ parts = decoded.split(":")
1195
+ if len(parts) >= 6 and parts[2] == _READY:
1196
+ return int(parts[4])
1197
+ return 0