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/queue.py
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from queue import Empty, Full
|
|
6
|
+
from threading import Condition
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from .store import QueueMessage, QueueStats, QueueStore, SQLiteQueueStore
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class PersistentQueue:
|
|
13
|
+
name: str
|
|
14
|
+
lease_timeout: float
|
|
15
|
+
maxsize: int
|
|
16
|
+
_store: QueueStore | None
|
|
17
|
+
_store_path: Path | None
|
|
18
|
+
_condition: Condition
|
|
19
|
+
_unfinished: list[QueueMessage]
|
|
20
|
+
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
name: str,
|
|
24
|
+
*,
|
|
25
|
+
store: QueueStore | None = None,
|
|
26
|
+
store_path: str | Path | None = None,
|
|
27
|
+
lease_timeout: float = 30.0,
|
|
28
|
+
maxsize: int = 0,
|
|
29
|
+
) -> None:
|
|
30
|
+
if store is not None and store_path is not None:
|
|
31
|
+
raise ValueError("pass either store= or store_path=, not both")
|
|
32
|
+
if lease_timeout <= 0:
|
|
33
|
+
raise ValueError("lease_timeout must be greater than zero")
|
|
34
|
+
if maxsize < 0:
|
|
35
|
+
raise ValueError("maxsize cannot be negative")
|
|
36
|
+
|
|
37
|
+
self.name = name
|
|
38
|
+
self.lease_timeout = lease_timeout
|
|
39
|
+
self.maxsize = maxsize
|
|
40
|
+
self._store = store
|
|
41
|
+
self._store_path = Path(store_path) if store_path is not None else None
|
|
42
|
+
self._condition = Condition()
|
|
43
|
+
self._unfinished = []
|
|
44
|
+
|
|
45
|
+
def put(
|
|
46
|
+
self,
|
|
47
|
+
item: Any,
|
|
48
|
+
block: bool = True,
|
|
49
|
+
timeout: float | None = None,
|
|
50
|
+
*,
|
|
51
|
+
delay: float = 0.0,
|
|
52
|
+
) -> QueueMessage:
|
|
53
|
+
if delay < 0:
|
|
54
|
+
raise ValueError("delay cannot be negative")
|
|
55
|
+
deadline = _deadline(timeout)
|
|
56
|
+
with self._condition:
|
|
57
|
+
while self.full():
|
|
58
|
+
if not block:
|
|
59
|
+
raise Full
|
|
60
|
+
remaining = _remaining(deadline)
|
|
61
|
+
if remaining is not None and remaining <= 0:
|
|
62
|
+
raise Full
|
|
63
|
+
_ = self._condition.wait(remaining)
|
|
64
|
+
message = self._get_store().enqueue(
|
|
65
|
+
self.name,
|
|
66
|
+
item,
|
|
67
|
+
available_at=time.time() + delay,
|
|
68
|
+
)
|
|
69
|
+
_ = self._condition.notify_all()
|
|
70
|
+
return message
|
|
71
|
+
|
|
72
|
+
def put_nowait(self, item: Any) -> QueueMessage:
|
|
73
|
+
return self.put(item, block=False)
|
|
74
|
+
|
|
75
|
+
def get(
|
|
76
|
+
self,
|
|
77
|
+
block: bool = True,
|
|
78
|
+
timeout: float | None = None,
|
|
79
|
+
*,
|
|
80
|
+
leased_by: str | None = None,
|
|
81
|
+
) -> Any:
|
|
82
|
+
message = self.get_message(block=block, timeout=timeout, leased_by=leased_by)
|
|
83
|
+
with self._condition:
|
|
84
|
+
self._unfinished.append(message)
|
|
85
|
+
return message.value
|
|
86
|
+
|
|
87
|
+
def get_nowait(self) -> Any:
|
|
88
|
+
return self.get(block=False)
|
|
89
|
+
|
|
90
|
+
def get_message(
|
|
91
|
+
self,
|
|
92
|
+
block: bool = True,
|
|
93
|
+
timeout: float | None = None,
|
|
94
|
+
*,
|
|
95
|
+
leased_by: str | None = None,
|
|
96
|
+
) -> QueueMessage:
|
|
97
|
+
deadline = _deadline(timeout)
|
|
98
|
+
while True:
|
|
99
|
+
with self._condition:
|
|
100
|
+
message = self._get_store().dequeue(
|
|
101
|
+
self.name,
|
|
102
|
+
lease_timeout=self.lease_timeout,
|
|
103
|
+
now=time.time(),
|
|
104
|
+
leased_by=leased_by,
|
|
105
|
+
)
|
|
106
|
+
if message is not None:
|
|
107
|
+
_ = self._condition.notify_all()
|
|
108
|
+
return message
|
|
109
|
+
if not block:
|
|
110
|
+
raise Empty
|
|
111
|
+
remaining = _remaining(deadline)
|
|
112
|
+
if remaining is not None and remaining <= 0:
|
|
113
|
+
raise Empty
|
|
114
|
+
_ = self._condition.wait(_wait_time(remaining))
|
|
115
|
+
|
|
116
|
+
def inspect(self, message_id: str) -> QueueMessage | None:
|
|
117
|
+
with self._condition:
|
|
118
|
+
return self._get_store().get(self.name, message_id)
|
|
119
|
+
|
|
120
|
+
def ack(self, message: QueueMessage) -> bool:
|
|
121
|
+
with self._condition:
|
|
122
|
+
removed = self._get_store().ack(self.name, message.id)
|
|
123
|
+
self._remove_unfinished(message.id)
|
|
124
|
+
_ = self._condition.notify_all()
|
|
125
|
+
return removed
|
|
126
|
+
|
|
127
|
+
def release(
|
|
128
|
+
self,
|
|
129
|
+
message: QueueMessage,
|
|
130
|
+
*,
|
|
131
|
+
delay: float = 0.0,
|
|
132
|
+
error: BaseException | str | None = None,
|
|
133
|
+
) -> bool:
|
|
134
|
+
if delay < 0:
|
|
135
|
+
raise ValueError("delay cannot be negative")
|
|
136
|
+
failed_at = time.time() if error is not None else None
|
|
137
|
+
with self._condition:
|
|
138
|
+
released = self._get_store().release(
|
|
139
|
+
self.name,
|
|
140
|
+
message.id,
|
|
141
|
+
available_at=time.time() + delay,
|
|
142
|
+
last_error=_error_payload(error),
|
|
143
|
+
failed_at=failed_at,
|
|
144
|
+
)
|
|
145
|
+
self._remove_unfinished(message.id)
|
|
146
|
+
_ = self._condition.notify_all()
|
|
147
|
+
return released
|
|
148
|
+
|
|
149
|
+
def dead_letter(
|
|
150
|
+
self, message: QueueMessage, *, error: BaseException | str | None = None
|
|
151
|
+
) -> bool:
|
|
152
|
+
failed_at = time.time() if error is not None else None
|
|
153
|
+
with self._condition:
|
|
154
|
+
moved = self._get_store().dead_letter(
|
|
155
|
+
self.name,
|
|
156
|
+
message.id,
|
|
157
|
+
last_error=_error_payload(error),
|
|
158
|
+
failed_at=failed_at,
|
|
159
|
+
)
|
|
160
|
+
self._remove_unfinished(message.id)
|
|
161
|
+
_ = self._condition.notify_all()
|
|
162
|
+
return moved
|
|
163
|
+
|
|
164
|
+
def task_done(self) -> None:
|
|
165
|
+
with self._condition:
|
|
166
|
+
if not self._unfinished:
|
|
167
|
+
raise ValueError("task_done() called too many times")
|
|
168
|
+
message = self._unfinished.pop(0)
|
|
169
|
+
_ = self._get_store().ack(self.name, message.id)
|
|
170
|
+
_ = self._condition.notify_all()
|
|
171
|
+
|
|
172
|
+
def join(self) -> None:
|
|
173
|
+
with self._condition:
|
|
174
|
+
while self._unfinished:
|
|
175
|
+
_ = self._condition.wait()
|
|
176
|
+
|
|
177
|
+
def qsize(self) -> int:
|
|
178
|
+
return self._get_store().qsize(self.name, now=time.time())
|
|
179
|
+
|
|
180
|
+
def stats(self) -> QueueStats:
|
|
181
|
+
return self._get_store().stats(self.name, now=time.time())
|
|
182
|
+
|
|
183
|
+
def dead_letters(self, *, limit: int | None = None) -> list[QueueMessage]:
|
|
184
|
+
return self._get_store().dead_letters(self.name, limit=limit)
|
|
185
|
+
|
|
186
|
+
def requeue_dead(self, message: QueueMessage, *, delay: float = 0.0) -> bool:
|
|
187
|
+
if delay < 0:
|
|
188
|
+
raise ValueError("delay cannot be negative")
|
|
189
|
+
with self._condition:
|
|
190
|
+
requeued = self._get_store().requeue_dead(
|
|
191
|
+
self.name,
|
|
192
|
+
message.id,
|
|
193
|
+
available_at=time.time() + delay,
|
|
194
|
+
)
|
|
195
|
+
_ = self._condition.notify_all()
|
|
196
|
+
return requeued
|
|
197
|
+
|
|
198
|
+
def empty(self) -> bool:
|
|
199
|
+
return self._get_store().empty(self.name, now=time.time())
|
|
200
|
+
|
|
201
|
+
def full(self) -> bool:
|
|
202
|
+
return self.maxsize > 0 and self.qsize() >= self.maxsize
|
|
203
|
+
|
|
204
|
+
def purge(self) -> int:
|
|
205
|
+
with self._condition:
|
|
206
|
+
self._unfinished.clear()
|
|
207
|
+
count = self._get_store().purge(self.name)
|
|
208
|
+
_ = self._condition.notify_all()
|
|
209
|
+
return count
|
|
210
|
+
|
|
211
|
+
def _get_store(self) -> QueueStore:
|
|
212
|
+
if self._store is None:
|
|
213
|
+
self._store = SQLiteQueueStore(
|
|
214
|
+
self._store_path
|
|
215
|
+
if self._store_path is not None
|
|
216
|
+
else "localqueue_queue.sqlite3"
|
|
217
|
+
)
|
|
218
|
+
return self._store
|
|
219
|
+
|
|
220
|
+
def _remove_unfinished(self, message_id: str) -> None:
|
|
221
|
+
self._unfinished = [
|
|
222
|
+
message for message in self._unfinished if message.id != message_id
|
|
223
|
+
]
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _deadline(timeout: float | None) -> float | None:
|
|
227
|
+
if timeout is None:
|
|
228
|
+
return None
|
|
229
|
+
if timeout < 0:
|
|
230
|
+
raise ValueError("'timeout' must be a non-negative number")
|
|
231
|
+
return time.monotonic() + timeout
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _remaining(deadline: float | None) -> float | None:
|
|
235
|
+
if deadline is None:
|
|
236
|
+
return None
|
|
237
|
+
return deadline - time.monotonic()
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _wait_time(remaining: float | None) -> float:
|
|
241
|
+
if remaining is None:
|
|
242
|
+
return 0.05
|
|
243
|
+
return min(max(remaining, 0.0), 0.05)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _error_payload(error: BaseException | str | None) -> dict[str, Any] | None:
|
|
247
|
+
if error is None:
|
|
248
|
+
return None
|
|
249
|
+
if isinstance(error, BaseException):
|
|
250
|
+
error_type = type(error)
|
|
251
|
+
payload: dict[str, Any] = {
|
|
252
|
+
"type": error_type.__name__,
|
|
253
|
+
"module": error_type.__module__,
|
|
254
|
+
"message": str(error),
|
|
255
|
+
}
|
|
256
|
+
for attr in ("command", "exit_code", "stdout", "stderr"):
|
|
257
|
+
value = getattr(error, attr, None)
|
|
258
|
+
if value is not None:
|
|
259
|
+
payload[attr] = value
|
|
260
|
+
return payload
|
|
261
|
+
return {"type": None, "module": None, "message": str(error)}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from .store import (
|
|
2
|
+
AttemptStore,
|
|
3
|
+
AttemptStoreLockedError,
|
|
4
|
+
LMDBAttemptStore,
|
|
5
|
+
MemoryAttemptStore,
|
|
6
|
+
RetryRecord,
|
|
7
|
+
SQLiteAttemptStore,
|
|
8
|
+
)
|
|
9
|
+
from .tenacity import (
|
|
10
|
+
PersistentAsyncRetrying,
|
|
11
|
+
PersistentRetryExhausted,
|
|
12
|
+
PersistentRetrying,
|
|
13
|
+
configure_default_store,
|
|
14
|
+
configure_default_store_factory,
|
|
15
|
+
idempotency_key_from_id,
|
|
16
|
+
key_from_argument,
|
|
17
|
+
key_from_attr,
|
|
18
|
+
persistent_async_retry,
|
|
19
|
+
persistent_retry,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"AttemptStore",
|
|
24
|
+
"AttemptStoreLockedError",
|
|
25
|
+
"LMDBAttemptStore",
|
|
26
|
+
"MemoryAttemptStore",
|
|
27
|
+
"PersistentAsyncRetrying",
|
|
28
|
+
"PersistentRetryExhausted",
|
|
29
|
+
"PersistentRetrying",
|
|
30
|
+
"RetryRecord",
|
|
31
|
+
"SQLiteAttemptStore",
|
|
32
|
+
"configure_default_store",
|
|
33
|
+
"configure_default_store_factory",
|
|
34
|
+
"idempotency_key_from_id",
|
|
35
|
+
"key_from_argument",
|
|
36
|
+
"key_from_attr",
|
|
37
|
+
"persistent_async_retry",
|
|
38
|
+
"persistent_retry",
|
|
39
|
+
]
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sqlite3
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
from contextlib import suppress
|
|
8
|
+
from dataclasses import asdict, dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import TYPE_CHECKING, Any, Protocol
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
import lmdb
|
|
14
|
+
|
|
15
|
+
_ENVS: dict[tuple[str, int], Any] = {}
|
|
16
|
+
_ENVS_LOCK = threading.Lock()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _import_lmdb() -> Any:
|
|
20
|
+
try:
|
|
21
|
+
import lmdb
|
|
22
|
+
except ModuleNotFoundError as exc:
|
|
23
|
+
raise RuntimeError(
|
|
24
|
+
"LMDB support requires the optional dependency; "
|
|
25
|
+
'install with `pip install "localqueue[lmdb]"`'
|
|
26
|
+
) from exc
|
|
27
|
+
return lmdb
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(slots=True)
|
|
31
|
+
class RetryRecord:
|
|
32
|
+
attempts: int = 0
|
|
33
|
+
first_attempt_at: float = 0.0
|
|
34
|
+
exhausted: bool = False
|
|
35
|
+
|
|
36
|
+
@classmethod
|
|
37
|
+
def new(cls) -> "RetryRecord":
|
|
38
|
+
return cls(attempts=0, first_attempt_at=time.time(), exhausted=False)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class AttemptStoreLockedError(RuntimeError):
|
|
42
|
+
path: str
|
|
43
|
+
|
|
44
|
+
def __init__(self, path: str | Path) -> None:
|
|
45
|
+
resolved = str(Path(path).resolve())
|
|
46
|
+
super().__init__(
|
|
47
|
+
f"LMDB store at {resolved!r} is locked by another process; "
|
|
48
|
+
+ "use a different store_path/db path or stop the competing process"
|
|
49
|
+
)
|
|
50
|
+
self.path = resolved
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class AttemptStore(Protocol):
|
|
54
|
+
def load(self, key: str) -> RetryRecord | None: ...
|
|
55
|
+
|
|
56
|
+
def save(self, key: str, record: RetryRecord) -> None: ...
|
|
57
|
+
|
|
58
|
+
def delete(self, key: str) -> None: ...
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class MemoryAttemptStore:
|
|
62
|
+
_records: dict[str, RetryRecord]
|
|
63
|
+
_lock: threading.Lock
|
|
64
|
+
|
|
65
|
+
def __init__(self) -> None:
|
|
66
|
+
self._records = {}
|
|
67
|
+
self._lock = threading.Lock()
|
|
68
|
+
|
|
69
|
+
def load(self, key: str) -> RetryRecord | None:
|
|
70
|
+
with self._lock:
|
|
71
|
+
record = self._records.get(key)
|
|
72
|
+
if record is None:
|
|
73
|
+
return None
|
|
74
|
+
return RetryRecord(**asdict(record))
|
|
75
|
+
|
|
76
|
+
def save(self, key: str, record: RetryRecord) -> None:
|
|
77
|
+
with self._lock:
|
|
78
|
+
self._records[key] = RetryRecord(**asdict(record))
|
|
79
|
+
|
|
80
|
+
def delete(self, key: str) -> None:
|
|
81
|
+
with self._lock:
|
|
82
|
+
_ = self._records.pop(key, None)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class LMDBAttemptStore:
|
|
86
|
+
path: Path
|
|
87
|
+
_env: lmdb.Environment
|
|
88
|
+
|
|
89
|
+
def __init__(self, path: str | Path, *, map_size: int = 10**7) -> None:
|
|
90
|
+
lmdb = _import_lmdb()
|
|
91
|
+
self.path = Path(path)
|
|
92
|
+
self.path.mkdir(parents=True, exist_ok=True)
|
|
93
|
+
key = (str(self.path.resolve()), map_size)
|
|
94
|
+
with _ENVS_LOCK:
|
|
95
|
+
env = _ENVS.get(key)
|
|
96
|
+
if env is None:
|
|
97
|
+
try:
|
|
98
|
+
env = lmdb.open(
|
|
99
|
+
str(self.path),
|
|
100
|
+
map_size=map_size,
|
|
101
|
+
subdir=True,
|
|
102
|
+
lock=True,
|
|
103
|
+
)
|
|
104
|
+
except lmdb.LockError as exc:
|
|
105
|
+
raise AttemptStoreLockedError(self.path) from exc
|
|
106
|
+
_ENVS[key] = env
|
|
107
|
+
self._env = env
|
|
108
|
+
|
|
109
|
+
def load(self, key: str) -> RetryRecord | None:
|
|
110
|
+
with self._env.begin() as txn:
|
|
111
|
+
raw = txn.get(key.encode("utf-8"))
|
|
112
|
+
if raw is None:
|
|
113
|
+
return None
|
|
114
|
+
payload = json.loads(bytes(raw).decode("utf-8"))
|
|
115
|
+
return RetryRecord(**payload)
|
|
116
|
+
|
|
117
|
+
def save(self, key: str, record: RetryRecord) -> None:
|
|
118
|
+
payload = json.dumps(asdict(record), separators=(",", ":")).encode("utf-8")
|
|
119
|
+
with self._env.begin(write=True) as txn:
|
|
120
|
+
_ = txn.put(key.encode("utf-8"), payload)
|
|
121
|
+
|
|
122
|
+
def delete(self, key: str) -> None:
|
|
123
|
+
with self._env.begin(write=True) as txn:
|
|
124
|
+
_ = txn.delete(key.encode("utf-8"))
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class SQLiteAttemptStore:
|
|
128
|
+
path: Path
|
|
129
|
+
_connection: sqlite3.Connection
|
|
130
|
+
_lock: threading.Lock
|
|
131
|
+
|
|
132
|
+
def __init__(self, path: str | Path, timeout: float = 15.0) -> None:
|
|
133
|
+
self.path = Path(path)
|
|
134
|
+
if self.path.parent != Path("."):
|
|
135
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
136
|
+
self._connection = sqlite3.connect(
|
|
137
|
+
self.path, timeout=timeout, check_same_thread=False
|
|
138
|
+
)
|
|
139
|
+
self._connection.execute("PRAGMA journal_mode=WAL;")
|
|
140
|
+
self._connection.execute("PRAGMA synchronous=NORMAL;")
|
|
141
|
+
self._connection.execute(
|
|
142
|
+
"CREATE TABLE IF NOT EXISTS retry_records ("
|
|
143
|
+
"key TEXT PRIMARY KEY, "
|
|
144
|
+
"record_json TEXT NOT NULL"
|
|
145
|
+
")"
|
|
146
|
+
)
|
|
147
|
+
self._connection.commit()
|
|
148
|
+
self._lock = threading.Lock()
|
|
149
|
+
|
|
150
|
+
def load(self, key: str) -> RetryRecord | None:
|
|
151
|
+
with self._lock:
|
|
152
|
+
cursor = self._connection.execute(
|
|
153
|
+
"SELECT record_json FROM retry_records WHERE key = ?", (key,)
|
|
154
|
+
)
|
|
155
|
+
row = cursor.fetchone()
|
|
156
|
+
if row is None:
|
|
157
|
+
return None
|
|
158
|
+
payload = json.loads(row[0])
|
|
159
|
+
return RetryRecord(**payload)
|
|
160
|
+
|
|
161
|
+
def save(self, key: str, record: RetryRecord) -> None:
|
|
162
|
+
payload = json.dumps(asdict(record), separators=(",", ":"))
|
|
163
|
+
with self._lock:
|
|
164
|
+
self._connection.execute(
|
|
165
|
+
"INSERT INTO retry_records(key, record_json) VALUES(?, ?) "
|
|
166
|
+
"ON CONFLICT(key) DO UPDATE SET record_json = excluded.record_json",
|
|
167
|
+
(key, payload),
|
|
168
|
+
)
|
|
169
|
+
self._connection.commit()
|
|
170
|
+
|
|
171
|
+
def delete(self, key: str) -> None:
|
|
172
|
+
with self._lock:
|
|
173
|
+
self._connection.execute("DELETE FROM retry_records WHERE key = ?", (key,))
|
|
174
|
+
self._connection.commit()
|
|
175
|
+
|
|
176
|
+
def close(self) -> None:
|
|
177
|
+
with self._lock:
|
|
178
|
+
self._connection.close()
|
|
179
|
+
|
|
180
|
+
def __del__(self) -> None: # pragma: no cover
|
|
181
|
+
with suppress(Exception):
|
|
182
|
+
self.close()
|