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.
@@ -0,0 +1,57 @@
1
+ """Client-side rate limiter for Simple Mode (mirror of rate-gate.ts).
2
+
3
+ Throttles job starts to ``max`` per ``duration`` ms window, optionally per
4
+ group (``group_key`` names a field of job.data). A job whose window is full
5
+ waits, holding its concurrency slot, until the window frees — the same
6
+ observable semantics as the official client's worker limiter.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import threading
12
+ import time
13
+ from typing import Any, Dict, List
14
+
15
+
16
+ class RateGate:
17
+ def __init__(self, options: Dict[str, Any]) -> None:
18
+ self._max = int(options["max"])
19
+ self._duration = float(options.get("duration") or 1000)
20
+ self._group_key = options.get("group_key") or options.get("groupKey")
21
+ self._windows: Dict[str, List[float]] = {}
22
+ self._lock = threading.Lock()
23
+
24
+ def group_for(self, data: Any) -> str:
25
+ """Resolve the rate-limit group for a job's data."""
26
+ if not self._group_key:
27
+ return ""
28
+ if isinstance(data, dict):
29
+ value = data.get(self._group_key)
30
+ if value is not None:
31
+ return str(value)
32
+ return ""
33
+
34
+ def acquire(self, group: str) -> None:
35
+ """Block until the group's window has room, then record the start."""
36
+ while True:
37
+ now = time.monotonic() * 1000
38
+ with self._lock:
39
+ self._prune_locked(now)
40
+ window = [t for t in self._windows.get(group, []) if now - t < self._duration]
41
+ if len(window) < self._max:
42
+ window.append(now)
43
+ self._windows[group] = window
44
+ return
45
+ self._windows[group] = window
46
+ oldest = window[0]
47
+ time.sleep(max((oldest + self._duration - now) / 1000.0, 0.01))
48
+
49
+ def _prune_locked(self, now: float) -> None:
50
+ """Evict fully-expired groups (high-cardinality group_key safety)."""
51
+ stale = [
52
+ group
53
+ for group, window in self._windows.items()
54
+ if not window or now - window[-1] >= self._duration
55
+ ]
56
+ for group in stale:
57
+ del self._windows[group]
@@ -0,0 +1,65 @@
1
+ """Advanced in-process retry with backoff strategies (mirror of retry.ts).
2
+
3
+ This retries INSIDE the processor while the job stays active (the worker's
4
+ lock heartbeats keep it owned) — different from the server-side
5
+ attempts/backoff, which re-queues the job.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import random
11
+ import time
12
+ from typing import Any, Callable, Dict, TypeVar
13
+
14
+ R = TypeVar("R")
15
+
16
+ STRATEGIES = ("fixed", "exponential", "jitter", "fibonacci", "custom")
17
+
18
+
19
+ def calculate_backoff(
20
+ strategy: str,
21
+ attempt: int,
22
+ base_delay: float,
23
+ error: BaseException,
24
+ config: Dict[str, Any],
25
+ ) -> float:
26
+ """Delay in ms for the given attempt (1-based), same formulas as retry.ts."""
27
+ if strategy == "fixed":
28
+ return base_delay
29
+ if strategy == "exponential":
30
+ return base_delay * (2 ** (attempt - 1))
31
+ if strategy == "jitter":
32
+ exp = base_delay * (2 ** (attempt - 1))
33
+ return float(int(exp * (0.5 + random.random())))
34
+ if strategy == "fibonacci":
35
+ a, b = 1, 1
36
+ for _ in range(attempt - 1):
37
+ a, b = b, a + b
38
+ return base_delay * b
39
+ if strategy == "custom":
40
+ custom = config.get("custom_backoff") or config.get("customBackoff")
41
+ if custom:
42
+ return float(custom(attempt, error))
43
+ return base_delay
44
+ return base_delay
45
+
46
+
47
+ def execute_with_retry(fn: Callable[[], R], config: Dict[str, Any]) -> R:
48
+ """Run ``fn`` retrying on exception, same semantics as executeWithRetry."""
49
+ max_attempts = int(config.get("max_attempts") or config.get("maxAttempts") or 3)
50
+ base_delay = float(config.get("delay") or 1000)
51
+ strategy = str(config.get("strategy") or "exponential")
52
+ retry_if = config.get("retry_if") or config.get("retryIf")
53
+
54
+ attempt = 1
55
+ while True:
56
+ try:
57
+ return fn()
58
+ except BaseException as exc: # noqa: BLE001 - retry semantics need broad catch
59
+ if attempt >= max_attempts:
60
+ raise
61
+ if retry_if is not None and not retry_if(exc, attempt):
62
+ raise
63
+ delay_ms = calculate_backoff(strategy, attempt, base_delay, exc, config)
64
+ time.sleep(delay_ms / 1000.0)
65
+ attempt += 1
@@ -0,0 +1,45 @@
1
+ """Event triggers: when a job completes/fails, create another job (mirror of
2
+ triggers.ts)."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from typing import Any, Dict, List
7
+
8
+ from ..queue import Queue
9
+ from ..worker import Worker
10
+
11
+
12
+ class TriggerManager:
13
+ def __init__(self, queue: Queue, worker: Worker) -> None:
14
+ self._queue = queue
15
+ self._worker = worker
16
+ self._rules: List[Dict[str, Any]] = []
17
+ self._active = False
18
+
19
+ def add(self, rule: Dict[str, Any]) -> None:
20
+ """Rule keys: on, create, data(result, job), event ('completed'
21
+ default | 'failed'), condition(result, job), opts (job options)."""
22
+ self._rules.append(rule)
23
+ self._ensure_active()
24
+
25
+ def _ensure_active(self) -> None:
26
+ if self._active:
27
+ return
28
+ self._active = True
29
+ self._worker.on("completed", lambda job, result: self._fire("completed", job, result))
30
+ self._worker.on("failed", lambda job, error: self._fire("failed", job, error))
31
+
32
+ def _fire(self, event: str, job: Any, result_or_error: Any) -> None:
33
+ for rule in self._rules:
34
+ if rule["on"] != job.name:
35
+ continue
36
+ if rule.get("event", "completed") != event:
37
+ continue
38
+ condition = rule.get("condition")
39
+ if condition and not condition(result_or_error, job):
40
+ continue
41
+ data = rule["data"](result_or_error, job)
42
+ try:
43
+ self._queue.add(rule["create"], data, **(rule.get("opts") or {}))
44
+ except Exception: # noqa: BLE001 - trigger add is fire-and-forget
45
+ pass
bunqueue/simple/ttl.py ADDED
@@ -0,0 +1,34 @@
1
+ """Job TTL: expire unprocessed jobs at pickup (mirror of ttl.ts).
2
+
3
+ Resolution order: per_name[job.name] -> default_ttl -> 0 (no TTL).
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import time
9
+ from typing import Any, Dict
10
+
11
+
12
+ class TtlChecker:
13
+ def __init__(self, config: Dict[str, Any]) -> None:
14
+ self._default_ttl = float(
15
+ config.get("default_ttl") or config.get("defaultTtl") or 0
16
+ )
17
+ self._per_name: Dict[str, float] = dict(
18
+ config.get("per_name") or config.get("perName") or {}
19
+ )
20
+
21
+ def get_ttl(self, job_name: str) -> float:
22
+ return self._per_name.get(job_name, self._default_ttl)
23
+
24
+ def is_expired(self, job_name: str, job_timestamp_ms: float) -> bool:
25
+ ttl = self.get_ttl(job_name)
26
+ if ttl <= 0:
27
+ return False
28
+ return (time.time() * 1000) - job_timestamp_ms > ttl
29
+
30
+ def set_default_ttl(self, ttl_ms: float) -> None:
31
+ self._default_ttl = ttl_ms
32
+
33
+ def set_name_ttl(self, job_name: str, ttl_ms: float) -> None:
34
+ self._per_name[job_name] = ttl_ms
bunqueue/telemetry.py ADDED
@@ -0,0 +1,29 @@
1
+ """Dependency-free structured telemetry for transport and command lifecycle."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from typing import Any, Callable, Dict, Optional
7
+
8
+ TelemetryEvent = Dict[str, Any]
9
+ TelemetryHandler = Callable[[TelemetryEvent], None]
10
+
11
+
12
+ class Telemetry:
13
+ """Safely dispatch structured events to an optional application callback."""
14
+
15
+ def __init__(self, handler: Optional[TelemetryHandler] = None) -> None:
16
+ self._handler = handler
17
+
18
+ @staticmethod
19
+ def now_ms() -> float:
20
+ return time.monotonic() * 1000
21
+
22
+ def emit(self, event_type: str, **fields: Any) -> None:
23
+ if self._handler is None:
24
+ return
25
+ try:
26
+ self._handler({"type": event_type, **fields})
27
+ except Exception:
28
+ # Observability must never break connection or worker correctness.
29
+ pass
bunqueue/transport.py ADDED
@@ -0,0 +1,50 @@
1
+ """Low-level socket and frame helpers for :mod:`bunqueue.connection`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import socket
6
+ import struct
7
+ from typing import Any, Dict
8
+
9
+ import msgpack
10
+
11
+ from .errors import SerializationError
12
+ from .wire import MAX_FRAME_SIZE, _compact, _js_safe
13
+
14
+
15
+ def encode_command(command: Dict[str, Any], req_id: str) -> bytes:
16
+ """Serialize and frame a command, enforcing the body cap before framing."""
17
+ try:
18
+ normalized = _js_safe({**_compact(command), "reqId": req_id})
19
+ payload = msgpack.packb(normalized, use_bin_type=True)
20
+ except SerializationError:
21
+ raise
22
+ except Exception as exc:
23
+ raise SerializationError(
24
+ f"cannot msgpack-serialize {command.get('cmd')} payload: {exc}"
25
+ ) from exc
26
+ if len(payload) > MAX_FRAME_SIZE:
27
+ raise SerializationError(
28
+ f"frame size {len(payload)} exceeds maximum {MAX_FRAME_SIZE}"
29
+ )
30
+ return struct.pack(">I", len(payload)) + payload
31
+
32
+
33
+ def enable_keepalive(raw: socket.socket) -> None:
34
+ """Enable portable TCP keepalive probes without making support mandatory."""
35
+ try:
36
+ raw.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)
37
+ except OSError:
38
+ return
39
+ for name, value in (
40
+ ("TCP_KEEPIDLE", 15),
41
+ ("TCP_KEEPINTVL", 5),
42
+ ("TCP_KEEPCNT", 3),
43
+ ("TCP_KEEPALIVE", 15),
44
+ ):
45
+ code = getattr(socket, name, None)
46
+ if code is not None:
47
+ try:
48
+ raw.setsockopt(socket.IPPROTO_TCP, code, value)
49
+ except OSError:
50
+ pass
bunqueue/wire.py ADDED
@@ -0,0 +1,99 @@
1
+ """Wire-level helpers: payload compaction, JS-safe numbers, TLS contexts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ssl
6
+ from typing import Any, Dict, Optional, Union
7
+
8
+ from .errors import SerializationError
9
+
10
+ PROTOCOL_VERSION = 2
11
+ MAX_FRAME_SIZE = 64 * 1024 * 1024 # mirror server-side limit
12
+
13
+ TlsOption = Union[bool, ssl.SSLContext, Dict[str, Any], None]
14
+
15
+
16
+ def _compact(command: Dict[str, Any]) -> Dict[str, Any]:
17
+ """Drop None-valued keys so the msgpack frame stays minimal."""
18
+ return {k: v for k, v in command.items() if v is not None}
19
+
20
+
21
+ # msgpackr (server side) decodes int64/uint64 as BigInt, which crashes JS
22
+ # arithmetic (e.g. `now - startedAt`). JS clients never hit this because large
23
+ # Numbers serialize as float64. Mirror that: encode ints outside the int32
24
+ # range as float64 (exact up to 2^53 — fine for ms timestamps).
25
+ _INT32_MIN, _INT32_MAX = -(2**31), 2**31 - 1
26
+
27
+
28
+ def _js_safe(
29
+ value: Any,
30
+ _active: Optional[set[int]] = None,
31
+ _path: str = "$",
32
+ ) -> Any:
33
+ """Normalize a command for msgpackr without silently changing map keys.
34
+
35
+ Only containers on the active recursion path are tracked. Reusing the same
36
+ list or map in two independent branches is valid, while an actual cycle is
37
+ rejected before msgpack serialization or socket writes.
38
+ """
39
+ if isinstance(value, bool):
40
+ return value
41
+ if isinstance(value, int) and not (_INT32_MIN <= value <= _INT32_MAX):
42
+ try:
43
+ return float(value)
44
+ except OverflowError as exc:
45
+ raise SerializationError(f"integer at {_path} exceeds float64 range") from exc
46
+ if _active is None:
47
+ _active = set()
48
+ if isinstance(value, dict):
49
+ identity = id(value)
50
+ if identity in _active:
51
+ raise SerializationError(f"cyclic container at {_path}")
52
+ _active.add(identity)
53
+ try:
54
+ normalized = {}
55
+ for key, item in value.items():
56
+ if not isinstance(key, str):
57
+ raise SerializationError(f"map key at {_path} must be a string")
58
+ normalized[key] = _js_safe(item, _active, f"{_path}.{key}")
59
+ return normalized
60
+ finally:
61
+ _active.remove(identity)
62
+ if isinstance(value, list):
63
+ identity = id(value)
64
+ if identity in _active:
65
+ raise SerializationError(f"cyclic container at {_path}")
66
+ _active.add(identity)
67
+ try:
68
+ return [_js_safe(item, _active, f"{_path}[{index}]") for index, item in enumerate(value)]
69
+ finally:
70
+ _active.remove(identity)
71
+ if isinstance(value, tuple):
72
+ identity = id(value)
73
+ if identity in _active:
74
+ raise SerializationError(f"cyclic container at {_path}")
75
+ _active.add(identity)
76
+ try:
77
+ return tuple(
78
+ _js_safe(item, _active, f"{_path}[{index}]")
79
+ for index, item in enumerate(value)
80
+ )
81
+ finally:
82
+ _active.remove(identity)
83
+ return value
84
+
85
+
86
+ def _build_ssl_context(tls: TlsOption, host: str) -> Optional[ssl.SSLContext]:
87
+ if tls is None or tls is False:
88
+ return None
89
+ if isinstance(tls, ssl.SSLContext):
90
+ return tls
91
+ ctx = ssl.create_default_context()
92
+ if isinstance(tls, dict):
93
+ ca_file = tls.get("ca_file")
94
+ if ca_file:
95
+ ctx = ssl.create_default_context(cafile=ca_file)
96
+ if tls.get("verify") is False:
97
+ ctx.check_hostname = False
98
+ ctx.verify_mode = ssl.CERT_NONE
99
+ return ctx
bunqueue/worker.py ADDED
@@ -0,0 +1,259 @@
1
+ """Worker: pulls jobs over TCP and runs a processor with bounded concurrency.
2
+
3
+ Feature parity with the TS client's Worker (TCP mode): events (ready, active,
4
+ completed, failed, progress, drained, error, closed), pause/resume, graceful
5
+ close, lock heartbeats, UnrecoverableError. All queue semantics (retry,
6
+ backoff, DLQ, stall detection, priorities) live in the bunqueue server.
7
+
8
+ The loop/heartbeat internals live in :mod:`worker_runtime`.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import logging
14
+ import os
15
+ import socket as _socket
16
+ import threading
17
+ import uuid
18
+ from concurrent.futures import ThreadPoolExecutor
19
+ from typing import Any, Callable, Dict, Optional
20
+
21
+ from .ack_batcher import AckBatcher
22
+ from .connection import Connection, TlsOption
23
+ from .errors import CommandTimeoutError, ConnectionClosedError
24
+ from .events import EventEmitter
25
+ from .telemetry import TelemetryHandler
26
+ from .job import Job
27
+ from .worker_runtime import MAX_POLL_TIMEOUT_MS, RECONNECT_BACKOFF_S, WorkerRuntime
28
+
29
+ logger = logging.getLogger("bunqueue")
30
+
31
+ Processor = Callable[[Job], Any]
32
+
33
+
34
+ class Worker(EventEmitter, WorkerRuntime):
35
+ """Consumes jobs from one queue and processes them.
36
+
37
+ Mirrors the TS Worker: ``autorun=True`` starts a background loop at
38
+ construction; use ``run()`` for a blocking loop instead.
39
+ """
40
+
41
+ def __init__(
42
+ self,
43
+ queue: str,
44
+ processor: Processor,
45
+ *,
46
+ host: str = "localhost",
47
+ port: int = 6789,
48
+ token: Optional[str] = None,
49
+ tls: TlsOption = None,
50
+ concurrency: int = 4,
51
+ batch_size: int = 10,
52
+ poll_timeout_ms: int = 5000,
53
+ lock_ttl_ms: int = 30000,
54
+ heartbeat_interval_s: float = 10.0,
55
+ autorun: bool = True,
56
+ name: Optional[str] = None,
57
+ ack_batch: Optional[Dict[str, Any]] = None,
58
+ on_telemetry: Optional[TelemetryHandler] = None,
59
+ ) -> None:
60
+ super().__init__()
61
+ if concurrency < 1:
62
+ raise ValueError("concurrency must be >= 1")
63
+ self.queue = queue
64
+ self.processor = processor
65
+ self.concurrency = concurrency
66
+ # The server rejects PULLB count > 1000 — an unclamped batch_size
67
+ # would wedge the poll loop in a permanent error cycle.
68
+ self.batch_size = max(1, min(batch_size, 1000))
69
+ self.poll_timeout_ms = min(poll_timeout_ms, MAX_POLL_TIMEOUT_MS)
70
+ self.lock_ttl_ms = lock_ttl_ms
71
+ self.heartbeat_interval_s = heartbeat_interval_s
72
+ self.worker_id = f"py-{_socket.gethostname()}-{os.getpid()}-{uuid.uuid4().hex[:8]}"
73
+ self.name = name or self.worker_id
74
+
75
+ self.connection = Connection(
76
+ host=host, port=port, token=token, tls=tls, on_telemetry=on_telemetry
77
+ )
78
+
79
+ # Opt-in ACKB batching, ack_batch={"max_size": 50, "max_delay_ms": 5}
80
+ # (TS parity); the dict is the opt-in, {"enabled": False} disables.
81
+ self._ack_batcher: Optional[AckBatcher] = None
82
+ if ack_batch and ack_batch.get("enabled", True):
83
+ self._ack_batcher = AckBatcher(
84
+ self.connection,
85
+ max_size=int(ack_batch.get("max_size", 50)),
86
+ max_delay_ms=float(ack_batch.get("max_delay_ms", 5)),
87
+ )
88
+
89
+ self._active: Dict[str, str] = {} # job id -> lock token
90
+ self._cancelled: Dict[str, Optional[str]] = {} # job id -> reason
91
+ self._active_lock = threading.Lock()
92
+ self._stop = threading.Event()
93
+ self._paused = threading.Event()
94
+ self._ready = threading.Event()
95
+ self._closed = False
96
+ self._loop_started = False # run() actually entered its loop
97
+ self._was_busy = False # for 'drained' edge detection
98
+ self._executor: Optional[ThreadPoolExecutor] = None
99
+ self._run_thread: Optional[threading.Thread] = None
100
+ self._processed = 0
101
+ self._failed = 0
102
+ self._registered_generation = -1
103
+
104
+ if autorun:
105
+ self.start()
106
+
107
+ def on(self, event: str, listener) -> "Worker": # type: ignore[override]
108
+ """Like EventEmitter.on, but replays 'ready' to late listeners.
109
+
110
+ With autorun the loop starts inside the constructor, so a listener
111
+ attached right after construction could otherwise miss the event.
112
+ """
113
+ if event == "ready" and self._ready.is_set():
114
+ try:
115
+ listener()
116
+ except Exception: # noqa: BLE001 - listener errors must not propagate
117
+ logger.warning("replayed 'ready' listener raised", exc_info=True)
118
+ super().on(event, listener)
119
+ return self
120
+
121
+ # -------------------------------------------------------------- lifecycle
122
+
123
+ def start(self) -> None:
124
+ """Run the worker in a background thread."""
125
+ if self._closed or self._run_thread is not None:
126
+ return
127
+ self._run_thread = threading.Thread(target=self.run, daemon=True)
128
+ self._run_thread.start()
129
+
130
+ def run(self) -> None:
131
+ """Blocking main loop; returns after :meth:`close`.
132
+
133
+ If the loop is already running (autorun / start()), this joins it
134
+ instead of starting a second loop, so blocking semantics hold.
135
+ """
136
+ if self._run_thread is not None and threading.current_thread() is not self._run_thread:
137
+ self._run_thread.join()
138
+ return
139
+ self._loop_started = True
140
+ self._executor = ThreadPoolExecutor(
141
+ max_workers=self.concurrency, thread_name_prefix="bunqueue-job"
142
+ )
143
+ self._register()
144
+ self._ready.set()
145
+ self.emit("ready")
146
+ # 0 (or negative) disables heartbeats — Event.wait(0) returns
147
+ # immediately, so the loop would busy-spin flooding the server.
148
+ if self.heartbeat_interval_s > 0:
149
+ heartbeat = threading.Thread(target=self._heartbeat_loop, daemon=True)
150
+ heartbeat.start()
151
+ backoff_idx = 0
152
+ try:
153
+ while not self._stop.is_set():
154
+ if self._paused.is_set():
155
+ self._stop.wait(0.05)
156
+ continue
157
+ try:
158
+ self._poll_once()
159
+ backoff_idx = 0
160
+ except (ConnectionClosedError, CommandTimeoutError) as exc:
161
+ self.emit("error", exc)
162
+ delay = RECONNECT_BACKOFF_S[min(backoff_idx, len(RECONNECT_BACKOFF_S) - 1)]
163
+ backoff_idx += 1
164
+ self._stop.wait(delay)
165
+ finally:
166
+ self._shutdown()
167
+
168
+ def pause(self) -> None:
169
+ self._paused.set()
170
+
171
+ def resume(self) -> None:
172
+ self._paused.clear()
173
+
174
+ def is_running(self) -> bool:
175
+ return self._run_thread is not None and not self._closed
176
+
177
+ def is_paused(self) -> bool:
178
+ return self._paused.is_set()
179
+
180
+ def is_closed(self) -> bool:
181
+ return self._closed
182
+
183
+ def wait_until_ready(self, timeout: float = 10.0) -> None:
184
+ if not self._ready.wait(timeout):
185
+ raise TimeoutError("worker did not become ready in time")
186
+
187
+ def close(self, timeout: Optional[float] = None) -> bool:
188
+ """Graceful shutdown: stop pulling, wait for in-flight jobs.
189
+
190
+ Returns True once fully shut down; False when ``timeout`` expires with
191
+ the loop thread still draining (the thread reference is kept alive so
192
+ state stays honest and a later ``close()`` can join it again).
193
+ """
194
+ self._stop.set()
195
+ thread = self._run_thread
196
+ if thread is not None:
197
+ thread.join(timeout)
198
+ if thread.is_alive():
199
+ return False # still draining; do NOT null the live thread
200
+ self._run_thread = None
201
+ return True
202
+ if not self._loop_started and not self._closed:
203
+ # autorun=False and run() never called: no loop will ever reach
204
+ # _shutdown, so settle the closed state here.
205
+ self._closed = True
206
+ self.connection.close()
207
+ self.emit("closed")
208
+ return True
209
+
210
+ # `stop` kept as an alias (pre-1.0 SDK name)
211
+ stop = close
212
+
213
+ def __enter__(self) -> "Worker":
214
+ return self
215
+
216
+ def __exit__(self, *exc: Any) -> None:
217
+ self.close()
218
+
219
+ # ------------------------------------------------------ cooperative cancel
220
+
221
+ def cancel_job(self, job_id: str, reason: Optional[str] = None) -> bool:
222
+ """Mark a locally-active job as cancelled (cooperative, no TCP command).
223
+
224
+ The processor must poll :meth:`is_job_cancelled` and abort itself.
225
+ Returns True if the job is currently active on this worker.
226
+ """
227
+ with self._active_lock:
228
+ if job_id not in self._active:
229
+ return False
230
+ self._cancelled[job_id] = reason
231
+ self.emit("cancelled", {"jobId": job_id, "reason": reason or "cancelled"})
232
+ return True
233
+
234
+ def cancel_all_jobs(self, reason: Optional[str] = None) -> None:
235
+ with self._active_lock:
236
+ active = list(self._active.keys())
237
+ for job_id in active:
238
+ self._cancelled[job_id] = reason
239
+ for job_id in active:
240
+ self.emit("cancelled", {"jobId": job_id, "reason": reason or "cancelled"})
241
+
242
+ def is_job_cancelled(self, job_id: str) -> bool:
243
+ with self._active_lock:
244
+ return job_id in self._cancelled
245
+
246
+ def _shutdown(self) -> None:
247
+ if self._executor is not None:
248
+ self._executor.shutdown(wait=True)
249
+ self._executor = None
250
+ # Flush AFTER the executor drained: the last in-flight jobs buffer
251
+ # their ACKs during shutdown(wait=True); send them before closing.
252
+ if self._ack_batcher is not None:
253
+ self._ack_batcher.flush()
254
+ self._safe_call({"cmd": "UnregisterWorker", "workerId": self.worker_id})
255
+ self.connection.close()
256
+ already_closed = self._closed
257
+ self._closed = True
258
+ if not already_closed:
259
+ self.emit("closed")