bunqueue-client 0.1.5__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.
- bunqueue/__init__.py +69 -0
- bunqueue/ack_batcher.py +93 -0
- bunqueue/connection.py +240 -0
- bunqueue/connection_lifecycle.py +104 -0
- bunqueue/errors.py +38 -0
- bunqueue/events.py +56 -0
- bunqueue/flow.py +257 -0
- bunqueue/job.py +179 -0
- bunqueue/options.py +126 -0
- bunqueue/py.typed +0 -0
- bunqueue/queue.py +230 -0
- bunqueue/queue_admin.py +201 -0
- bunqueue/queue_query.py +204 -0
- bunqueue/simple/__init__.py +16 -0
- bunqueue/simple/aging.py +68 -0
- bunqueue/simple/app.py +163 -0
- bunqueue/simple/app_api.py +142 -0
- bunqueue/simple/batch.py +88 -0
- bunqueue/simple/cancellation.py +79 -0
- bunqueue/simple/circuit_breaker.py +93 -0
- bunqueue/simple/dedup_debounce.py +42 -0
- bunqueue/simple/rate_gate.py +57 -0
- bunqueue/simple/retry.py +65 -0
- bunqueue/simple/triggers.py +45 -0
- bunqueue/simple/ttl.py +34 -0
- bunqueue/telemetry.py +29 -0
- bunqueue/transport.py +50 -0
- bunqueue/wire.py +99 -0
- bunqueue/worker.py +259 -0
- bunqueue/worker_runtime.py +221 -0
- bunqueue_client-0.1.5.dist-info/METADATA +197 -0
- bunqueue_client-0.1.5.dist-info/RECORD +34 -0
- bunqueue_client-0.1.5.dist-info/WHEEL +4 -0
- bunqueue_client-0.1.5.dist-info/licenses/LICENSE +21 -0
bunqueue/__init__.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""bunqueue Python SDK — TCP client for the bunqueue job queue server.
|
|
2
|
+
|
|
3
|
+
Feature parity with the TypeScript client (TCP mode): Queue, Worker,
|
|
4
|
+
FlowProducer, Job, plus the full management/command surface.
|
|
5
|
+
|
|
6
|
+
Usage::
|
|
7
|
+
|
|
8
|
+
from bunqueue import Queue, Worker
|
|
9
|
+
|
|
10
|
+
queue = Queue("emails", host="localhost", port=6789)
|
|
11
|
+
queue.add("send", {"to": "user@example.com"}, priority=5)
|
|
12
|
+
|
|
13
|
+
def process(job):
|
|
14
|
+
job.update_progress(50)
|
|
15
|
+
return {"sent": True}
|
|
16
|
+
|
|
17
|
+
Worker("emails", process, concurrency=10).run()
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import logging as _logging
|
|
21
|
+
|
|
22
|
+
from .connection import Connection
|
|
23
|
+
from .errors import (
|
|
24
|
+
AuthError,
|
|
25
|
+
BunqueueError,
|
|
26
|
+
CommandError,
|
|
27
|
+
CommandTimeoutError,
|
|
28
|
+
ConnectionClosedError,
|
|
29
|
+
SerializationError,
|
|
30
|
+
UnrecoverableError,
|
|
31
|
+
)
|
|
32
|
+
from .events import EventEmitter
|
|
33
|
+
from .flow import FlowNode, FlowProducer
|
|
34
|
+
from .job import Job
|
|
35
|
+
from .queue import Queue
|
|
36
|
+
from .simple import Bunqueue, CancellationManager, CancelSignal
|
|
37
|
+
from .telemetry import TelemetryEvent, TelemetryHandler
|
|
38
|
+
from .worker import Worker
|
|
39
|
+
|
|
40
|
+
# Library logging convention: a NullHandler on the package logger so the SDK
|
|
41
|
+
# stays silent unless the application configures logging. Internal warnings
|
|
42
|
+
# (swallowed command failures, raising listeners, background-task errors) go
|
|
43
|
+
# to logging.getLogger("bunqueue").
|
|
44
|
+
_logging.getLogger("bunqueue").addHandler(_logging.NullHandler())
|
|
45
|
+
|
|
46
|
+
__version__ = "0.1.4"
|
|
47
|
+
|
|
48
|
+
__all__ = [
|
|
49
|
+
"AuthError",
|
|
50
|
+
"Bunqueue",
|
|
51
|
+
"BunqueueError",
|
|
52
|
+
"CancelSignal",
|
|
53
|
+
"CancellationManager",
|
|
54
|
+
"CommandError",
|
|
55
|
+
"CommandTimeoutError",
|
|
56
|
+
"Connection",
|
|
57
|
+
"ConnectionClosedError",
|
|
58
|
+
"EventEmitter",
|
|
59
|
+
"FlowNode",
|
|
60
|
+
"FlowProducer",
|
|
61
|
+
"Job",
|
|
62
|
+
"Queue",
|
|
63
|
+
"SerializationError",
|
|
64
|
+
"TelemetryEvent",
|
|
65
|
+
"TelemetryHandler",
|
|
66
|
+
"UnrecoverableError",
|
|
67
|
+
"Worker",
|
|
68
|
+
"__version__",
|
|
69
|
+
]
|
bunqueue/ack_batcher.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Batches completed-job ACKs into ACKB round-trips for higher throughput.
|
|
2
|
+
|
|
3
|
+
Opt-in (see the Worker ``ack_batch`` option; mirrors the TypeScript SDK's
|
|
4
|
+
AckBatcher). Items flush when the buffer reaches ``max_size`` or after
|
|
5
|
+
``max_delay_ms``, whichever comes first; :meth:`flush` drains the rest on
|
|
6
|
+
shutdown. Each item's ``on_settled`` fires after the batch is acked (or
|
|
7
|
+
errored), so the worker keeps a job "active", and its lock renewed, until
|
|
8
|
+
the server has confirmed the ack.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import logging
|
|
14
|
+
import threading
|
|
15
|
+
from typing import Any, Callable, List, Optional
|
|
16
|
+
|
|
17
|
+
from .wire import _compact
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger("bunqueue")
|
|
20
|
+
|
|
21
|
+
# Called once the batch settles: the error on failure, None on ack.
|
|
22
|
+
SettleCallback = Callable[[Optional[BaseException]], None]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class AckItem:
|
|
26
|
+
"""One buffered ACK: job id, lock token, result, settle callback."""
|
|
27
|
+
|
|
28
|
+
__slots__ = ("id", "token", "result", "on_settled")
|
|
29
|
+
|
|
30
|
+
def __init__(
|
|
31
|
+
self, id: str, token: str, result: Any, on_settled: SettleCallback
|
|
32
|
+
) -> None:
|
|
33
|
+
self.id = id
|
|
34
|
+
self.token = token
|
|
35
|
+
self.result = result
|
|
36
|
+
self.on_settled = on_settled
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class AckBatcher:
|
|
40
|
+
"""Thread-safe ACK buffer flushed as a single ACKB command."""
|
|
41
|
+
|
|
42
|
+
def __init__(self, connection: Any, max_size: int = 50, max_delay_ms: float = 5) -> None:
|
|
43
|
+
self._connection = connection
|
|
44
|
+
self._max_size = max(1, max_size)
|
|
45
|
+
self._max_delay_s = max_delay_ms / 1000.0
|
|
46
|
+
self._buffer: List[AckItem] = []
|
|
47
|
+
self._lock = threading.Lock()
|
|
48
|
+
self._timer: Optional[threading.Timer] = None
|
|
49
|
+
|
|
50
|
+
def add(self, item: AckItem) -> None:
|
|
51
|
+
with self._lock:
|
|
52
|
+
self._buffer.append(item)
|
|
53
|
+
full = len(self._buffer) >= self._max_size
|
|
54
|
+
if not full and self._timer is None:
|
|
55
|
+
self._timer = threading.Timer(self._max_delay_s, self.flush)
|
|
56
|
+
self._timer.daemon = True # don't keep the process alive
|
|
57
|
+
self._timer.start()
|
|
58
|
+
if full:
|
|
59
|
+
self.flush()
|
|
60
|
+
|
|
61
|
+
def flush(self) -> None:
|
|
62
|
+
"""Send the buffered ACKs as one ACKB (no-op when empty)."""
|
|
63
|
+
with self._lock:
|
|
64
|
+
if self._timer is not None:
|
|
65
|
+
self._timer.cancel()
|
|
66
|
+
self._timer = None
|
|
67
|
+
batch, self._buffer = self._buffer, []
|
|
68
|
+
if not batch:
|
|
69
|
+
return
|
|
70
|
+
# Capture the wire outcome first; settle callbacks run OUTSIDE the try
|
|
71
|
+
# so a throwing callback can neither be re-invoked with an error
|
|
72
|
+
# (double settle) nor starve the remaining items of their callback.
|
|
73
|
+
error: Optional[BaseException] = None
|
|
74
|
+
try:
|
|
75
|
+
self._connection.call(
|
|
76
|
+
_compact(
|
|
77
|
+
{
|
|
78
|
+
"cmd": "ACKB",
|
|
79
|
+
"ids": [item.id for item in batch],
|
|
80
|
+
"tokens": [item.token for item in batch],
|
|
81
|
+
"results": [item.result for item in batch],
|
|
82
|
+
}
|
|
83
|
+
)
|
|
84
|
+
)
|
|
85
|
+
except BaseException as exc: # noqa: BLE001 - settle every item, always
|
|
86
|
+
error = exc
|
|
87
|
+
for item in batch:
|
|
88
|
+
try:
|
|
89
|
+
item.on_settled(error)
|
|
90
|
+
except Exception: # noqa: BLE001
|
|
91
|
+
# A callback error (e.g. a raising listener in the worker) must
|
|
92
|
+
# never prevent the other items from settling.
|
|
93
|
+
logger.warning("ACKB settle callback for job %s raised", item.id, exc_info=True)
|
bunqueue/connection.py
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
"""TCP connection to a bunqueue server.
|
|
2
|
+
|
|
3
|
+
Wire protocol: each message is a 4-byte big-endian length prefix followed by a
|
|
4
|
+
standard msgpack map. Requests carry a ``reqId`` string; the server echoes it
|
|
5
|
+
back, which allows pipelining (many in-flight commands on one socket).
|
|
6
|
+
|
|
7
|
+
Thread-safe: any thread may call :meth:`Connection.call`. A single daemon
|
|
8
|
+
reader thread demultiplexes responses to their pending futures.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import socket
|
|
14
|
+
import struct
|
|
15
|
+
import threading
|
|
16
|
+
from concurrent.futures import Future
|
|
17
|
+
from concurrent.futures import TimeoutError as FutureTimeoutError
|
|
18
|
+
from typing import Any, Dict, Optional
|
|
19
|
+
|
|
20
|
+
import msgpack
|
|
21
|
+
|
|
22
|
+
from .connection_lifecycle import ConnectionLifecycle
|
|
23
|
+
from .errors import (
|
|
24
|
+
CommandError,
|
|
25
|
+
CommandTimeoutError,
|
|
26
|
+
ConnectionClosedError,
|
|
27
|
+
)
|
|
28
|
+
from .telemetry import Telemetry, TelemetryHandler
|
|
29
|
+
from .transport import encode_command
|
|
30
|
+
from .wire import (
|
|
31
|
+
MAX_FRAME_SIZE,
|
|
32
|
+
PROTOCOL_VERSION,
|
|
33
|
+
TlsOption,
|
|
34
|
+
_compact,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
__all__ = ["Connection", "TlsOption", "_compact", "PROTOCOL_VERSION", "MAX_FRAME_SIZE"]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class Connection(ConnectionLifecycle):
|
|
41
|
+
"""A single pipelined TCP connection to a bunqueue server."""
|
|
42
|
+
|
|
43
|
+
def __init__(
|
|
44
|
+
self,
|
|
45
|
+
host: str = "localhost",
|
|
46
|
+
port: int = 6789,
|
|
47
|
+
token: Optional[str] = None,
|
|
48
|
+
tls: TlsOption = None,
|
|
49
|
+
connect_timeout: float = 5.0,
|
|
50
|
+
command_timeout: float = 10.0,
|
|
51
|
+
on_telemetry: Optional[TelemetryHandler] = None,
|
|
52
|
+
) -> None:
|
|
53
|
+
self.host = host
|
|
54
|
+
self.port = port
|
|
55
|
+
self.token = token
|
|
56
|
+
self.tls = tls
|
|
57
|
+
self.connect_timeout = connect_timeout
|
|
58
|
+
self.command_timeout = command_timeout
|
|
59
|
+
self._telemetry = Telemetry(on_telemetry)
|
|
60
|
+
|
|
61
|
+
self._sock: Optional[socket.socket] = None
|
|
62
|
+
self._connected = False
|
|
63
|
+
self._closed = False
|
|
64
|
+
# Reentrant: connect() holds this while sending Auth via _send(), which
|
|
65
|
+
# may itself call _teardown() on a failed send — both need the lock.
|
|
66
|
+
self._conn_lock = threading.RLock() # serializes connect/teardown
|
|
67
|
+
self._write_lock = threading.Lock()
|
|
68
|
+
self._pending_lock = threading.Lock()
|
|
69
|
+
self._pending: Dict[str, Future] = {}
|
|
70
|
+
self._req_counter = 0
|
|
71
|
+
self._generation = 0
|
|
72
|
+
self._failed_attempts = 0
|
|
73
|
+
self._next_attempt_at = 0.0
|
|
74
|
+
# Half-open recovery (#94): after this many consecutive command timeouts
|
|
75
|
+
# the socket is presumed dead and torn down so the next call reconnects.
|
|
76
|
+
self._max_command_timeouts = 3
|
|
77
|
+
self._consecutive_timeouts = 0
|
|
78
|
+
|
|
79
|
+
# ------------------------------------------------------------- commands
|
|
80
|
+
|
|
81
|
+
def call(self, command: Dict[str, Any], timeout: Optional[float] = None) -> Dict[str, Any]:
|
|
82
|
+
"""Send a command and wait for its response.
|
|
83
|
+
|
|
84
|
+
Raises :class:`CommandError` when the server answers ``ok=false``.
|
|
85
|
+
Reconnects lazily if the connection was lost.
|
|
86
|
+
"""
|
|
87
|
+
if not self._connected:
|
|
88
|
+
self.connect()
|
|
89
|
+
return self._send(command, timeout)
|
|
90
|
+
|
|
91
|
+
def _send(self, command: Dict[str, Any], timeout: Optional[float] = None) -> Dict[str, Any]:
|
|
92
|
+
"""Frame + write a command and await its response on the open socket.
|
|
93
|
+
|
|
94
|
+
Assumes the socket is up (used both by :meth:`call` and, during
|
|
95
|
+
:meth:`connect`, to send the initial Auth before ``_connected`` flips).
|
|
96
|
+
"""
|
|
97
|
+
gen = self._generation # snapshot: a timeout must not tear down a newer conn
|
|
98
|
+
started_at = self._telemetry.now_ms()
|
|
99
|
+
with self._pending_lock:
|
|
100
|
+
self._req_counter = (self._req_counter + 1) & 0x7FFFFFFF
|
|
101
|
+
req_id = str(self._req_counter)
|
|
102
|
+
|
|
103
|
+
try:
|
|
104
|
+
frame = encode_command(command, req_id)
|
|
105
|
+
except Exception as exc:
|
|
106
|
+
self._telemetry.emit("error", operation="serialize", error=str(exc))
|
|
107
|
+
raise
|
|
108
|
+
|
|
109
|
+
with self._pending_lock:
|
|
110
|
+
fut: Future = Future()
|
|
111
|
+
self._pending[req_id] = fut
|
|
112
|
+
|
|
113
|
+
sock = self._sock
|
|
114
|
+
try:
|
|
115
|
+
if sock is None:
|
|
116
|
+
raise OSError("socket gone")
|
|
117
|
+
with self._write_lock:
|
|
118
|
+
sock.sendall(frame)
|
|
119
|
+
except OSError as exc:
|
|
120
|
+
with self._pending_lock:
|
|
121
|
+
self._pending.pop(req_id, None)
|
|
122
|
+
self._teardown()
|
|
123
|
+
self._telemetry.emit("error", operation="write", error=str(exc))
|
|
124
|
+
raise ConnectionClosedError(f"send failed: {exc}") from exc
|
|
125
|
+
|
|
126
|
+
try:
|
|
127
|
+
response = fut.result(timeout if timeout is not None else self.command_timeout)
|
|
128
|
+
except FutureTimeoutError:
|
|
129
|
+
with self._pending_lock:
|
|
130
|
+
self._pending.pop(req_id, None)
|
|
131
|
+
self._note_timeout(gen)
|
|
132
|
+
self._telemetry.emit(
|
|
133
|
+
"command_timeout", cmd=command.get("cmd"), req_id=req_id
|
|
134
|
+
)
|
|
135
|
+
raise CommandTimeoutError(f"no response for {command.get('cmd')} within timeout") from None
|
|
136
|
+
|
|
137
|
+
self._consecutive_timeouts = 0
|
|
138
|
+
if isinstance(response, BaseException):
|
|
139
|
+
raise response
|
|
140
|
+
self._telemetry.emit(
|
|
141
|
+
"command",
|
|
142
|
+
cmd=command.get("cmd"),
|
|
143
|
+
req_id=req_id,
|
|
144
|
+
duration_ms=self._telemetry.now_ms() - started_at,
|
|
145
|
+
ok=bool(response.get("ok")),
|
|
146
|
+
)
|
|
147
|
+
if not response.get("ok"):
|
|
148
|
+
raise CommandError(str(response.get("error", "unknown server error")))
|
|
149
|
+
return response
|
|
150
|
+
|
|
151
|
+
def _note_timeout(self, gen: int) -> None:
|
|
152
|
+
"""Tear down after repeated timeouts so a dead/half-open link recovers.
|
|
153
|
+
|
|
154
|
+
On a link the peer dropped without FIN/RST, every command times out
|
|
155
|
+
while the socket still looks connected. Counting consecutive timeouts
|
|
156
|
+
and forcing a teardown lets the next :meth:`call` reconnect instead of
|
|
157
|
+
wedging until the OS abandons the writes (~tcp_retries2, minutes).
|
|
158
|
+
|
|
159
|
+
``gen`` is the connection generation at send time: a timeout from an
|
|
160
|
+
already-replaced connection must not tear down (or miscount against)
|
|
161
|
+
the current one."""
|
|
162
|
+
if gen != self._generation:
|
|
163
|
+
return
|
|
164
|
+
self._consecutive_timeouts += 1
|
|
165
|
+
if self._consecutive_timeouts >= self._max_command_timeouts:
|
|
166
|
+
self._teardown()
|
|
167
|
+
|
|
168
|
+
def ping(self) -> bool:
|
|
169
|
+
try:
|
|
170
|
+
response = self.call({"cmd": "Ping"})
|
|
171
|
+
except (ConnectionClosedError, CommandTimeoutError, CommandError):
|
|
172
|
+
return False
|
|
173
|
+
data = response.get("data")
|
|
174
|
+
return bool(isinstance(data, dict) and data.get("pong"))
|
|
175
|
+
|
|
176
|
+
def hello(self) -> Dict[str, Any]:
|
|
177
|
+
"""Protocol negotiation; returns server name/version/protocolVersion."""
|
|
178
|
+
return self.call(
|
|
179
|
+
{"cmd": "Hello", "protocolVersion": PROTOCOL_VERSION, "capabilities": ["pipelining"]}
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
# ------------------------------------------------------------- internals
|
|
183
|
+
|
|
184
|
+
def _read_loop(self, sock: socket.socket) -> None:
|
|
185
|
+
buffer = bytearray()
|
|
186
|
+
try:
|
|
187
|
+
while True:
|
|
188
|
+
chunk = sock.recv(65536)
|
|
189
|
+
if not chunk:
|
|
190
|
+
break
|
|
191
|
+
buffer.extend(chunk)
|
|
192
|
+
while len(buffer) >= 4:
|
|
193
|
+
(length,) = struct.unpack_from(">I", buffer)
|
|
194
|
+
if length > MAX_FRAME_SIZE:
|
|
195
|
+
raise ConnectionClosedError("frame exceeds max size")
|
|
196
|
+
if len(buffer) < 4 + length:
|
|
197
|
+
break
|
|
198
|
+
frame = bytes(buffer[4 : 4 + length])
|
|
199
|
+
del buffer[: 4 + length]
|
|
200
|
+
self._dispatch(frame)
|
|
201
|
+
except (OSError, ConnectionClosedError):
|
|
202
|
+
pass
|
|
203
|
+
finally:
|
|
204
|
+
# Only tear down if this reader's socket is still current: a stale
|
|
205
|
+
# reader from a previous connection must not kill a fresh one.
|
|
206
|
+
if self._sock is sock:
|
|
207
|
+
self._teardown()
|
|
208
|
+
|
|
209
|
+
def _dispatch(self, frame: bytes) -> None:
|
|
210
|
+
try:
|
|
211
|
+
message = msgpack.unpackb(frame, raw=False)
|
|
212
|
+
except Exception:
|
|
213
|
+
return # unparseable frame: skip; stream desync ends via recv error
|
|
214
|
+
req_id = message.get("reqId") if isinstance(message, dict) else None
|
|
215
|
+
if req_id is None:
|
|
216
|
+
return # server-push messages are not supported yet
|
|
217
|
+
with self._pending_lock:
|
|
218
|
+
fut = self._pending.pop(str(req_id), None)
|
|
219
|
+
if fut is not None and not fut.done():
|
|
220
|
+
fut.set_result(message)
|
|
221
|
+
|
|
222
|
+
def _teardown(self) -> None:
|
|
223
|
+
with self._conn_lock:
|
|
224
|
+
was_connected = self._connected
|
|
225
|
+
self._connected = False
|
|
226
|
+
sock, self._sock = self._sock, None
|
|
227
|
+
if sock is not None:
|
|
228
|
+
try:
|
|
229
|
+
sock.close()
|
|
230
|
+
except OSError:
|
|
231
|
+
pass
|
|
232
|
+
with self._pending_lock:
|
|
233
|
+
pending, self._pending = self._pending, {}
|
|
234
|
+
for fut in pending.values():
|
|
235
|
+
if not fut.done():
|
|
236
|
+
fut.set_result(ConnectionClosedError("connection lost"))
|
|
237
|
+
if was_connected:
|
|
238
|
+
self._telemetry.emit(
|
|
239
|
+
"disconnect", host=self.host, port=self.port, generation=self._generation
|
|
240
|
+
)
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Connection establishment, authentication, backoff, and close lifecycle."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import socket
|
|
6
|
+
import threading
|
|
7
|
+
import time
|
|
8
|
+
|
|
9
|
+
from .errors import AuthError, CommandError, CommandTimeoutError, ConnectionClosedError
|
|
10
|
+
from .transport import enable_keepalive
|
|
11
|
+
from .wire import _build_ssl_context
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ConnectionLifecycle:
|
|
15
|
+
@property
|
|
16
|
+
def generation(self) -> int:
|
|
17
|
+
"""Monotonic counter bumped on every successful reconnect."""
|
|
18
|
+
return self._generation
|
|
19
|
+
|
|
20
|
+
def connect(self) -> None:
|
|
21
|
+
"""Open the socket and authenticate before publishing it as connected."""
|
|
22
|
+
with self._conn_lock:
|
|
23
|
+
if self._connected:
|
|
24
|
+
return
|
|
25
|
+
if self._closed:
|
|
26
|
+
raise ConnectionClosedError("connection closed by client")
|
|
27
|
+
now = time.monotonic()
|
|
28
|
+
if now < self._next_attempt_at:
|
|
29
|
+
remaining = int((self._next_attempt_at - now) * 1000)
|
|
30
|
+
raise ConnectionClosedError(f"server unreachable, retry in {remaining}ms")
|
|
31
|
+
|
|
32
|
+
started_at = self._telemetry.now_ms()
|
|
33
|
+
try:
|
|
34
|
+
raw = socket.create_connection(
|
|
35
|
+
(self.host, self.port), timeout=self.connect_timeout
|
|
36
|
+
)
|
|
37
|
+
except OSError as exc:
|
|
38
|
+
self._note_connect_failure()
|
|
39
|
+
self._telemetry.emit("error", operation="connect", error=str(exc))
|
|
40
|
+
raise ConnectionClosedError(f"connect failed: {exc}") from exc
|
|
41
|
+
raw.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
|
|
42
|
+
enable_keepalive(raw)
|
|
43
|
+
context = _build_ssl_context(self.tls, self.host)
|
|
44
|
+
if context is not None:
|
|
45
|
+
try:
|
|
46
|
+
raw = context.wrap_socket(raw, server_hostname=self.host)
|
|
47
|
+
except OSError as exc:
|
|
48
|
+
try:
|
|
49
|
+
raw.close()
|
|
50
|
+
except OSError:
|
|
51
|
+
pass
|
|
52
|
+
self._note_connect_failure()
|
|
53
|
+
self._telemetry.emit("error", operation="tls", error=str(exc))
|
|
54
|
+
raise ConnectionClosedError(f"TLS handshake failed: {exc}") from exc
|
|
55
|
+
|
|
56
|
+
self._failed_attempts = 0
|
|
57
|
+
self._next_attempt_at = 0.0
|
|
58
|
+
raw.settimeout(None)
|
|
59
|
+
self._sock = raw
|
|
60
|
+
self._generation += 1
|
|
61
|
+
self._consecutive_timeouts = 0
|
|
62
|
+
threading.Thread(target=self._read_loop, args=(raw,), daemon=True).start()
|
|
63
|
+
|
|
64
|
+
if self.token:
|
|
65
|
+
try:
|
|
66
|
+
self._send({"cmd": "Auth", "token": self.token})
|
|
67
|
+
except CommandError as exc:
|
|
68
|
+
self._telemetry.emit("auth", ok=False)
|
|
69
|
+
self._teardown()
|
|
70
|
+
raise AuthError(str(exc)) from exc
|
|
71
|
+
except (CommandTimeoutError, ConnectionClosedError) as exc:
|
|
72
|
+
self._telemetry.emit("auth", ok=False)
|
|
73
|
+
self._teardown()
|
|
74
|
+
raise ConnectionClosedError(f"auth failed: {exc}") from exc
|
|
75
|
+
self._telemetry.emit("auth", ok=True)
|
|
76
|
+
|
|
77
|
+
self._connected = True
|
|
78
|
+
self._telemetry.emit(
|
|
79
|
+
"connect",
|
|
80
|
+
host=self.host,
|
|
81
|
+
port=self.port,
|
|
82
|
+
generation=self._generation,
|
|
83
|
+
duration_ms=self._telemetry.now_ms() - started_at,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
def _note_connect_failure(self) -> None:
|
|
87
|
+
self._failed_attempts += 1
|
|
88
|
+
backoff = min(0.5 * (2 ** (self._failed_attempts - 1)), 5.0)
|
|
89
|
+
self._next_attempt_at = time.monotonic() + backoff
|
|
90
|
+
self._telemetry.emit(
|
|
91
|
+
"reconnect_scheduled",
|
|
92
|
+
host=self.host,
|
|
93
|
+
port=self.port,
|
|
94
|
+
attempt=self._failed_attempts,
|
|
95
|
+
delay_ms=backoff * 1000,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
def close(self) -> None:
|
|
99
|
+
self._closed = True
|
|
100
|
+
self._teardown()
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def connected(self) -> bool:
|
|
104
|
+
return self._connected
|
bunqueue/errors.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Exception hierarchy for the bunqueue Python SDK."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class BunqueueError(Exception):
|
|
5
|
+
"""Base class for all bunqueue SDK errors."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ConnectionClosedError(BunqueueError):
|
|
9
|
+
"""The TCP connection is closed or was lost mid-command."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class CommandTimeoutError(BunqueueError):
|
|
13
|
+
"""No response received for a command within the timeout."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class CommandError(BunqueueError):
|
|
17
|
+
"""The server answered with ok=false; message carries the server error."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class SerializationError(BunqueueError):
|
|
21
|
+
"""A command payload cannot be encoded within the wire constraints.
|
|
22
|
+
|
|
23
|
+
Raised before anything is written when msgpack serialization fails or the
|
|
24
|
+
encoded payload exceeds the protocol frame cap. Serialization failures
|
|
25
|
+
retain the original error as ``__cause__``.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class AuthError(BunqueueError):
|
|
30
|
+
"""Authentication with the server failed."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class UnrecoverableError(BunqueueError):
|
|
34
|
+
"""Raise inside a Worker processor to fail the job terminally.
|
|
35
|
+
|
|
36
|
+
Skips all remaining retry attempts and sends the job straight to the
|
|
37
|
+
DLQ (mirrors ``UnrecoverableError`` in the TypeScript client).
|
|
38
|
+
"""
|
bunqueue/events.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Minimal thread-safe event emitter (mirrors Node's EventEmitter subset)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import threading
|
|
7
|
+
from typing import Any, Callable, Dict, List
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger("bunqueue")
|
|
10
|
+
|
|
11
|
+
Listener = Callable[..., None]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class EventEmitter:
|
|
15
|
+
"""on/once/off/emit with the semantics the TS client exposes."""
|
|
16
|
+
|
|
17
|
+
def __init__(self) -> None:
|
|
18
|
+
self._listeners: Dict[str, List[Listener]] = {}
|
|
19
|
+
self._once: Dict[str, List[Listener]] = {}
|
|
20
|
+
self._lock = threading.Lock()
|
|
21
|
+
|
|
22
|
+
def on(self, event: str, listener: Listener) -> "EventEmitter":
|
|
23
|
+
with self._lock:
|
|
24
|
+
self._listeners.setdefault(event, []).append(listener)
|
|
25
|
+
return self
|
|
26
|
+
|
|
27
|
+
def once(self, event: str, listener: Listener) -> "EventEmitter":
|
|
28
|
+
with self._lock:
|
|
29
|
+
self._once.setdefault(event, []).append(listener)
|
|
30
|
+
return self
|
|
31
|
+
|
|
32
|
+
def off(self, event: str, listener: Listener) -> "EventEmitter":
|
|
33
|
+
with self._lock:
|
|
34
|
+
for registry in (self._listeners, self._once):
|
|
35
|
+
if event in registry and listener in registry[event]:
|
|
36
|
+
registry[event].remove(listener)
|
|
37
|
+
return self
|
|
38
|
+
|
|
39
|
+
def remove_all_listeners(self, event: str = None) -> None:
|
|
40
|
+
with self._lock:
|
|
41
|
+
if event is None:
|
|
42
|
+
self._listeners.clear()
|
|
43
|
+
self._once.clear()
|
|
44
|
+
else:
|
|
45
|
+
self._listeners.pop(event, None)
|
|
46
|
+
self._once.pop(event, None)
|
|
47
|
+
|
|
48
|
+
def emit(self, event: str, *args: Any) -> None:
|
|
49
|
+
with self._lock:
|
|
50
|
+
listeners = list(self._listeners.get(event, ()))
|
|
51
|
+
once = self._once.pop(event, [])
|
|
52
|
+
for listener in listeners + once:
|
|
53
|
+
try:
|
|
54
|
+
listener(*args)
|
|
55
|
+
except Exception: # noqa: BLE001 - listeners must not kill the emitter
|
|
56
|
+
logger.warning("listener for %r raised", event, exc_info=True)
|