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/__init__.py +67 -0
- localqueue/cli.py +744 -0
- localqueue/queue.py +261 -0
- localqueue/retry/__init__.py +39 -0
- localqueue/retry/store.py +182 -0
- localqueue/retry/tenacity.py +685 -0
- localqueue/store.py +1197 -0
- localqueue/worker.py +192 -0
- localqueue-0.1.0.dist-info/METADATA +356 -0
- localqueue-0.1.0.dist-info/RECORD +13 -0
- localqueue-0.1.0.dist-info/WHEEL +4 -0
- localqueue-0.1.0.dist-info/entry_points.txt +2 -0
- localqueue-0.1.0.dist-info/licenses/LICENSE +21 -0
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
|