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 +19 -0
- baqueue/balancer.py +108 -0
- baqueue/batch.py +159 -0
- baqueue/cli.py +459 -0
- baqueue/config.py +79 -0
- baqueue/dashboard/__init__.py +1 -0
- baqueue/dashboard/api.py +193 -0
- baqueue/dashboard/server.py +263 -0
- baqueue/dashboard/static/app.js +450 -0
- baqueue/dashboard/static/index.html +580 -0
- baqueue/dashboard/static/style.css +1415 -0
- baqueue/drivers/__init__.py +1 -0
- baqueue/drivers/base.py +212 -0
- baqueue/drivers/memory_driver.py +318 -0
- baqueue/drivers/postgres_driver.py +656 -0
- baqueue/drivers/redis_driver.py +656 -0
- baqueue/drivers/sqlite_driver.py +706 -0
- baqueue/events.py +64 -0
- baqueue/job.py +128 -0
- baqueue/pruner.py +128 -0
- baqueue/queue.py +225 -0
- baqueue/retry.py +55 -0
- baqueue/scheduler.py +101 -0
- baqueue/serializer.py +124 -0
- baqueue/supervisor.py +206 -0
- baqueue/worker.py +165 -0
- baqueue-0.1.0.dist-info/METADATA +609 -0
- baqueue-0.1.0.dist-info/RECORD +32 -0
- baqueue-0.1.0.dist-info/WHEEL +5 -0
- baqueue-0.1.0.dist-info/entry_points.txt +2 -0
- baqueue-0.1.0.dist-info/licenses/LICENSE +21 -0
- baqueue-0.1.0.dist-info/top_level.txt +1 -0
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})"
|