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 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
+ ]
@@ -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)