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/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()