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/simple/aging.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Priority aging: boost old waiting jobs so they never starve (mirror of
|
|
2
|
+
aging.ts, adapted to the TCP query surface)."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import logging
|
|
7
|
+
import threading
|
|
8
|
+
import time
|
|
9
|
+
from typing import Any, Dict, Optional
|
|
10
|
+
|
|
11
|
+
from ..queue import Queue
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger("bunqueue")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class PriorityAger:
|
|
17
|
+
def __init__(self, config: Dict[str, Any], queue: Queue) -> None:
|
|
18
|
+
self._config = config
|
|
19
|
+
self._queue = queue
|
|
20
|
+
self._timer: Optional[threading.Timer] = None
|
|
21
|
+
self._stopped = False
|
|
22
|
+
|
|
23
|
+
def start(self) -> None:
|
|
24
|
+
self._schedule()
|
|
25
|
+
|
|
26
|
+
def _schedule(self) -> None:
|
|
27
|
+
if self._stopped:
|
|
28
|
+
return
|
|
29
|
+
interval = float(self._config.get("interval") or 60000)
|
|
30
|
+
self._timer = threading.Timer(interval / 1000.0, self._tick)
|
|
31
|
+
self._timer.daemon = True
|
|
32
|
+
self._timer.start()
|
|
33
|
+
|
|
34
|
+
def _tick(self) -> None:
|
|
35
|
+
try:
|
|
36
|
+
self._boost_old_jobs()
|
|
37
|
+
except Exception: # noqa: BLE001 - best-effort background task
|
|
38
|
+
logger.warning("priority aging tick failed", exc_info=True)
|
|
39
|
+
finally:
|
|
40
|
+
self._schedule()
|
|
41
|
+
|
|
42
|
+
def _boost_old_jobs(self) -> None:
|
|
43
|
+
min_age = float(self._config.get("min_age") or self._config.get("minAge") or 60000)
|
|
44
|
+
boost = int(self._config.get("boost") or 1)
|
|
45
|
+
max_priority = int(
|
|
46
|
+
self._config.get("max_priority") or self._config.get("maxPriority") or 100
|
|
47
|
+
)
|
|
48
|
+
max_scan = int(self._config.get("max_scan") or self._config.get("maxScan") or 100)
|
|
49
|
+
|
|
50
|
+
jobs = self._queue.get_waiting(0, max_scan) + self._queue.get_jobs(
|
|
51
|
+
"prioritized", 0, max_scan
|
|
52
|
+
)
|
|
53
|
+
now = time.time() * 1000
|
|
54
|
+
for job in jobs:
|
|
55
|
+
created = job.created_at or now
|
|
56
|
+
age = now - created
|
|
57
|
+
if age >= min_age and job.priority < max_priority:
|
|
58
|
+
new_priority = min(job.priority + boost, max_priority)
|
|
59
|
+
try:
|
|
60
|
+
self._queue.change_job_priority(job.id, new_priority)
|
|
61
|
+
except Exception: # noqa: BLE001 - job may have been processed
|
|
62
|
+
pass
|
|
63
|
+
|
|
64
|
+
def destroy(self) -> None:
|
|
65
|
+
self._stopped = True
|
|
66
|
+
if self._timer:
|
|
67
|
+
self._timer.cancel()
|
|
68
|
+
self._timer = None
|
bunqueue/simple/app.py
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""Bunqueue Simple Mode: Queue + Worker in one object (mirror of bunqueue.ts).
|
|
2
|
+
|
|
3
|
+
TCP mode only: the ``embedded`` option of the original requires the in-process
|
|
4
|
+
Bun runtime and raises here with a clear error.
|
|
5
|
+
|
|
6
|
+
Processing pipeline per job (same order as the original):
|
|
7
|
+
Job -> Circuit Breaker -> TTL check -> CancelSignal -> Retry -> Middleware -> Processor
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
13
|
+
|
|
14
|
+
from ..job import Job
|
|
15
|
+
from ..queue import Queue
|
|
16
|
+
from ..worker import Worker
|
|
17
|
+
from .aging import PriorityAger
|
|
18
|
+
from .batch import BatchAccumulator
|
|
19
|
+
from .cancellation import CancellationManager, CancelSignal
|
|
20
|
+
from .circuit_breaker import WorkerCircuitBreaker
|
|
21
|
+
from .dedup_debounce import DedupDebounceMerger
|
|
22
|
+
from .rate_gate import RateGate
|
|
23
|
+
from .retry import execute_with_retry
|
|
24
|
+
from .triggers import TriggerManager
|
|
25
|
+
from .ttl import TtlChecker
|
|
26
|
+
|
|
27
|
+
Processor = Callable[[Job], Any]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
from .app_api import BunqueueApi
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Bunqueue(BunqueueApi):
|
|
34
|
+
"""All-in-one Queue + Worker with routes, middleware, cron, batch, retry,
|
|
35
|
+
circuit breaker, TTL, priority aging, cancellation, dedup/debounce."""
|
|
36
|
+
|
|
37
|
+
def __init__(self, name: str, **opts: Any) -> None:
|
|
38
|
+
if opts.get("embedded"):
|
|
39
|
+
raise ValueError(
|
|
40
|
+
"embedded mode requires the Bun runtime and the official bunqueue "
|
|
41
|
+
"package; this SDK is TCP-only — connect to a bunqueue server instead"
|
|
42
|
+
)
|
|
43
|
+
modes = [m for m in (opts.get("processor"), opts.get("routes"), opts.get("batch")) if m]
|
|
44
|
+
if len(modes) == 0:
|
|
45
|
+
raise ValueError('Bunqueue requires "processor", "routes", or "batch"')
|
|
46
|
+
if len(modes) > 1:
|
|
47
|
+
raise ValueError('Bunqueue: use only one of "processor", "routes", or "batch"')
|
|
48
|
+
|
|
49
|
+
self.name = name
|
|
50
|
+
self._middlewares: List[Callable[[Job, Callable[[], Any]], Any]] = []
|
|
51
|
+
self._retry_config: Optional[Dict[str, Any]] = opts.get("retry")
|
|
52
|
+
self._ttl_checker = TtlChecker(opts["ttl"]) if opts.get("ttl") else None
|
|
53
|
+
self._merger = DedupDebounceMerger(opts.get("deduplication"), opts.get("debounce"))
|
|
54
|
+
self._cancellation = CancellationManager()
|
|
55
|
+
self._default_job_options: Dict[str, Any] = dict(opts.get("default_job_options") or {})
|
|
56
|
+
|
|
57
|
+
if opts.get("batch"):
|
|
58
|
+
self._batch_acc: Optional[BatchAccumulator] = BatchAccumulator(opts["batch"])
|
|
59
|
+
self._base_processor: Processor = self._batch_acc.build_processor()
|
|
60
|
+
else:
|
|
61
|
+
self._batch_acc = None
|
|
62
|
+
routes = opts.get("routes")
|
|
63
|
+
self._base_processor = (
|
|
64
|
+
self._build_route_processor(routes) if routes else opts["processor"]
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
connection = {
|
|
68
|
+
"host": opts.get("host", "localhost"),
|
|
69
|
+
"port": opts.get("port", 6789),
|
|
70
|
+
"token": opts.get("token"),
|
|
71
|
+
"tls": opts.get("tls"),
|
|
72
|
+
}
|
|
73
|
+
self.queue = Queue(name, **connection)
|
|
74
|
+
self.worker = Worker(
|
|
75
|
+
name,
|
|
76
|
+
self._process_job,
|
|
77
|
+
host=connection["host"],
|
|
78
|
+
port=connection["port"],
|
|
79
|
+
token=connection["token"],
|
|
80
|
+
tls=connection["tls"],
|
|
81
|
+
concurrency=opts.get("concurrency", 4),
|
|
82
|
+
batch_size=opts.get("batch_size", 10),
|
|
83
|
+
poll_timeout_ms=opts.get("poll_timeout_ms", 5000),
|
|
84
|
+
heartbeat_interval_s=opts.get("heartbeat_interval_s", 10.0),
|
|
85
|
+
autorun=opts.get("autorun", True),
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
if opts.get("dlq"):
|
|
89
|
+
self.queue.set_dlq_config(opts["dlq"])
|
|
90
|
+
limiter = opts.get("rate_limit") or opts.get("limiter")
|
|
91
|
+
self._rate_gate = RateGate(limiter) if limiter else None
|
|
92
|
+
|
|
93
|
+
self._cb = (
|
|
94
|
+
WorkerCircuitBreaker(opts["circuit_breaker"], self.worker)
|
|
95
|
+
if opts.get("circuit_breaker")
|
|
96
|
+
else None
|
|
97
|
+
)
|
|
98
|
+
self._trigger_mgr = TriggerManager(self.queue, self.worker)
|
|
99
|
+
self._ager = (
|
|
100
|
+
PriorityAger(opts["priority_aging"], self.queue)
|
|
101
|
+
if opts.get("priority_aging")
|
|
102
|
+
else None
|
|
103
|
+
)
|
|
104
|
+
if self._ager:
|
|
105
|
+
self._ager.start()
|
|
106
|
+
|
|
107
|
+
# ---------------------------------------------------------- processing
|
|
108
|
+
|
|
109
|
+
def _build_route_processor(self, routes: Dict[str, Processor]) -> Processor:
|
|
110
|
+
def process(job: Job) -> Any:
|
|
111
|
+
handler = routes.get(job.name or "")
|
|
112
|
+
if handler is None:
|
|
113
|
+
raise ValueError(f'No route for job "{job.name}" in queue "{self.name}"')
|
|
114
|
+
return handler(job)
|
|
115
|
+
|
|
116
|
+
return process
|
|
117
|
+
|
|
118
|
+
def _process_job(self, job: Job) -> Any:
|
|
119
|
+
if self._rate_gate is not None:
|
|
120
|
+
self._rate_gate.acquire(self._rate_gate.group_for(job.data))
|
|
121
|
+
if self._cb is not None and self._cb.is_open():
|
|
122
|
+
raise RuntimeError("Circuit breaker is open")
|
|
123
|
+
timestamp = job.created_at or 0
|
|
124
|
+
if self._ttl_checker is not None and self._ttl_checker.is_expired(
|
|
125
|
+
job.name or "", timestamp
|
|
126
|
+
):
|
|
127
|
+
import time
|
|
128
|
+
|
|
129
|
+
raise RuntimeError(f"Job expired (age: {int(time.time() * 1000 - timestamp)}ms)")
|
|
130
|
+
signal = self._cancellation.register(job.id)
|
|
131
|
+
try:
|
|
132
|
+
run = lambda: self._run_middleware_chain(job, signal) # noqa: E731
|
|
133
|
+
if self._retry_config:
|
|
134
|
+
result = execute_with_retry(run, self._retry_config)
|
|
135
|
+
else:
|
|
136
|
+
result = run()
|
|
137
|
+
except BaseException:
|
|
138
|
+
if self._cb is not None:
|
|
139
|
+
self._cb.on_failure()
|
|
140
|
+
raise
|
|
141
|
+
else:
|
|
142
|
+
if self._cb is not None:
|
|
143
|
+
self._cb.on_success()
|
|
144
|
+
return result
|
|
145
|
+
finally:
|
|
146
|
+
self._cancellation.unregister(job.id)
|
|
147
|
+
|
|
148
|
+
def _run_middleware_chain(self, job: Job, signal: CancelSignal) -> Any:
|
|
149
|
+
if not self._middlewares:
|
|
150
|
+
return self._base_processor(job)
|
|
151
|
+
index = 0
|
|
152
|
+
|
|
153
|
+
def next_fn() -> Any:
|
|
154
|
+
nonlocal index
|
|
155
|
+
if signal.aborted:
|
|
156
|
+
raise RuntimeError("Job cancelled")
|
|
157
|
+
if index < len(self._middlewares):
|
|
158
|
+
middleware = self._middlewares[index]
|
|
159
|
+
index += 1
|
|
160
|
+
return middleware(job, next_fn)
|
|
161
|
+
return self._base_processor(job)
|
|
162
|
+
|
|
163
|
+
return next_fn()
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Public API surface of Bunqueue Simple Mode (mixin): middleware, queue
|
|
2
|
+
operations, cron shorthands, subsystem accessors, events and control.
|
|
3
|
+
|
|
4
|
+
Split from :mod:`app` to keep files small; semantics mirror bunqueue.ts.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
10
|
+
|
|
11
|
+
from ..job import Job
|
|
12
|
+
from .cancellation import CancelSignal
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class BunqueueApi:
|
|
16
|
+
"""Mixin: requires the attributes initialized in ``Bunqueue.__init__``."""
|
|
17
|
+
|
|
18
|
+
# ---------------------------------------------------------- middleware
|
|
19
|
+
|
|
20
|
+
def use(self, middleware: Callable[[Job, Callable[[], Any]], Any]) -> "Bunqueue":
|
|
21
|
+
self._middlewares.append(middleware)
|
|
22
|
+
return self
|
|
23
|
+
|
|
24
|
+
# ---------------------------------------------------------- queue ops
|
|
25
|
+
|
|
26
|
+
def add(self, name: str, data: Any = None, **opts: Any) -> Job:
|
|
27
|
+
merged = self._merger.merge(name, {**self._default_job_options, **opts}, data)
|
|
28
|
+
return self.queue.add(name, data, **merged)
|
|
29
|
+
|
|
30
|
+
def add_bulk(self, jobs: List[Dict[str, Any]]) -> List[str]:
|
|
31
|
+
entries = []
|
|
32
|
+
for entry in jobs:
|
|
33
|
+
entry = dict(entry)
|
|
34
|
+
opts = {k: v for k, v in entry.items() if k not in ("name", "data")}
|
|
35
|
+
merged = self._merger.merge(
|
|
36
|
+
entry["name"], {**self._default_job_options, **opts}, entry.get("data")
|
|
37
|
+
)
|
|
38
|
+
entries.append({"name": entry["name"], "data": entry.get("data"), **merged})
|
|
39
|
+
return self.queue.add_bulk(entries)
|
|
40
|
+
|
|
41
|
+
def get_job(self, job_id: str) -> Optional[Job]:
|
|
42
|
+
return self.queue.get_job(job_id)
|
|
43
|
+
|
|
44
|
+
def get_job_counts(self) -> Dict[str, int]:
|
|
45
|
+
return self.queue.get_job_counts()
|
|
46
|
+
|
|
47
|
+
def count(self) -> int:
|
|
48
|
+
return self.queue.count()
|
|
49
|
+
|
|
50
|
+
# ---------------------------------------------------------- cron
|
|
51
|
+
|
|
52
|
+
def cron(self, scheduler_id: str, pattern: str, data: Any = None, **opts: Any) -> None:
|
|
53
|
+
self.queue.upsert_job_scheduler(
|
|
54
|
+
scheduler_id,
|
|
55
|
+
{"pattern": pattern, "tz": opts.get("timezone"), "limit": opts.get("limit")},
|
|
56
|
+
{"name": scheduler_id, "data": data, "opts": opts.get("job_opts")},
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
def every(self, scheduler_id: str, interval_ms: int, data: Any = None, **opts: Any) -> None:
|
|
60
|
+
self.queue.upsert_job_scheduler(
|
|
61
|
+
scheduler_id,
|
|
62
|
+
{"every": interval_ms, "limit": opts.get("limit")},
|
|
63
|
+
{"name": scheduler_id, "data": data, "opts": opts.get("job_opts")},
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
def remove_cron(self, scheduler_id: str) -> None:
|
|
67
|
+
self.queue.remove_job_scheduler(scheduler_id)
|
|
68
|
+
|
|
69
|
+
def list_crons(self) -> List[Dict[str, Any]]:
|
|
70
|
+
return self.queue.get_job_schedulers()
|
|
71
|
+
|
|
72
|
+
# ---------------------------------------------------------- subsystems
|
|
73
|
+
|
|
74
|
+
def cancel(self, job_id: str, grace_period_ms: float = 0) -> None:
|
|
75
|
+
self._cancellation.cancel(job_id, grace_period_ms)
|
|
76
|
+
|
|
77
|
+
def is_cancelled(self, job_id: str) -> bool:
|
|
78
|
+
return self._cancellation.is_cancelled(job_id)
|
|
79
|
+
|
|
80
|
+
def get_signal(self, job_id: str) -> Optional[CancelSignal]:
|
|
81
|
+
return self._cancellation.get_signal(job_id)
|
|
82
|
+
|
|
83
|
+
def get_circuit_state(self) -> str:
|
|
84
|
+
return self._cb.current_state if self._cb else "closed"
|
|
85
|
+
|
|
86
|
+
def reset_circuit(self) -> None:
|
|
87
|
+
if self._cb:
|
|
88
|
+
self._cb.reset()
|
|
89
|
+
|
|
90
|
+
def trigger(self, rule: Dict[str, Any]) -> "Bunqueue":
|
|
91
|
+
self._trigger_mgr.add(rule)
|
|
92
|
+
return self
|
|
93
|
+
|
|
94
|
+
def set_default_ttl(self, ttl_ms: float) -> None:
|
|
95
|
+
if self._ttl_checker:
|
|
96
|
+
self._ttl_checker.set_default_ttl(ttl_ms)
|
|
97
|
+
|
|
98
|
+
def set_name_ttl(self, name: str, ttl_ms: float) -> None:
|
|
99
|
+
if self._ttl_checker:
|
|
100
|
+
self._ttl_checker.set_name_ttl(name, ttl_ms)
|
|
101
|
+
|
|
102
|
+
# ---------------------------------------------------------- events/control
|
|
103
|
+
|
|
104
|
+
def on(self, event: str, listener: Callable[..., None]) -> "Bunqueue":
|
|
105
|
+
self.worker.on(event, listener)
|
|
106
|
+
return self
|
|
107
|
+
|
|
108
|
+
def once(self, event: str, listener: Callable[..., None]) -> "Bunqueue":
|
|
109
|
+
self.worker.once(event, listener)
|
|
110
|
+
return self
|
|
111
|
+
|
|
112
|
+
def off(self, event: str, listener: Callable[..., None]) -> "Bunqueue":
|
|
113
|
+
self.worker.off(event, listener)
|
|
114
|
+
return self
|
|
115
|
+
|
|
116
|
+
def pause(self) -> None:
|
|
117
|
+
self.queue.pause()
|
|
118
|
+
self.worker.pause()
|
|
119
|
+
|
|
120
|
+
def resume(self) -> None:
|
|
121
|
+
self.queue.resume()
|
|
122
|
+
self.worker.resume()
|
|
123
|
+
|
|
124
|
+
def close(self, force: bool = False) -> None:
|
|
125
|
+
if self._ager:
|
|
126
|
+
self._ager.destroy()
|
|
127
|
+
if self._cb:
|
|
128
|
+
self._cb.destroy()
|
|
129
|
+
if self._batch_acc:
|
|
130
|
+
self._batch_acc.destroy()
|
|
131
|
+
self._cancellation.destroy_all()
|
|
132
|
+
self.worker.close(timeout=0 if force else None)
|
|
133
|
+
self.queue.close()
|
|
134
|
+
|
|
135
|
+
def is_running(self) -> bool:
|
|
136
|
+
return self.worker.is_running()
|
|
137
|
+
|
|
138
|
+
def is_paused(self) -> bool:
|
|
139
|
+
return self.worker.is_paused()
|
|
140
|
+
|
|
141
|
+
def is_closed(self) -> bool:
|
|
142
|
+
return self.worker.is_closed()
|
bunqueue/simple/batch.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Batch processing: accumulate N jobs, process them together (mirror of
|
|
2
|
+
batch.ts). Each job's processor call blocks until its batch flushes, so the
|
|
3
|
+
worker concurrency must be >= batch size for full batches — same as the
|
|
4
|
+
original."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import threading
|
|
9
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
10
|
+
|
|
11
|
+
from ..job import Job
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class _Entry:
|
|
15
|
+
__slots__ = ("job", "event", "result", "error")
|
|
16
|
+
|
|
17
|
+
def __init__(self, job: Job) -> None:
|
|
18
|
+
self.job = job
|
|
19
|
+
self.event = threading.Event()
|
|
20
|
+
self.result: Any = None
|
|
21
|
+
self.error: Optional[BaseException] = None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class BatchAccumulator:
|
|
25
|
+
def __init__(self, config: Dict[str, Any]) -> None:
|
|
26
|
+
self._size = int(config["size"])
|
|
27
|
+
self._timeout_ms = float(config.get("timeout") or 5000)
|
|
28
|
+
self._processor: Callable[[List[Job]], List[Any]] = config["processor"]
|
|
29
|
+
self._buffer: List[_Entry] = []
|
|
30
|
+
self._timer: Optional[threading.Timer] = None
|
|
31
|
+
self._lock = threading.Lock()
|
|
32
|
+
|
|
33
|
+
def build_processor(self) -> Callable[[Job], Any]:
|
|
34
|
+
"""Processor that buffers jobs; blocks each caller until flush."""
|
|
35
|
+
|
|
36
|
+
def process(job: Job) -> Any:
|
|
37
|
+
entry = _Entry(job)
|
|
38
|
+
with self._lock:
|
|
39
|
+
self._buffer.append(entry)
|
|
40
|
+
if len(self._buffer) >= self._size:
|
|
41
|
+
self._flush_locked()
|
|
42
|
+
elif self._timer is None:
|
|
43
|
+
self._timer = threading.Timer(self._timeout_ms / 1000.0, self.flush)
|
|
44
|
+
self._timer.daemon = True
|
|
45
|
+
self._timer.start()
|
|
46
|
+
entry.event.wait()
|
|
47
|
+
if entry.error is not None:
|
|
48
|
+
raise entry.error
|
|
49
|
+
return entry.result
|
|
50
|
+
|
|
51
|
+
return process
|
|
52
|
+
|
|
53
|
+
def flush(self) -> None:
|
|
54
|
+
with self._lock:
|
|
55
|
+
self._flush_locked()
|
|
56
|
+
|
|
57
|
+
def _flush_locked(self) -> None:
|
|
58
|
+
if self._timer:
|
|
59
|
+
self._timer.cancel()
|
|
60
|
+
self._timer = None
|
|
61
|
+
batch = self._buffer[:]
|
|
62
|
+
self._buffer.clear()
|
|
63
|
+
if not batch:
|
|
64
|
+
return
|
|
65
|
+
# Run the batch processor outside the lock via a dedicated thread so
|
|
66
|
+
# a size-triggered flush doesn't execute inside a caller's lock scope.
|
|
67
|
+
thread = threading.Thread(target=self._run_batch, args=(batch,), daemon=True)
|
|
68
|
+
thread.start()
|
|
69
|
+
|
|
70
|
+
def _run_batch(self, batch: List[_Entry]) -> None:
|
|
71
|
+
try:
|
|
72
|
+
results = self._processor([entry.job for entry in batch])
|
|
73
|
+
except BaseException as exc: # noqa: BLE001 - every entry fails together
|
|
74
|
+
for entry in batch:
|
|
75
|
+
entry.error = exc
|
|
76
|
+
entry.event.set()
|
|
77
|
+
return
|
|
78
|
+
for i, entry in enumerate(batch):
|
|
79
|
+
entry.result = results[i] if results and i < len(results) else None
|
|
80
|
+
entry.event.set()
|
|
81
|
+
|
|
82
|
+
def destroy(self) -> None:
|
|
83
|
+
with self._lock:
|
|
84
|
+
if self._timer:
|
|
85
|
+
self._timer.cancel()
|
|
86
|
+
self._timer = None
|
|
87
|
+
if self._buffer:
|
|
88
|
+
self._flush_locked()
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Graceful job cancellation (mirror of cancellation.ts).
|
|
2
|
+
|
|
3
|
+
Python has no AbortController; :class:`CancelSignal` exposes the same
|
|
4
|
+
``aborted`` surface, checked cooperatively by processors and the middleware
|
|
5
|
+
chain.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import threading
|
|
11
|
+
from typing import Dict, Optional
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class CancelSignal:
|
|
15
|
+
"""Minimal AbortSignal equivalent: ``signal.aborted`` flips once."""
|
|
16
|
+
|
|
17
|
+
def __init__(self) -> None:
|
|
18
|
+
self._event = threading.Event()
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
def aborted(self) -> bool:
|
|
22
|
+
return self._event.is_set()
|
|
23
|
+
|
|
24
|
+
def abort(self) -> None:
|
|
25
|
+
self._event.set()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class CancellationManager:
|
|
29
|
+
def __init__(self) -> None:
|
|
30
|
+
self._signals: Dict[str, CancelSignal] = {}
|
|
31
|
+
self._timers: Dict[str, threading.Timer] = {}
|
|
32
|
+
self._lock = threading.Lock()
|
|
33
|
+
|
|
34
|
+
def register(self, job_id: str) -> CancelSignal:
|
|
35
|
+
signal = CancelSignal()
|
|
36
|
+
with self._lock:
|
|
37
|
+
self._signals[job_id] = signal
|
|
38
|
+
return signal
|
|
39
|
+
|
|
40
|
+
def unregister(self, job_id: str) -> None:
|
|
41
|
+
with self._lock:
|
|
42
|
+
self._signals.pop(job_id, None)
|
|
43
|
+
timer = self._timers.pop(job_id, None)
|
|
44
|
+
if timer:
|
|
45
|
+
timer.cancel()
|
|
46
|
+
|
|
47
|
+
def cancel(self, job_id: str, grace_period_ms: float = 0) -> None:
|
|
48
|
+
with self._lock:
|
|
49
|
+
signal = self._signals.get(job_id)
|
|
50
|
+
if signal is None:
|
|
51
|
+
return
|
|
52
|
+
if grace_period_ms > 0:
|
|
53
|
+
timer = threading.Timer(grace_period_ms / 1000.0, signal.abort)
|
|
54
|
+
timer.daemon = True
|
|
55
|
+
with self._lock:
|
|
56
|
+
self._timers[job_id] = timer
|
|
57
|
+
timer.start()
|
|
58
|
+
else:
|
|
59
|
+
signal.abort()
|
|
60
|
+
|
|
61
|
+
def is_cancelled(self, job_id: str) -> bool:
|
|
62
|
+
with self._lock:
|
|
63
|
+
signal = self._signals.get(job_id)
|
|
64
|
+
return signal.aborted if signal else False
|
|
65
|
+
|
|
66
|
+
def get_signal(self, job_id: str) -> Optional[CancelSignal]:
|
|
67
|
+
with self._lock:
|
|
68
|
+
return self._signals.get(job_id)
|
|
69
|
+
|
|
70
|
+
def destroy_all(self) -> None:
|
|
71
|
+
with self._lock:
|
|
72
|
+
signals = list(self._signals.values())
|
|
73
|
+
timers = list(self._timers.values())
|
|
74
|
+
self._signals.clear()
|
|
75
|
+
self._timers.clear()
|
|
76
|
+
for timer in timers:
|
|
77
|
+
timer.cancel()
|
|
78
|
+
for signal in signals:
|
|
79
|
+
signal.abort()
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Circuit breaker for worker protection (mirror of circuitBreaker.ts).
|
|
2
|
+
|
|
3
|
+
CLOSED -> failures >= threshold -> OPEN (worker paused)
|
|
4
|
+
|
|
|
5
|
+
<- success --- HALF-OPEN <- reset timeout expires
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import threading
|
|
11
|
+
from typing import Any, Dict, Optional
|
|
12
|
+
|
|
13
|
+
from ..worker import Worker
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class WorkerCircuitBreaker:
|
|
17
|
+
def __init__(self, config: Dict[str, Any], worker: Worker) -> None:
|
|
18
|
+
self._config = config
|
|
19
|
+
self._worker = worker
|
|
20
|
+
self._state = "closed"
|
|
21
|
+
self._failures = 0
|
|
22
|
+
self._timer: Optional[threading.Timer] = None
|
|
23
|
+
self._lock = threading.Lock()
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def current_state(self) -> str:
|
|
27
|
+
return self._state
|
|
28
|
+
|
|
29
|
+
def is_open(self) -> bool:
|
|
30
|
+
return self._state == "open"
|
|
31
|
+
|
|
32
|
+
def on_success(self) -> None:
|
|
33
|
+
with self._lock:
|
|
34
|
+
if self._state == "half-open":
|
|
35
|
+
self._state = "closed"
|
|
36
|
+
self._failures = 0
|
|
37
|
+
callback = self._config.get("on_close") or self._config.get("onClose")
|
|
38
|
+
elif self._state == "closed":
|
|
39
|
+
self._failures = 0
|
|
40
|
+
callback = None
|
|
41
|
+
else:
|
|
42
|
+
callback = None
|
|
43
|
+
if callback:
|
|
44
|
+
callback()
|
|
45
|
+
|
|
46
|
+
def on_failure(self) -> None:
|
|
47
|
+
with self._lock:
|
|
48
|
+
self._failures += 1
|
|
49
|
+
threshold = int(self._config.get("threshold") or 5)
|
|
50
|
+
should_open = self._state == "half-open" or self._failures >= threshold
|
|
51
|
+
if should_open:
|
|
52
|
+
self._open()
|
|
53
|
+
|
|
54
|
+
def _open(self) -> None:
|
|
55
|
+
with self._lock:
|
|
56
|
+
self._state = "open"
|
|
57
|
+
failures = self._failures
|
|
58
|
+
if self._timer:
|
|
59
|
+
self._timer.cancel()
|
|
60
|
+
reset_timeout = float(
|
|
61
|
+
self._config.get("reset_timeout") or self._config.get("resetTimeout") or 30000
|
|
62
|
+
)
|
|
63
|
+
self._timer = threading.Timer(reset_timeout / 1000.0, self._half_open)
|
|
64
|
+
self._timer.daemon = True
|
|
65
|
+
self._timer.start()
|
|
66
|
+
on_open = self._config.get("on_open") or self._config.get("onOpen")
|
|
67
|
+
if on_open:
|
|
68
|
+
on_open(failures)
|
|
69
|
+
self._worker.pause()
|
|
70
|
+
|
|
71
|
+
def _half_open(self) -> None:
|
|
72
|
+
with self._lock:
|
|
73
|
+
self._state = "half-open"
|
|
74
|
+
on_half_open = self._config.get("on_half_open") or self._config.get("onHalfOpen")
|
|
75
|
+
if on_half_open:
|
|
76
|
+
on_half_open()
|
|
77
|
+
self._worker.resume()
|
|
78
|
+
|
|
79
|
+
def reset(self) -> None:
|
|
80
|
+
with self._lock:
|
|
81
|
+
self._state = "closed"
|
|
82
|
+
self._failures = 0
|
|
83
|
+
if self._timer:
|
|
84
|
+
self._timer.cancel()
|
|
85
|
+
self._timer = None
|
|
86
|
+
if self._worker.is_paused():
|
|
87
|
+
self._worker.resume()
|
|
88
|
+
|
|
89
|
+
def destroy(self) -> None:
|
|
90
|
+
with self._lock:
|
|
91
|
+
if self._timer:
|
|
92
|
+
self._timer.cancel()
|
|
93
|
+
self._timer = None
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Deduplication & debounce defaults merger (mirror of dedupDebounce.ts).
|
|
2
|
+
|
|
3
|
+
Jobs with the same name + data derive the same deduplication id
|
|
4
|
+
(``name:JSON(data)``, compact separators to match JSON.stringify).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from typing import Any, Dict, Optional
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class DedupDebounceMerger:
|
|
14
|
+
def __init__(
|
|
15
|
+
self,
|
|
16
|
+
dedup: Optional[Dict[str, Any]],
|
|
17
|
+
debounce: Optional[Dict[str, Any]],
|
|
18
|
+
) -> None:
|
|
19
|
+
self._dedup = dedup
|
|
20
|
+
self._debounce = debounce
|
|
21
|
+
|
|
22
|
+
@property
|
|
23
|
+
def active(self) -> bool:
|
|
24
|
+
return self._dedup is not None or self._debounce is not None
|
|
25
|
+
|
|
26
|
+
def merge(self, name: str, opts: Dict[str, Any], data: Any) -> Dict[str, Any]:
|
|
27
|
+
if not self.active:
|
|
28
|
+
return opts
|
|
29
|
+
merged = dict(opts)
|
|
30
|
+
if self._dedup is not None and "deduplication" not in merged:
|
|
31
|
+
data_key = (
|
|
32
|
+
json.dumps(data, separators=(",", ":")) if data is not None else ""
|
|
33
|
+
)
|
|
34
|
+
merged["deduplication"] = {
|
|
35
|
+
"id": f"{name}:{data_key}",
|
|
36
|
+
"ttl": self._dedup.get("ttl", 3600000),
|
|
37
|
+
"extend": self._dedup.get("extend"),
|
|
38
|
+
"replace": self._dedup.get("replace"),
|
|
39
|
+
}
|
|
40
|
+
if self._debounce is not None and "debounce" not in merged:
|
|
41
|
+
merged["debounce"] = {"id": name, "ttl": self._debounce["ttl"]}
|
|
42
|
+
return merged
|