baqueue 0.1.0__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.
baqueue/__init__.py ADDED
@@ -0,0 +1,19 @@
1
+ """BaQueue - A powerful Python queue management package inspired by Laravel Horizon."""
2
+
3
+ from baqueue.config import BaQueueConfig
4
+ from baqueue.job import Job
5
+ from baqueue.queue import Queue
6
+ from baqueue.batch import Batch
7
+ from baqueue.events import EventBus
8
+ from baqueue.retry import BackoffStrategy
9
+
10
+ __version__ = "0.1.0"
11
+
12
+ __all__ = [
13
+ "BaQueueConfig",
14
+ "Job",
15
+ "Queue",
16
+ "Batch",
17
+ "EventBus",
18
+ "BackoffStrategy",
19
+ ]
baqueue/balancer.py ADDED
@@ -0,0 +1,108 @@
1
+ """Worker balancing strategies across queues."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from abc import ABC, abstractmethod
7
+
8
+ from baqueue.drivers.base import BaseDriver
9
+
10
+ logger = logging.getLogger("baqueue.balancer")
11
+
12
+
13
+ class BaseBalancer(ABC):
14
+ """Base class for balancing strategies."""
15
+
16
+ @abstractmethod
17
+ async def recommend(
18
+ self,
19
+ driver: BaseDriver,
20
+ queues: list[str],
21
+ current_workers: int,
22
+ ) -> int:
23
+ """Return the recommended number of workers."""
24
+ ...
25
+
26
+
27
+ class AutoBalancer(BaseBalancer):
28
+ """Dynamically adjusts workers based on queue pressure.
29
+
30
+ Scales up when queues have high wait times or large backlogs,
31
+ scales down when queues are mostly idle.
32
+ """
33
+
34
+ def __init__(self, min_workers: int = 1, max_workers: int = 10):
35
+ self.min_workers = min_workers
36
+ self.max_workers = max_workers
37
+
38
+ async def recommend(
39
+ self,
40
+ driver: BaseDriver,
41
+ queues: list[str],
42
+ current_workers: int,
43
+ ) -> int:
44
+ total_pending = 0
45
+ for q in queues:
46
+ total_pending += await driver.size(q)
47
+
48
+ if total_pending == 0:
49
+ return self.min_workers
50
+
51
+ if total_pending > current_workers * 10:
52
+ desired = min(current_workers + 2, self.max_workers)
53
+ elif total_pending > current_workers * 5:
54
+ desired = min(current_workers + 1, self.max_workers)
55
+ elif total_pending < current_workers:
56
+ desired = max(current_workers - 1, self.min_workers)
57
+ else:
58
+ desired = current_workers
59
+
60
+ return desired
61
+
62
+
63
+ class SimpleBalancer(BaseBalancer):
64
+ """Round-robin: keeps worker count stable, just ensures fairness."""
65
+
66
+ def __init__(self, min_workers: int = 1, max_workers: int = 10):
67
+ self.min_workers = min_workers
68
+ self.max_workers = max_workers
69
+
70
+ async def recommend(
71
+ self,
72
+ driver: BaseDriver,
73
+ queues: list[str],
74
+ current_workers: int,
75
+ ) -> int:
76
+ total_pending = 0
77
+ for q in queues:
78
+ total_pending += await driver.size(q)
79
+
80
+ if total_pending > 0:
81
+ return max(min(len(queues), self.max_workers), self.min_workers)
82
+ return self.min_workers
83
+
84
+
85
+ class NullBalancer(BaseBalancer):
86
+ """No balancing - keeps the current worker count unchanged."""
87
+
88
+ async def recommend(
89
+ self,
90
+ driver: BaseDriver,
91
+ queues: list[str],
92
+ current_workers: int,
93
+ ) -> int:
94
+ return current_workers
95
+
96
+
97
+ def create_balancer(
98
+ strategy: str,
99
+ min_workers: int = 1,
100
+ max_workers: int = 10,
101
+ ) -> BaseBalancer:
102
+ """Factory for creating a balancer by strategy name."""
103
+ if strategy == "auto":
104
+ return AutoBalancer(min_workers=min_workers, max_workers=max_workers)
105
+ elif strategy == "simple":
106
+ return SimpleBalancer(min_workers=min_workers, max_workers=max_workers)
107
+ else:
108
+ return NullBalancer()
baqueue/batch.py ADDED
@@ -0,0 +1,159 @@
1
+ """Batch job orchestration with lifecycle callbacks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Type
6
+ from uuid import uuid4
7
+
8
+ from baqueue.drivers.base import BaseDriver
9
+ from baqueue.events import EventBus
10
+ from baqueue.job import Job, FunctionJob
11
+ from baqueue.serializer import JobPayload, _now_ts, get_class_path
12
+
13
+
14
+ class Batch:
15
+ """Fluent builder for dispatching a batch of jobs.
16
+
17
+ Usage::
18
+
19
+ batch = await Batch(driver, [
20
+ (SendEmail, {"to": "a@b.com", "subject": "Hi", "body": "Hey"}),
21
+ (SendEmail, {"to": "c@d.com", "subject": "Hi", "body": "Hey"}),
22
+ ]).then(OnAllDone).catch(OnAnyFailed).name("newsletter-2024").dispatch()
23
+ """
24
+
25
+ def __init__(
26
+ self,
27
+ driver: BaseDriver,
28
+ jobs: list[tuple[Type[Job] | Job | FunctionJob, dict[str, Any]]],
29
+ events: EventBus | None = None,
30
+ ):
31
+ self._driver = driver
32
+ self._jobs = jobs
33
+ self._events = events or EventBus.default()
34
+ self._batch_id = uuid4().hex
35
+ self._name: str = ""
36
+ self._then_job: Type[Job] | None = None
37
+ self._catch_job: Type[Job] | None = None
38
+ self._finally_job: Type[Job] | None = None
39
+ self._allow_failures = False
40
+ self._tags: list[str] = []
41
+
42
+ def name(self, name: str) -> Batch:
43
+ self._name = name
44
+ return self
45
+
46
+ def then(self, job_class: Type[Job]) -> Batch:
47
+ """Job to run when all jobs in the batch complete successfully."""
48
+ self._then_job = job_class
49
+ return self
50
+
51
+ def catch(self, job_class: Type[Job]) -> Batch:
52
+ """Job to run when any job in the batch fails."""
53
+ self._catch_job = job_class
54
+ return self
55
+
56
+ def on_finally(self, job_class: Type[Job]) -> Batch:
57
+ """Job to run when all jobs in the batch finish (regardless of success/failure)."""
58
+ self._finally_job = job_class
59
+ return self
60
+
61
+ def allow_failures(self) -> Batch:
62
+ self._allow_failures = True
63
+ return self
64
+
65
+ def tag(self, *tags: str) -> Batch:
66
+ self._tags.extend(tags)
67
+ return self
68
+
69
+ async def dispatch(self) -> BatchResult:
70
+ """Push all batch jobs and store batch metadata."""
71
+ payloads: list[JobPayload] = []
72
+ for job, kwargs in self._jobs:
73
+ payload = self._build_payload(job, kwargs)
74
+ payload.batch_id = self._batch_id
75
+ payload.tags.append(f"batch:{self._batch_id}")
76
+ if self._name:
77
+ payload.tags.append(f"batch:{self._name}")
78
+ for t in self._tags:
79
+ if t not in payload.tags:
80
+ payload.tags.append(t)
81
+ payloads.append(payload)
82
+
83
+ batch_data = {
84
+ "id": self._batch_id,
85
+ "name": self._name,
86
+ "total": len(payloads),
87
+ "completed_count": 0,
88
+ "failed_count": 0,
89
+ "allow_failures": self._allow_failures,
90
+ "then_job": get_class_path(self._then_job) if self._then_job else None,
91
+ "catch_job": get_class_path(self._catch_job) if self._catch_job else None,
92
+ "finally_job": get_class_path(self._finally_job) if self._finally_job else None,
93
+ "created_at": _now_ts(),
94
+ "status": "processing",
95
+ }
96
+
97
+ await self._driver.store_batch(self._batch_id, batch_data)
98
+ ids = await self._driver.push_many(payloads)
99
+
100
+ self._events.emit_nowait("batch.dispatched", batch_id=self._batch_id, count=len(ids))
101
+
102
+ self._register_callbacks()
103
+
104
+ return BatchResult(batch_id=self._batch_id, name=self._name, job_ids=ids, total=len(ids))
105
+
106
+ def _register_callbacks(self) -> None:
107
+ batch_id = self._batch_id
108
+ driver = self._driver
109
+ then_job = self._then_job
110
+ catch_job = self._catch_job
111
+ finally_job = self._finally_job
112
+
113
+ async def on_batch_completed(**kwargs: Any) -> None:
114
+ if kwargs.get("batch_id") != batch_id:
115
+ return
116
+ batch = kwargs.get("batch", {})
117
+ if then_job and batch.get("failed_count", 0) == 0:
118
+ inst = then_job()
119
+ payload = inst.build_payload(batch_id=batch_id, batch=batch)
120
+ await driver.push(payload)
121
+ if finally_job:
122
+ inst = finally_job()
123
+ payload = inst.build_payload(batch_id=batch_id, batch=batch)
124
+ await driver.push(payload)
125
+ await driver.update_batch(batch_id, {"status": "completed"})
126
+
127
+ async def on_batch_failed(**kwargs: Any) -> None:
128
+ if kwargs.get("batch_id") != batch_id:
129
+ return
130
+ batch = kwargs.get("batch", {})
131
+ if catch_job:
132
+ inst = catch_job()
133
+ payload = inst.build_payload(batch_id=batch_id, batch=batch)
134
+ await driver.push(payload)
135
+
136
+ self._events.on("batch.completed", on_batch_completed)
137
+ self._events.on("batch.failed", on_batch_failed)
138
+
139
+ def _build_payload(self, job: Type[Job] | Job | FunctionJob, kwargs: dict[str, Any]) -> JobPayload:
140
+ if isinstance(job, FunctionJob):
141
+ return job.build_payload(**kwargs)
142
+ if isinstance(job, Job):
143
+ return job.build_payload(**kwargs)
144
+ if isinstance(job, type) and issubclass(job, Job):
145
+ return job().build_payload(**kwargs)
146
+ raise TypeError(f"Expected a Job class or instance, got {type(job)}")
147
+
148
+
149
+ class BatchResult:
150
+ """Result of a dispatched batch."""
151
+
152
+ def __init__(self, batch_id: str, name: str, job_ids: list[str], total: int):
153
+ self.batch_id = batch_id
154
+ self.name = name
155
+ self.job_ids = job_ids
156
+ self.total = total
157
+
158
+ def __repr__(self) -> str:
159
+ return f"BatchResult(id={self.batch_id!r}, name={self.name!r}, total={self.total})"