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
|
@@ -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]
|
bunqueue/simple/retry.py
ADDED
|
@@ -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")
|