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,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()
@@ -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