localqueue 1.0.0__cp312-cp312-win_amd64.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- localqueue/__init__.py +16 -0
- localqueue/core.py +272 -0
- localqueue/exceptions.py +5 -0
- localqueue/job.py +25 -0
- localqueue/localqueue.cp312-win_amd64.pyd +0 -0
- localqueue/localqueue.pyi +58 -0
- localqueue/worker.py +156 -0
- localqueue-1.0.0.dist-info/METADATA +131 -0
- localqueue-1.0.0.dist-info/RECORD +11 -0
- localqueue-1.0.0.dist-info/WHEEL +4 -0
- localqueue-1.0.0.dist-info/sboms/localqueue.cyclonedx.json +1195 -0
localqueue/__init__.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""localqueue: fila persistente local em SQLite com ACK, lease e retry."""
|
|
2
|
+
|
|
3
|
+
from localqueue.core import JsonSerializer, SimpleQueue
|
|
4
|
+
from localqueue.exceptions import Empty, LeaseExpired, LocalQueueError
|
|
5
|
+
from localqueue.job import Job
|
|
6
|
+
from localqueue.worker import Worker
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"Empty",
|
|
10
|
+
"Job",
|
|
11
|
+
"JsonSerializer",
|
|
12
|
+
"LeaseExpired",
|
|
13
|
+
"LocalQueueError",
|
|
14
|
+
"SimpleQueue",
|
|
15
|
+
"Worker",
|
|
16
|
+
]
|
localqueue/core.py
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
"""Facade Python para a fila persistente localqueue."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import time as _time
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, Optional, Protocol
|
|
10
|
+
|
|
11
|
+
from localqueue import localqueue as _native
|
|
12
|
+
from localqueue.exceptions import Empty, LeaseExpired, LocalQueueError
|
|
13
|
+
from localqueue.job import Job
|
|
14
|
+
|
|
15
|
+
log = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Serializer(Protocol):
|
|
19
|
+
"""Protocolo para serializadores compatíveis."""
|
|
20
|
+
|
|
21
|
+
def dumps(self, obj: Any) -> bytes: ...
|
|
22
|
+
|
|
23
|
+
def loads(self, data: bytes) -> Any: ...
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class JsonSerializer:
|
|
27
|
+
"""Serializador JSON padrão."""
|
|
28
|
+
|
|
29
|
+
def dumps(self, obj: Any) -> bytes:
|
|
30
|
+
return json.dumps(obj).encode("utf-8")
|
|
31
|
+
|
|
32
|
+
def loads(self, data: bytes) -> Any:
|
|
33
|
+
return json.loads(data.decode("utf-8"))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class SimpleQueue:
|
|
37
|
+
"""Fila persistente baseada em SQLite com ACK/NACK, lease e retry.
|
|
38
|
+
|
|
39
|
+
O motor transacional é implementado em Rust (extensão nativa), garantindo
|
|
40
|
+
atomicidade mesmo com múltiplos processos/threads.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def __init__(
|
|
44
|
+
self,
|
|
45
|
+
path: str,
|
|
46
|
+
name: str = "default",
|
|
47
|
+
*,
|
|
48
|
+
lease_seconds: float = 60.0,
|
|
49
|
+
max_retries: int = 3,
|
|
50
|
+
fsync: bool = False,
|
|
51
|
+
serializer: Optional[Serializer] = None,
|
|
52
|
+
) -> None:
|
|
53
|
+
"""Inicializa a fila.
|
|
54
|
+
|
|
55
|
+
:param path: diretório onde o banco SQLite será armazenado.
|
|
56
|
+
:param name: nome da fila.
|
|
57
|
+
:param lease_seconds: tempo de lease para cada job retirado.
|
|
58
|
+
:param max_retries: número máximo de tentativas antes de dead-letter.
|
|
59
|
+
:param fsync: quando ``True`` usa ``PRAGMA synchronous=FULL``.
|
|
60
|
+
:param serializer: serializador com métodos ``dumps`` e ``loads``.
|
|
61
|
+
"""
|
|
62
|
+
if not lease_seconds > 0:
|
|
63
|
+
raise ValueError("'lease_seconds' deve ser positivo")
|
|
64
|
+
if max_retries < 0:
|
|
65
|
+
raise ValueError("'max_retries' deve ser não negativo")
|
|
66
|
+
|
|
67
|
+
self.path = Path(path)
|
|
68
|
+
self.name = name
|
|
69
|
+
self.lease_seconds = lease_seconds
|
|
70
|
+
self.max_retries = max_retries
|
|
71
|
+
self.serializer = serializer or JsonSerializer()
|
|
72
|
+
|
|
73
|
+
self.path.mkdir(parents=True, exist_ok=True)
|
|
74
|
+
db_path = self.path / "localqueue.db"
|
|
75
|
+
|
|
76
|
+
self._native: Optional[_native.NativeQueue] = _native.NativeQueue(
|
|
77
|
+
str(db_path),
|
|
78
|
+
name,
|
|
79
|
+
max_attempts=max_retries + 1,
|
|
80
|
+
fsync=fsync,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
def _get_native(self) -> "_native.NativeQueue":
|
|
84
|
+
native = self._native
|
|
85
|
+
if native is None:
|
|
86
|
+
raise LocalQueueError("fila fechada")
|
|
87
|
+
return native
|
|
88
|
+
|
|
89
|
+
def put(self, data: Any, job_id: Optional[str] = None) -> int:
|
|
90
|
+
"""Adiciona um item à fila.
|
|
91
|
+
|
|
92
|
+
:param data: payload a ser enfileirado.
|
|
93
|
+
:param job_id: identificador único opcional para deduplicação.
|
|
94
|
+
:return: id interno do item na fila.
|
|
95
|
+
"""
|
|
96
|
+
payload = self.serializer.dumps(data)
|
|
97
|
+
return self._get_native().put(payload, job_id)
|
|
98
|
+
|
|
99
|
+
def get(
|
|
100
|
+
self, block: bool = True, timeout: Optional[float] = None
|
|
101
|
+
) -> Job:
|
|
102
|
+
"""Retira um item da fila com lease.
|
|
103
|
+
|
|
104
|
+
:param block: se ``True``, bloqueia até haver um item disponível.
|
|
105
|
+
:param timeout: tempo máximo de espera em segundos.
|
|
106
|
+
:return: instância de :class:`Job`.
|
|
107
|
+
:raises Empty: se ``block=False`` e a fila estiver vazia, ou se
|
|
108
|
+
``block=True`` e ``timeout`` expirar.
|
|
109
|
+
"""
|
|
110
|
+
lease_ms = int(self.lease_seconds * 1000)
|
|
111
|
+
|
|
112
|
+
if not block:
|
|
113
|
+
lease = self._get_native().get(lease_ms)
|
|
114
|
+
if lease is None:
|
|
115
|
+
raise Empty("fila vazia")
|
|
116
|
+
return self._to_job(lease)
|
|
117
|
+
|
|
118
|
+
if timeout is not None and timeout < 0:
|
|
119
|
+
raise ValueError("'timeout' deve ser não negativo")
|
|
120
|
+
|
|
121
|
+
start = _time.monotonic()
|
|
122
|
+
sleep = 0.01
|
|
123
|
+
max_sleep = 0.25
|
|
124
|
+
while True:
|
|
125
|
+
lease = self._get_native().get(lease_ms)
|
|
126
|
+
if lease is not None:
|
|
127
|
+
return self._to_job(lease)
|
|
128
|
+
|
|
129
|
+
if timeout is not None:
|
|
130
|
+
elapsed = _time.monotonic() - start
|
|
131
|
+
if elapsed >= timeout:
|
|
132
|
+
raise Empty("fila vazia")
|
|
133
|
+
|
|
134
|
+
_time.sleep(sleep)
|
|
135
|
+
sleep = min(sleep * 1.5, max_sleep)
|
|
136
|
+
|
|
137
|
+
def get_nowait(self) -> Job:
|
|
138
|
+
"""Variação não bloqueante de :meth:`get`."""
|
|
139
|
+
return self.get(block=False)
|
|
140
|
+
|
|
141
|
+
def ack(self, job: Job) -> None:
|
|
142
|
+
"""Confirma o processamento bem-sucedido de um job."""
|
|
143
|
+
self._get_native().ack(job.id, job.receipt)
|
|
144
|
+
|
|
145
|
+
def nack(
|
|
146
|
+
self,
|
|
147
|
+
job: Job,
|
|
148
|
+
*,
|
|
149
|
+
delay: float = 0.0,
|
|
150
|
+
last_error: Optional[str] = None,
|
|
151
|
+
) -> None:
|
|
152
|
+
"""Devolve o job à fila (erro transitório).
|
|
153
|
+
|
|
154
|
+
Se o job já atingiu ``max_retries``, ele é movido para dead-letter.
|
|
155
|
+
"""
|
|
156
|
+
delay_ms = int(delay * 1000)
|
|
157
|
+
self._get_native().nack(job.id, job.receipt, delay_ms, last_error)
|
|
158
|
+
|
|
159
|
+
def fail(self, job: Job, last_error: Optional[str] = None) -> None:
|
|
160
|
+
"""Marca o job como falha definitiva (dead-letter)."""
|
|
161
|
+
self._get_native().fail(job.id, job.receipt, last_error)
|
|
162
|
+
|
|
163
|
+
def extend_lease(self, job: Job, seconds: float) -> None:
|
|
164
|
+
"""Estende o lease de um job.
|
|
165
|
+
|
|
166
|
+
Levanta :class:`LeaseExpired` se o lease já tiver expirado.
|
|
167
|
+
"""
|
|
168
|
+
extend_ms = int(seconds * 1000)
|
|
169
|
+
new_expiration = self._get_native().extend_lease(
|
|
170
|
+
job.id, job.receipt, extend_ms
|
|
171
|
+
)
|
|
172
|
+
job.lease_expires_at = new_expiration / 1000.0
|
|
173
|
+
|
|
174
|
+
def reclaim_expired_leases(self) -> int:
|
|
175
|
+
"""Recupera jobs cujo lease expirou.
|
|
176
|
+
|
|
177
|
+
:return: número de leases recuperados.
|
|
178
|
+
"""
|
|
179
|
+
return self._get_native().reclaim_expired(None)
|
|
180
|
+
|
|
181
|
+
def stats(self) -> dict[str, int]:
|
|
182
|
+
"""Retorna estatísticas da fila."""
|
|
183
|
+
stats = self._get_native().stats()
|
|
184
|
+
return {
|
|
185
|
+
"ready": stats.ready,
|
|
186
|
+
"processing": stats.processing,
|
|
187
|
+
"acked": stats.acked,
|
|
188
|
+
"failed": stats.failed,
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
def _to_job(self, lease: "_native.Lease") -> Job:
|
|
192
|
+
data = self.serializer.loads(lease.payload)
|
|
193
|
+
return Job(
|
|
194
|
+
id=lease.id,
|
|
195
|
+
data=data,
|
|
196
|
+
attempts=max(0, lease.attempts - 1),
|
|
197
|
+
receipt=lease.receipt,
|
|
198
|
+
lease_expires_at=lease.lease_until / 1000.0,
|
|
199
|
+
queue=self,
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
def purge(self, older_than: float, *, include_failed: bool = False) -> int:
|
|
203
|
+
"""Remove mensagens antigas da fila.
|
|
204
|
+
|
|
205
|
+
:param older_than: idade máxima em segundos. Mensagens mais antigas
|
|
206
|
+
serão removidas.
|
|
207
|
+
:param include_failed: quando ``True``, também remove mensagens
|
|
208
|
+
``failed``. Por padrão apenas ``acked`` são removidas.
|
|
209
|
+
:return: número de mensagens removidas.
|
|
210
|
+
"""
|
|
211
|
+
if older_than < 0:
|
|
212
|
+
raise ValueError("'older_than' deve ser não negativo")
|
|
213
|
+
|
|
214
|
+
older_than_ms = int(older_than * 1000)
|
|
215
|
+
removed = 0
|
|
216
|
+
removed += self._get_native().purge(older_than_ms, 2) # acked
|
|
217
|
+
if include_failed:
|
|
218
|
+
removed += self._get_native().purge(older_than_ms, 3) # failed
|
|
219
|
+
return removed
|
|
220
|
+
|
|
221
|
+
def list_failed(
|
|
222
|
+
self, limit: int = 100, offset: int = 0
|
|
223
|
+
) -> list[dict[str, Any]]:
|
|
224
|
+
"""Lista mensagens na dead-letter.
|
|
225
|
+
|
|
226
|
+
:param limit: número máximo de mensagens.
|
|
227
|
+
:param offset: deslocamento para paginação.
|
|
228
|
+
:return: lista de dicionários com informações das mensagens.
|
|
229
|
+
"""
|
|
230
|
+
if limit < 0:
|
|
231
|
+
raise ValueError("'limit' deve ser não negativo")
|
|
232
|
+
if offset < 0:
|
|
233
|
+
raise ValueError("'offset' deve ser não negativo")
|
|
234
|
+
|
|
235
|
+
failed = self._get_native().list_failed(limit, offset)
|
|
236
|
+
return [
|
|
237
|
+
{
|
|
238
|
+
"id": msg.id,
|
|
239
|
+
"data": self.serializer.loads(msg.payload),
|
|
240
|
+
"attempts": msg.attempts,
|
|
241
|
+
"last_error": msg.last_error,
|
|
242
|
+
"created_at": msg.created_at / 1000.0,
|
|
243
|
+
"updated_at": msg.updated_at / 1000.0,
|
|
244
|
+
}
|
|
245
|
+
for msg in failed
|
|
246
|
+
]
|
|
247
|
+
|
|
248
|
+
def retry_failed(self, message_id: int) -> None:
|
|
249
|
+
"""Move uma mensagem da dead-letter de volta para a fila.
|
|
250
|
+
|
|
251
|
+
:param message_id: id da mensagem a ser retentada.
|
|
252
|
+
"""
|
|
253
|
+
self._get_native().retry_failed(message_id)
|
|
254
|
+
|
|
255
|
+
def vacuum(self) -> None:
|
|
256
|
+
"""Compacta todo o ``localqueue.db`` compartilhado pelas filas.
|
|
257
|
+
|
|
258
|
+
A operação pode disputar o lock do SQLite com workers ativos.
|
|
259
|
+
"""
|
|
260
|
+
self._get_native().vacuum()
|
|
261
|
+
|
|
262
|
+
def close(self) -> None:
|
|
263
|
+
"""Fecha a conexão com o banco."""
|
|
264
|
+
if self._native is not None:
|
|
265
|
+
self._native.close()
|
|
266
|
+
self._native = None
|
|
267
|
+
|
|
268
|
+
def __enter__(self) -> SimpleQueue:
|
|
269
|
+
return self
|
|
270
|
+
|
|
271
|
+
def __exit__(self, *exc: Any) -> None:
|
|
272
|
+
self.close()
|
localqueue/exceptions.py
ADDED
localqueue/job.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Modelo de job retornado pela fila."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class Job:
|
|
11
|
+
"""Representa um item retirado da fila para processamento."""
|
|
12
|
+
|
|
13
|
+
id: int
|
|
14
|
+
data: Any
|
|
15
|
+
attempts: int
|
|
16
|
+
receipt: str
|
|
17
|
+
lease_expires_at: float
|
|
18
|
+
queue: "SimpleQueue" # type: ignore[name-defined] # noqa: F821
|
|
19
|
+
|
|
20
|
+
def extend_lease(self, seconds: float) -> None:
|
|
21
|
+
"""Estende o lease do job.
|
|
22
|
+
|
|
23
|
+
Levanta :class:`LeaseExpired` se o lease já tiver expirado.
|
|
24
|
+
"""
|
|
25
|
+
self.queue.extend_lease(self, seconds)
|
|
Binary file
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Type stubs para o módulo nativo localqueue.localqueue."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
class Lease:
|
|
8
|
+
id: int
|
|
9
|
+
payload: bytes
|
|
10
|
+
attempts: int
|
|
11
|
+
receipt: str
|
|
12
|
+
lease_until: int
|
|
13
|
+
|
|
14
|
+
class Stats:
|
|
15
|
+
ready: int
|
|
16
|
+
processing: int
|
|
17
|
+
acked: int
|
|
18
|
+
failed: int
|
|
19
|
+
|
|
20
|
+
class FailedMessage:
|
|
21
|
+
id: int
|
|
22
|
+
payload: bytes
|
|
23
|
+
attempts: int
|
|
24
|
+
last_error: Optional[str]
|
|
25
|
+
created_at: int
|
|
26
|
+
updated_at: int
|
|
27
|
+
|
|
28
|
+
class NativeQueue:
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
path: str,
|
|
32
|
+
queue: str,
|
|
33
|
+
max_attempts: int = 3,
|
|
34
|
+
fsync: bool = False,
|
|
35
|
+
) -> None: ...
|
|
36
|
+
def put(self, payload: bytes, job_id: Optional[str] = None) -> int: ...
|
|
37
|
+
def get(self, lease_ms: int) -> Optional[Lease]: ...
|
|
38
|
+
def ack(self, id: int, receipt: str) -> None: ...
|
|
39
|
+
def nack(
|
|
40
|
+
self,
|
|
41
|
+
id: int,
|
|
42
|
+
receipt: str,
|
|
43
|
+
delay_ms: int = 0,
|
|
44
|
+
last_error: Optional[str] = None,
|
|
45
|
+
) -> None: ...
|
|
46
|
+
def fail(self, id: int, receipt: str, last_error: Optional[str] = None) -> None: ...
|
|
47
|
+
def extend_lease(self, id: int, receipt: str, extend_ms: int) -> int: ...
|
|
48
|
+
def reclaim_expired(self, now: Optional[int] = None) -> int: ...
|
|
49
|
+
def stats(self) -> Stats: ...
|
|
50
|
+
def purge(self, older_than_ms: int, status: Optional[int] = None) -> int: ...
|
|
51
|
+
def list_failed(self, limit: int = 100, offset: int = 0) -> list[FailedMessage]: ...
|
|
52
|
+
def retry_failed(self, id: int) -> None: ...
|
|
53
|
+
def vacuum(self) -> None: ...
|
|
54
|
+
def close(self) -> None: ...
|
|
55
|
+
|
|
56
|
+
class LocalQueueError(Exception): ...
|
|
57
|
+
class Empty(LocalQueueError): ...
|
|
58
|
+
class LeaseExpired(LocalQueueError): ...
|
localqueue/worker.py
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""Worker genérico para processar jobs do localqueue."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from typing import Any, Callable, Optional
|
|
7
|
+
|
|
8
|
+
from localqueue.core import SimpleQueue
|
|
9
|
+
from localqueue.exceptions import Empty, LeaseExpired
|
|
10
|
+
from localqueue.job import Job
|
|
11
|
+
|
|
12
|
+
log = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Worker:
|
|
16
|
+
"""Worker simples que consome jobs de uma :class:`SimpleQueue`.
|
|
17
|
+
|
|
18
|
+
O handler recebe o ``Job`` completo. Ele pode levantar exceções para
|
|
19
|
+
sinalizar erro:
|
|
20
|
+
|
|
21
|
+
* Qualquer exceção não listada em ``permanent_errors``: o job volta para
|
|
22
|
+
a fila (ou dead-letter se atingir ``max_retries``).
|
|
23
|
+
* Exceção em ``permanent_errors``: o job vai para dead-letter.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
queue: SimpleQueue,
|
|
29
|
+
handler: Callable[[Job], Any],
|
|
30
|
+
*,
|
|
31
|
+
permanent_errors: Optional[tuple[type[BaseException], ...]] = None,
|
|
32
|
+
poll_interval: float = 1.0,
|
|
33
|
+
heartbeat_interval: Optional[float] = None,
|
|
34
|
+
) -> None:
|
|
35
|
+
"""Inicializa o worker.
|
|
36
|
+
|
|
37
|
+
:param queue: fila a ser consumida.
|
|
38
|
+
:param handler: função que processa o job.
|
|
39
|
+
:param permanent_errors: tupla de exceções que devem mover o job para
|
|
40
|
+
dead-letter.
|
|
41
|
+
:param poll_interval: intervalo entre tentativas quando a fila está
|
|
42
|
+
vazia.
|
|
43
|
+
:param heartbeat_interval: se definido, renova o lease
|
|
44
|
+
automaticamente durante o processamento.
|
|
45
|
+
"""
|
|
46
|
+
if not poll_interval > 0:
|
|
47
|
+
raise ValueError("'poll_interval' deve ser positivo")
|
|
48
|
+
if heartbeat_interval is not None and not heartbeat_interval > 0:
|
|
49
|
+
raise ValueError("'heartbeat_interval' deve ser positivo")
|
|
50
|
+
|
|
51
|
+
self.queue = queue
|
|
52
|
+
self.handler = handler
|
|
53
|
+
self.permanent_errors = permanent_errors or ()
|
|
54
|
+
self.poll_interval = poll_interval
|
|
55
|
+
self.heartbeat_interval = heartbeat_interval
|
|
56
|
+
self._stop = False
|
|
57
|
+
|
|
58
|
+
def run(self) -> None:
|
|
59
|
+
"""Inicia o loop de consumo."""
|
|
60
|
+
while not self._stop:
|
|
61
|
+
try:
|
|
62
|
+
job = self.queue.get(block=True, timeout=self.poll_interval)
|
|
63
|
+
except Empty:
|
|
64
|
+
continue
|
|
65
|
+
|
|
66
|
+
self._process(job)
|
|
67
|
+
|
|
68
|
+
def run_once(self) -> bool:
|
|
69
|
+
"""Processa no máximo um job.
|
|
70
|
+
|
|
71
|
+
:return: ``True`` se um job foi processado, ``False`` se a fila estava
|
|
72
|
+
vazia.
|
|
73
|
+
"""
|
|
74
|
+
try:
|
|
75
|
+
job = self.queue.get(block=False)
|
|
76
|
+
except Empty:
|
|
77
|
+
return False
|
|
78
|
+
self._process(job)
|
|
79
|
+
return True
|
|
80
|
+
|
|
81
|
+
def _process(self, job: Job) -> None:
|
|
82
|
+
log.info("Processando job %s (tentativa %d)", job.id, job.attempts)
|
|
83
|
+
try:
|
|
84
|
+
if self.heartbeat_interval is not None:
|
|
85
|
+
result = self._run_with_heartbeat(job)
|
|
86
|
+
else:
|
|
87
|
+
result = self.handler(job)
|
|
88
|
+
except LeaseExpired:
|
|
89
|
+
log.warning(
|
|
90
|
+
"Lease do job %s foi perdido; resultado será descartado",
|
|
91
|
+
job.id,
|
|
92
|
+
)
|
|
93
|
+
return
|
|
94
|
+
except self.permanent_errors:
|
|
95
|
+
log.exception("Erro permanente no job %s", job.id)
|
|
96
|
+
self._transition(self.queue.fail, job)
|
|
97
|
+
except Exception:
|
|
98
|
+
log.exception("Erro transitório no job %s; devolvendo à fila", job.id)
|
|
99
|
+
self._transition(self.queue.nack, job)
|
|
100
|
+
else:
|
|
101
|
+
log.debug("Job %s processado com sucesso: %s", job.id, result)
|
|
102
|
+
self._transition(self.queue.ack, job)
|
|
103
|
+
|
|
104
|
+
def _transition(
|
|
105
|
+
self, operation: Callable[[Job], None], job: Job
|
|
106
|
+
) -> None:
|
|
107
|
+
try:
|
|
108
|
+
operation(job)
|
|
109
|
+
except LeaseExpired:
|
|
110
|
+
log.warning(
|
|
111
|
+
"Lease do job %s foi perdido antes da transição; "
|
|
112
|
+
"resultado será descartado",
|
|
113
|
+
job.id,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
def _run_with_heartbeat(self, job: Job) -> Any:
|
|
117
|
+
"""Executa o handler renovando o lease periodicamente."""
|
|
118
|
+
import threading
|
|
119
|
+
|
|
120
|
+
result: list[Any] = []
|
|
121
|
+
error: list[BaseException] = []
|
|
122
|
+
done = threading.Event()
|
|
123
|
+
lease_lost = False
|
|
124
|
+
|
|
125
|
+
def target() -> None:
|
|
126
|
+
try:
|
|
127
|
+
result.append(self.handler(job))
|
|
128
|
+
except BaseException as exc: # noqa: BLE001
|
|
129
|
+
error.append(exc)
|
|
130
|
+
finally:
|
|
131
|
+
done.set()
|
|
132
|
+
|
|
133
|
+
thread = threading.Thread(target=target, daemon=True)
|
|
134
|
+
thread.start()
|
|
135
|
+
|
|
136
|
+
while not done.wait(self.heartbeat_interval):
|
|
137
|
+
try:
|
|
138
|
+
job.extend_lease(self.queue.lease_seconds)
|
|
139
|
+
except Exception:
|
|
140
|
+
log.warning("Perdeu o lease do job %s", job.id)
|
|
141
|
+
lease_lost = True
|
|
142
|
+
break
|
|
143
|
+
|
|
144
|
+
# Espera o handler terminar, mesmo se o lease foi perdido.
|
|
145
|
+
done.wait()
|
|
146
|
+
|
|
147
|
+
if lease_lost:
|
|
148
|
+
raise LeaseExpired(f"job {job.id} perdeu o lease durante o processamento")
|
|
149
|
+
|
|
150
|
+
if error:
|
|
151
|
+
raise error[0]
|
|
152
|
+
return result[0] if result else None
|
|
153
|
+
|
|
154
|
+
def stop(self) -> None:
|
|
155
|
+
"""Sinaliza para o worker parar no próximo ciclo."""
|
|
156
|
+
self._stop = True
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: localqueue
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Requires-Dist: persist-queue==1.1.0 ; extra == 'benchmark'
|
|
5
|
+
Requires-Dist: pytest>=8.0 ; extra == 'dev'
|
|
6
|
+
Requires-Dist: maturin>=1.14 ; extra == 'dev'
|
|
7
|
+
Provides-Extra: benchmark
|
|
8
|
+
Provides-Extra: dev
|
|
9
|
+
Summary: Fila persistente local em SQLite com ACK, lease e retry.
|
|
10
|
+
Requires-Python: >=3.10
|
|
11
|
+
Description-Content-Type: text/markdown; charset=UTF-8; variant=GFM
|
|
12
|
+
|
|
13
|
+
# localqueue
|
|
14
|
+
|
|
15
|
+
Fila persistente local em SQLite com ACK, lease e retry.
|
|
16
|
+
|
|
17
|
+
O motor transacional é implementado em **Rust** (extensão nativa via pyo3/maturin),
|
|
18
|
+
garantindo atomicidade mesmo com múltiplos processos e threads. A facade em
|
|
19
|
+
**Python** mantém uma API simples e agradável.
|
|
20
|
+
|
|
21
|
+
## Características
|
|
22
|
+
|
|
23
|
+
- Persistência em disco via SQLite (sobrevive a reinícios).
|
|
24
|
+
- Semântica de ACK/NACK/falha com **receipt/fencing token**.
|
|
25
|
+
- Lease/timeout: jobs não confirmados dentro do prazo voltam para a fila.
|
|
26
|
+
- Retry com limite máximo; após isso vão para dead-letter.
|
|
27
|
+
- Deduplicação opcional por `job_id`.
|
|
28
|
+
- Extensão de lease (`job.extend_lease(...)`) e heartbeat no worker.
|
|
29
|
+
- Estatísticas consistentes mesmo em multiprocessos.
|
|
30
|
+
- Serialização JSON por padrão (legível e interoperável).
|
|
31
|
+
- Suporte a múltiplas filas no mesmo banco (`name="foo"`, `name="bar"`).
|
|
32
|
+
|
|
33
|
+
## Arquitetura
|
|
34
|
+
|
|
35
|
+
```text
|
|
36
|
+
localqueue/
|
|
37
|
+
├── src/ # Rust: schema, storage, queue, error, lib (pyo3)
|
|
38
|
+
├── python/localqueue/ # Python: SimpleQueue, Job, Worker, exceções
|
|
39
|
+
├── Cargo.toml
|
|
40
|
+
└── pyproject.toml # build via maturin
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Toda a máquina de estados vive em uma única tabela SQLite (`messages`), com
|
|
44
|
+
transações `BEGIN IMMEDIATE` para atomicidade. Não há camadas auxiliares de
|
|
45
|
+
estado: cada operação (`put`, `get`, `ack`, `nack`, `fail`) é uma única
|
|
46
|
+
transação.
|
|
47
|
+
|
|
48
|
+
## Uso rápido
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
from localqueue import SimpleQueue
|
|
52
|
+
|
|
53
|
+
with SimpleQueue("./data", lease_seconds=30, max_retries=3) as q:
|
|
54
|
+
q.put({"type": "deploy", "app": "jarvis", "revision": "abc123"})
|
|
55
|
+
|
|
56
|
+
job = q.get()
|
|
57
|
+
try:
|
|
58
|
+
process(job.data)
|
|
59
|
+
except Exception:
|
|
60
|
+
q.nack(job)
|
|
61
|
+
else:
|
|
62
|
+
q.ack(job)
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Worker
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
from localqueue import SimpleQueue, Worker
|
|
69
|
+
|
|
70
|
+
def deploy(job):
|
|
71
|
+
print(f"Deploying {job.data['app']}@{job.data['revision']}")
|
|
72
|
+
# Opcional: renova lease para jobs longos
|
|
73
|
+
job.extend_lease(60)
|
|
74
|
+
|
|
75
|
+
q = SimpleQueue("./data")
|
|
76
|
+
worker = Worker(q, deploy, heartbeat_interval=10)
|
|
77
|
+
worker.run()
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## Multithreading / multiprocessos
|
|
81
|
+
|
|
82
|
+
A fila é segura para uso com múltiplas threads e processos. O SQLite (em WAL)
|
|
83
|
+
serializa escritas, mas `BEGIN IMMEDIATE` e `busy_timeout` garantem que
|
|
84
|
+
operações concorrentes não corrompam o estado.
|
|
85
|
+
|
|
86
|
+
## Idempotência
|
|
87
|
+
|
|
88
|
+
Mesmo com fencing token, um job pode ser entregue mais de uma vez (por
|
|
89
|
+
exemplo, se o worker concluir um efeito externo e morrer antes de executar
|
|
90
|
+
`ack`). A fila oferece semântica at-least-once, não exactly-once.
|
|
91
|
+
Recomenda-se que os handlers sejam idempotentes — use `job_id` para
|
|
92
|
+
deduplicação quando necessário.
|
|
93
|
+
|
|
94
|
+
As garantias completas de durabilidade, leases, retries e fencing estão em
|
|
95
|
+
[`docs/guarantees.md`](docs/guarantees.md).
|
|
96
|
+
|
|
97
|
+
## API resumida
|
|
98
|
+
|
|
99
|
+
* ``put(data, job_id=None)`` – enfileira um item.
|
|
100
|
+
* ``get(block=True, timeout=None)`` – retira um item com lease.
|
|
101
|
+
* ``get_nowait()`` – variação não bloqueante.
|
|
102
|
+
* ``ack(job)`` – confirma processamento.
|
|
103
|
+
* ``nack(job, delay=0, last_error=None)`` – devolve à fila (erro transitório).
|
|
104
|
+
* ``fail(job, last_error=None)`` – envia para dead-letter.
|
|
105
|
+
* ``extend_lease(job, seconds)`` – renova o lease de um job.
|
|
106
|
+
* ``reclaim_expired_leases()`` – recupera leases expirados manualmente.
|
|
107
|
+
* ``stats()`` – retorna contagens de ready, processing, acked e failed.
|
|
108
|
+
|
|
109
|
+
## Manutenção
|
|
110
|
+
|
|
111
|
+
* ``purge(older_than, include_failed=False)`` – remove mensagens antigas.
|
|
112
|
+
* ``list_failed(limit=100, offset=0)`` – lista mensagens na dead-letter.
|
|
113
|
+
* ``retry_failed(message_id)`` – move mensagem failed de volta para ready.
|
|
114
|
+
* ``vacuum()`` – compacta todo o arquivo compartilhado ``localqueue.db``. Pode
|
|
115
|
+
disputar o lock do SQLite com workers ativos, portanto prefira executá-lo em
|
|
116
|
+
uma janela de manutenção.
|
|
117
|
+
|
|
118
|
+
## Desenvolvimento
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
uv sync --extra dev
|
|
122
|
+
maturin develop
|
|
123
|
+
pytest
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
## Build para distribuição
|
|
127
|
+
|
|
128
|
+
```bash
|
|
129
|
+
maturin build --release
|
|
130
|
+
```
|
|
131
|
+
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
localqueue/__init__.py,sha256=t4A3Jck_OlW_tPU_TEx0Jd9-hXhgyGDpbI6-1U3b3FU,424
|
|
2
|
+
localqueue/core.py,sha256=3v2SgWab_EwYtjAdQ4v1zM1KqZ3xOFIZy-2FV4CAm0g,9206
|
|
3
|
+
localqueue/exceptions.py,sha256=6QLLFOaNDTGUW7sEXd1geLgeub_Dcpma3pJvf77J93Y,174
|
|
4
|
+
localqueue/job.py,sha256=3hPuysli4MMVMp0TrHGgCk3ZF8CBMZae4ElGvg0OB-E,626
|
|
5
|
+
localqueue/localqueue.cp312-win_amd64.pyd,sha256=Oc3-clPGdjm6f90wOMpBRoRI9Ec984fyCquB5P9RqqM,2016256
|
|
6
|
+
localqueue/localqueue.pyi,sha256=Sk8LiP68YKOaDhX0_mwViw44vshRakK-koQg5xeyAhE,1690
|
|
7
|
+
localqueue/worker.py,sha256=Lo9teBb8CAuMsQECKV4_kVo1M_fAfiOEAMoHDu9JWdI,5274
|
|
8
|
+
localqueue-1.0.0.dist-info/METADATA,sha256=SPAz2jtddAlJLzI69HcYlbvsmx53c2oJIsuHb8lw7-8,4415
|
|
9
|
+
localqueue-1.0.0.dist-info/WHEEL,sha256=SN7W5s0Ji01-9u_6QL7cEnscsXvljOVQDFMt-2tILwI,97
|
|
10
|
+
localqueue-1.0.0.dist-info/sboms/localqueue.cyclonedx.json,sha256=68KV1N48gKG3FVCOIRkp8YUZY6cGLZKueEtFT0X7DeQ,36852
|
|
11
|
+
localqueue-1.0.0.dist-info/RECORD,,
|