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/events.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Event bus for BaQueue lifecycle events."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
from collections import defaultdict
|
|
8
|
+
from typing import Any, Callable, Coroutine
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger("baqueue.events")
|
|
11
|
+
|
|
12
|
+
EventHandler = Callable[..., Coroutine[Any, Any, None]]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class EventBus:
|
|
16
|
+
"""Simple async event bus for job lifecycle events.
|
|
17
|
+
|
|
18
|
+
Built-in events:
|
|
19
|
+
job.pushed, job.started, job.completed, job.failed, job.retrying,
|
|
20
|
+
batch.completed, batch.failed,
|
|
21
|
+
worker.started, worker.stopped,
|
|
22
|
+
supervisor.started, supervisor.stopped,
|
|
23
|
+
queue.pruned
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
_handlers: dict[str, list[EventHandler]]
|
|
27
|
+
_instance: EventBus | None = None
|
|
28
|
+
|
|
29
|
+
def __init__(self) -> None:
|
|
30
|
+
self._handlers = defaultdict(list)
|
|
31
|
+
|
|
32
|
+
@classmethod
|
|
33
|
+
def default(cls) -> EventBus:
|
|
34
|
+
if cls._instance is None:
|
|
35
|
+
cls._instance = cls()
|
|
36
|
+
return cls._instance
|
|
37
|
+
|
|
38
|
+
def on(self, event: str, handler: EventHandler) -> None:
|
|
39
|
+
self._handlers[event].append(handler)
|
|
40
|
+
|
|
41
|
+
def off(self, event: str, handler: EventHandler | None = None) -> None:
|
|
42
|
+
if handler is None:
|
|
43
|
+
self._handlers.pop(event, None)
|
|
44
|
+
else:
|
|
45
|
+
handlers = self._handlers.get(event, [])
|
|
46
|
+
self._handlers[event] = [h for h in handlers if h is not handler]
|
|
47
|
+
|
|
48
|
+
async def emit(self, event: str, **kwargs: Any) -> None:
|
|
49
|
+
for handler in self._handlers.get(event, []):
|
|
50
|
+
try:
|
|
51
|
+
await handler(**kwargs)
|
|
52
|
+
except Exception:
|
|
53
|
+
logger.exception("Error in event handler for %s", event)
|
|
54
|
+
|
|
55
|
+
def emit_nowait(self, event: str, **kwargs: Any) -> None:
|
|
56
|
+
"""Fire-and-forget emit for use in sync contexts."""
|
|
57
|
+
try:
|
|
58
|
+
loop = asyncio.get_running_loop()
|
|
59
|
+
loop.create_task(self.emit(event, **kwargs))
|
|
60
|
+
except RuntimeError:
|
|
61
|
+
pass
|
|
62
|
+
|
|
63
|
+
def clear(self) -> None:
|
|
64
|
+
self._handlers.clear()
|
baqueue/job.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Base Job class and decorator for defining queue jobs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import functools
|
|
6
|
+
from typing import Any, Callable, Coroutine
|
|
7
|
+
|
|
8
|
+
from baqueue.serializer import JobPayload, get_class_path
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Job:
|
|
12
|
+
"""Base class for queue jobs.
|
|
13
|
+
|
|
14
|
+
Usage::
|
|
15
|
+
|
|
16
|
+
class SendEmail(Job):
|
|
17
|
+
queue = "emails"
|
|
18
|
+
max_attempts = 3
|
|
19
|
+
backoff = "exponential"
|
|
20
|
+
timeout = 30
|
|
21
|
+
tags = ["email"]
|
|
22
|
+
|
|
23
|
+
async def handle(self, to: str, subject: str, body: str):
|
|
24
|
+
await send_email(to, subject, body)
|
|
25
|
+
|
|
26
|
+
async def on_failure(self, error: Exception, payload: JobPayload):
|
|
27
|
+
log.error("Email send failed: %s", error)
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
queue: str = "default"
|
|
31
|
+
max_attempts: int = 3
|
|
32
|
+
backoff: str | list[int] = "exponential"
|
|
33
|
+
timeout: int = 60
|
|
34
|
+
tags: list[str] = []
|
|
35
|
+
|
|
36
|
+
async def handle(self, **kwargs: Any) -> Any:
|
|
37
|
+
raise NotImplementedError("Subclasses must implement handle()")
|
|
38
|
+
|
|
39
|
+
async def on_failure(self, error: Exception, payload: JobPayload) -> None:
|
|
40
|
+
"""Called when the job fails all retry attempts."""
|
|
41
|
+
pass
|
|
42
|
+
|
|
43
|
+
async def on_success(self, result: Any, payload: JobPayload) -> None:
|
|
44
|
+
"""Called when the job completes successfully."""
|
|
45
|
+
pass
|
|
46
|
+
|
|
47
|
+
def build_payload(self, **kwargs: Any) -> JobPayload:
|
|
48
|
+
return JobPayload(
|
|
49
|
+
job_class=get_class_path(self.__class__),
|
|
50
|
+
data=kwargs,
|
|
51
|
+
queue=self.queue,
|
|
52
|
+
max_attempts=self.max_attempts,
|
|
53
|
+
backoff=self.backoff,
|
|
54
|
+
timeout=self.timeout,
|
|
55
|
+
tags=list(self.tags),
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
@classmethod
|
|
59
|
+
def as_job(
|
|
60
|
+
cls,
|
|
61
|
+
queue: str = "default",
|
|
62
|
+
max_attempts: int = 3,
|
|
63
|
+
backoff: str | list[int] = "exponential",
|
|
64
|
+
timeout: int = 60,
|
|
65
|
+
tags: list[str] | None = None,
|
|
66
|
+
) -> Callable:
|
|
67
|
+
"""Decorator to turn an async function into a dispatchable job.
|
|
68
|
+
|
|
69
|
+
Usage::
|
|
70
|
+
|
|
71
|
+
@Job.as_job(queue="emails", max_attempts=3)
|
|
72
|
+
async def send_email(to, subject, body):
|
|
73
|
+
...
|
|
74
|
+
|
|
75
|
+
await Queue.push(send_email, to="a@b.com", subject="Hi", body="Hello")
|
|
76
|
+
"""
|
|
77
|
+
def decorator(func: Callable[..., Coroutine]) -> FunctionJob:
|
|
78
|
+
job = FunctionJob(
|
|
79
|
+
func=func,
|
|
80
|
+
queue=queue,
|
|
81
|
+
max_attempts=max_attempts,
|
|
82
|
+
backoff=backoff,
|
|
83
|
+
timeout=timeout,
|
|
84
|
+
tags=tags or [],
|
|
85
|
+
)
|
|
86
|
+
functools.update_wrapper(job, func)
|
|
87
|
+
return job
|
|
88
|
+
return decorator
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class FunctionJob(Job):
|
|
92
|
+
"""Wraps a plain async function as a Job."""
|
|
93
|
+
|
|
94
|
+
def __init__(
|
|
95
|
+
self,
|
|
96
|
+
func: Callable[..., Coroutine],
|
|
97
|
+
queue: str = "default",
|
|
98
|
+
max_attempts: int = 3,
|
|
99
|
+
backoff: str | list[int] = "exponential",
|
|
100
|
+
timeout: int = 60,
|
|
101
|
+
tags: list[str] | None = None,
|
|
102
|
+
):
|
|
103
|
+
self._func = func
|
|
104
|
+
self.queue = queue
|
|
105
|
+
self.max_attempts = max_attempts
|
|
106
|
+
self.backoff = backoff
|
|
107
|
+
self.timeout = timeout
|
|
108
|
+
self.tags = tags or []
|
|
109
|
+
self.__name__ = func.__name__
|
|
110
|
+
self.__qualname__ = func.__qualname__
|
|
111
|
+
self.__module__ = func.__module__
|
|
112
|
+
|
|
113
|
+
async def handle(self, **kwargs: Any) -> Any:
|
|
114
|
+
return await self._func(**kwargs)
|
|
115
|
+
|
|
116
|
+
def build_payload(self, **kwargs: Any) -> JobPayload:
|
|
117
|
+
return JobPayload(
|
|
118
|
+
job_class=f"{self._func.__module__}.{self._func.__qualname__}",
|
|
119
|
+
data=kwargs,
|
|
120
|
+
queue=self.queue,
|
|
121
|
+
max_attempts=self.max_attempts,
|
|
122
|
+
backoff=self.backoff,
|
|
123
|
+
timeout=self.timeout,
|
|
124
|
+
tags=list(self.tags),
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
async def __call__(self, *args: Any, **kwargs: Any) -> Any:
|
|
128
|
+
return await self._func(*args, **kwargs)
|
baqueue/pruner.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Pruner - removes old or unwanted jobs from the queue system."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
|
|
8
|
+
from baqueue.config import BaQueueConfig
|
|
9
|
+
from baqueue.drivers.base import BaseDriver
|
|
10
|
+
from baqueue.events import EventBus
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger("baqueue.pruner")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Pruner:
|
|
16
|
+
"""Automatically prunes old jobs on a schedule.
|
|
17
|
+
|
|
18
|
+
Can also be triggered manually via the CLI or API.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
driver: BaseDriver,
|
|
24
|
+
config: BaQueueConfig | None = None,
|
|
25
|
+
events: EventBus | None = None,
|
|
26
|
+
):
|
|
27
|
+
self.driver = driver
|
|
28
|
+
self.config = config or BaQueueConfig()
|
|
29
|
+
self.events = events or EventBus.default()
|
|
30
|
+
self._running = False
|
|
31
|
+
|
|
32
|
+
# ── Threshold resolution ────────────────────────────────────
|
|
33
|
+
# New *_seconds fields are primary. The legacy *_hours fields override
|
|
34
|
+
# when set to a positive value, so existing JSON configs keep working.
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def completed_threshold(self) -> float:
|
|
38
|
+
if self.config.prune_completed_hours > 0:
|
|
39
|
+
return self.config.prune_completed_hours * 3600
|
|
40
|
+
return float(self.config.prune_completed_seconds)
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def failed_threshold(self) -> float:
|
|
44
|
+
if self.config.prune_failed_hours > 0:
|
|
45
|
+
return self.config.prune_failed_hours * 3600
|
|
46
|
+
return float(self.config.prune_other_seconds)
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def cancelled_threshold(self) -> float:
|
|
50
|
+
if self.config.prune_cancelled_hours > 0:
|
|
51
|
+
return self.config.prune_cancelled_hours * 3600
|
|
52
|
+
return float(self.config.prune_other_seconds)
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def metrics_threshold(self) -> float:
|
|
56
|
+
if self.config.prune_metrics_hours > 0:
|
|
57
|
+
return self.config.prune_metrics_hours * 3600
|
|
58
|
+
return float(self.config.prune_metrics_seconds)
|
|
59
|
+
|
|
60
|
+
async def prune_once(self) -> dict[str, int]:
|
|
61
|
+
"""Run a single prune pass based on config."""
|
|
62
|
+
results: dict[str, int] = {}
|
|
63
|
+
|
|
64
|
+
if self.completed_threshold > 0:
|
|
65
|
+
results["completed"] = await self.driver.prune(
|
|
66
|
+
status="completed",
|
|
67
|
+
older_than_seconds=self.completed_threshold,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
if self.failed_threshold > 0:
|
|
71
|
+
results["failed"] = await self.driver.prune(
|
|
72
|
+
status="failed",
|
|
73
|
+
older_than_seconds=self.failed_threshold,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
if self.cancelled_threshold > 0:
|
|
77
|
+
results["cancelled"] = await self.driver.prune(
|
|
78
|
+
status="cancelled",
|
|
79
|
+
older_than_seconds=self.cancelled_threshold,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
if self.metrics_threshold > 0:
|
|
83
|
+
results["metrics"] = await self.driver.prune_metrics(
|
|
84
|
+
older_than_seconds=self.metrics_threshold,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
total = sum(results.values())
|
|
88
|
+
if total > 0:
|
|
89
|
+
logger.info("Pruned %d entries: %s", total, results)
|
|
90
|
+
self.events.emit_nowait("queue.pruned", results=results)
|
|
91
|
+
|
|
92
|
+
return results
|
|
93
|
+
|
|
94
|
+
async def prune_by_tag(self, tag: str) -> int:
|
|
95
|
+
count = await self.driver.prune(tag=tag)
|
|
96
|
+
if count > 0:
|
|
97
|
+
logger.info("Pruned %d jobs with tag '%s'", count, tag)
|
|
98
|
+
return count
|
|
99
|
+
|
|
100
|
+
async def prune_by_status(self, status: str, hours: float | None = None) -> int:
|
|
101
|
+
older_than = hours * 3600 if hours else None
|
|
102
|
+
count = await self.driver.prune(status=status, older_than_seconds=older_than)
|
|
103
|
+
if count > 0:
|
|
104
|
+
logger.info("Pruned %d jobs with status '%s'", count, status)
|
|
105
|
+
return count
|
|
106
|
+
|
|
107
|
+
async def start(self, interval_seconds: float | None = None) -> None:
|
|
108
|
+
"""Start automatic pruning loop. Uses config.prune_interval_seconds by default."""
|
|
109
|
+
if interval_seconds is None:
|
|
110
|
+
interval_seconds = float(self.config.prune_interval_seconds)
|
|
111
|
+
interval_seconds = max(1.0, float(interval_seconds))
|
|
112
|
+
|
|
113
|
+
self._running = True
|
|
114
|
+
logger.info("Pruner started (every %.0fs)", interval_seconds)
|
|
115
|
+
|
|
116
|
+
try:
|
|
117
|
+
while self._running:
|
|
118
|
+
try:
|
|
119
|
+
await self.prune_once()
|
|
120
|
+
except Exception:
|
|
121
|
+
logger.exception("Error during prune cycle")
|
|
122
|
+
await asyncio.sleep(interval_seconds)
|
|
123
|
+
except asyncio.CancelledError:
|
|
124
|
+
self._running = False
|
|
125
|
+
raise
|
|
126
|
+
|
|
127
|
+
def stop(self) -> None:
|
|
128
|
+
self._running = False
|
baqueue/queue.py
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"""Queue manager - the main entry point for dispatching jobs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Type
|
|
6
|
+
|
|
7
|
+
from baqueue.config import BaQueueConfig
|
|
8
|
+
from baqueue.drivers.base import BaseDriver
|
|
9
|
+
from baqueue.drivers.memory_driver import MemoryDriver
|
|
10
|
+
from baqueue.events import EventBus
|
|
11
|
+
from baqueue.job import Job, FunctionJob
|
|
12
|
+
from baqueue.serializer import JobPayload, _now_ts
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Queue:
|
|
16
|
+
"""Central queue manager. Maintains a singleton driver connection.
|
|
17
|
+
|
|
18
|
+
Usage::
|
|
19
|
+
|
|
20
|
+
Queue.configure(BaQueueConfig(driver=DriverConfig(name="redis", url="redis://localhost")))
|
|
21
|
+
await Queue.push(SendEmail, to="user@example.com", subject="Hi", body="Hello")
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
_driver: BaseDriver | None = None
|
|
25
|
+
_config: BaQueueConfig | None = None
|
|
26
|
+
_events: EventBus | None = None
|
|
27
|
+
|
|
28
|
+
@classmethod
|
|
29
|
+
def configure(cls, config: BaQueueConfig | None = None, driver: BaseDriver | None = None) -> None:
|
|
30
|
+
cls._config = config or BaQueueConfig()
|
|
31
|
+
if driver is not None:
|
|
32
|
+
driver.auto_cleanup_on_disk_full = cls._config.auto_cleanup_on_disk_full
|
|
33
|
+
cls._driver = driver
|
|
34
|
+
cls._events = EventBus.default()
|
|
35
|
+
|
|
36
|
+
@classmethod
|
|
37
|
+
def get_driver(cls) -> BaseDriver:
|
|
38
|
+
if cls._driver is None:
|
|
39
|
+
cls._driver = _create_driver(cls._config or BaQueueConfig())
|
|
40
|
+
return cls._driver
|
|
41
|
+
|
|
42
|
+
@classmethod
|
|
43
|
+
def get_config(cls) -> BaQueueConfig:
|
|
44
|
+
if cls._config is None:
|
|
45
|
+
cls._config = BaQueueConfig()
|
|
46
|
+
return cls._config
|
|
47
|
+
|
|
48
|
+
@classmethod
|
|
49
|
+
def get_events(cls) -> EventBus:
|
|
50
|
+
if cls._events is None:
|
|
51
|
+
cls._events = EventBus.default()
|
|
52
|
+
return cls._events
|
|
53
|
+
|
|
54
|
+
@classmethod
|
|
55
|
+
async def connect(cls) -> None:
|
|
56
|
+
await cls.get_driver().connect()
|
|
57
|
+
|
|
58
|
+
@classmethod
|
|
59
|
+
async def disconnect(cls) -> None:
|
|
60
|
+
driver = cls.get_driver()
|
|
61
|
+
await driver.disconnect()
|
|
62
|
+
cls._driver = None
|
|
63
|
+
|
|
64
|
+
# ── Dispatch ────────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
@classmethod
|
|
67
|
+
async def push(cls, job: Type[Job] | Job | FunctionJob, **kwargs: Any) -> str:
|
|
68
|
+
"""Push a job onto the queue for immediate processing."""
|
|
69
|
+
payload = _build_payload(job, kwargs)
|
|
70
|
+
job_id = await cls.get_driver().push(payload)
|
|
71
|
+
cls.get_events().emit_nowait("job.pushed", payload=payload)
|
|
72
|
+
return job_id
|
|
73
|
+
|
|
74
|
+
@classmethod
|
|
75
|
+
async def later(
|
|
76
|
+
cls,
|
|
77
|
+
job: Type[Job] | Job | FunctionJob,
|
|
78
|
+
delay: float,
|
|
79
|
+
**kwargs: Any,
|
|
80
|
+
) -> str:
|
|
81
|
+
"""Push a job with a delay (in seconds)."""
|
|
82
|
+
payload = _build_payload(job, kwargs)
|
|
83
|
+
payload.delay_until = _now_ts() + delay
|
|
84
|
+
job_id = await cls.get_driver().push(payload)
|
|
85
|
+
cls.get_events().emit_nowait("job.pushed", payload=payload)
|
|
86
|
+
return job_id
|
|
87
|
+
|
|
88
|
+
@classmethod
|
|
89
|
+
async def bulk(cls, jobs: list[tuple[Type[Job] | Job | FunctionJob, dict[str, Any]]]) -> list[str]:
|
|
90
|
+
"""Push multiple jobs at once."""
|
|
91
|
+
payloads = [_build_payload(j, kw) for j, kw in jobs]
|
|
92
|
+
ids = await cls.get_driver().push_many(payloads)
|
|
93
|
+
for p in payloads:
|
|
94
|
+
cls.get_events().emit_nowait("job.pushed", payload=p)
|
|
95
|
+
return ids
|
|
96
|
+
|
|
97
|
+
# ── Query ───────────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
@classmethod
|
|
100
|
+
async def size(cls, queue: str = "default") -> int:
|
|
101
|
+
return await cls.get_driver().size(queue)
|
|
102
|
+
|
|
103
|
+
@classmethod
|
|
104
|
+
async def queues(cls) -> list[str]:
|
|
105
|
+
return await cls.get_driver().queues()
|
|
106
|
+
|
|
107
|
+
@classmethod
|
|
108
|
+
async def get_job(cls, job_id: str) -> JobPayload | None:
|
|
109
|
+
return await cls.get_driver().get_job(job_id)
|
|
110
|
+
|
|
111
|
+
@classmethod
|
|
112
|
+
async def get_jobs(cls, **kwargs: Any) -> list[JobPayload]:
|
|
113
|
+
return await cls.get_driver().get_jobs(**kwargs)
|
|
114
|
+
|
|
115
|
+
# ── Retry ───────────────────────────────────────────────────
|
|
116
|
+
|
|
117
|
+
@classmethod
|
|
118
|
+
async def retry_failed(
|
|
119
|
+
cls,
|
|
120
|
+
queue: str | None = None,
|
|
121
|
+
tag: str | None = None,
|
|
122
|
+
created_from: float | None = None,
|
|
123
|
+
created_to: float | None = None,
|
|
124
|
+
) -> int:
|
|
125
|
+
"""Retry all failed jobs matching the optional filters. Returns count retried.
|
|
126
|
+
|
|
127
|
+
Each matched job is released back onto its queue with ``delay=0`` —
|
|
128
|
+
the same path used by single-job retry.
|
|
129
|
+
"""
|
|
130
|
+
driver = cls.get_driver()
|
|
131
|
+
count = 0
|
|
132
|
+
batch_size = 200
|
|
133
|
+
# release() flips status to pending, so failed-filtered fetches naturally
|
|
134
|
+
# shrink. Cap iterations as a safety net.
|
|
135
|
+
for _ in range(1000):
|
|
136
|
+
jobs = await driver.get_jobs(
|
|
137
|
+
status="failed", queue=queue, tag=tag,
|
|
138
|
+
created_from=created_from, created_to=created_to,
|
|
139
|
+
offset=0, limit=batch_size,
|
|
140
|
+
)
|
|
141
|
+
if not jobs:
|
|
142
|
+
break
|
|
143
|
+
for job in jobs:
|
|
144
|
+
await driver.release(job, delay=0)
|
|
145
|
+
count += 1
|
|
146
|
+
return count
|
|
147
|
+
|
|
148
|
+
# ── Prune ───────────────────────────────────────────────────
|
|
149
|
+
|
|
150
|
+
@classmethod
|
|
151
|
+
async def prune(
|
|
152
|
+
cls,
|
|
153
|
+
status: str | None = None,
|
|
154
|
+
tag: str | None = None,
|
|
155
|
+
hours: float | None = None,
|
|
156
|
+
queue: str | None = None,
|
|
157
|
+
) -> int:
|
|
158
|
+
older_than = hours * 3600 if hours else None
|
|
159
|
+
count = await cls.get_driver().prune(
|
|
160
|
+
status=status, tag=tag, older_than_seconds=older_than, queue=queue
|
|
161
|
+
)
|
|
162
|
+
cls.get_events().emit_nowait("queue.pruned", status=status, tag=tag, count=count)
|
|
163
|
+
return count
|
|
164
|
+
|
|
165
|
+
# ── Flush ───────────────────────────────────────────────────
|
|
166
|
+
|
|
167
|
+
@classmethod
|
|
168
|
+
async def flush(cls, queue: str | None = None) -> None:
|
|
169
|
+
await cls.get_driver().flush(queue)
|
|
170
|
+
|
|
171
|
+
# ── Metrics ─────────────────────────────────────────────────
|
|
172
|
+
|
|
173
|
+
@classmethod
|
|
174
|
+
async def metrics(cls, queue: str | None = None) -> dict[str, Any]:
|
|
175
|
+
return await cls.get_driver().get_metrics(queue)
|
|
176
|
+
|
|
177
|
+
# ── Reset (for testing) ─────────────────────────────────────
|
|
178
|
+
|
|
179
|
+
@classmethod
|
|
180
|
+
def reset(cls) -> None:
|
|
181
|
+
cls._driver = None
|
|
182
|
+
cls._config = None
|
|
183
|
+
cls._events = None
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _build_payload(job: Type[Job] | Job | FunctionJob, kwargs: dict[str, Any]) -> JobPayload:
|
|
187
|
+
if isinstance(job, FunctionJob):
|
|
188
|
+
return job.build_payload(**kwargs)
|
|
189
|
+
if isinstance(job, Job):
|
|
190
|
+
return job.build_payload(**kwargs)
|
|
191
|
+
if isinstance(job, type) and issubclass(job, Job):
|
|
192
|
+
return job().build_payload(**kwargs)
|
|
193
|
+
raise TypeError(f"Expected a Job class or instance, got {type(job)}")
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _create_driver(config: BaQueueConfig) -> BaseDriver:
|
|
197
|
+
name = config.driver.name
|
|
198
|
+
if name == "memory":
|
|
199
|
+
driver: BaseDriver = MemoryDriver()
|
|
200
|
+
elif name == "sqlite":
|
|
201
|
+
from baqueue.drivers.sqlite_driver import SqliteDriver
|
|
202
|
+
path = config.driver.url or ".baqueue.db"
|
|
203
|
+
driver = SqliteDriver(path=path, **config.driver.options)
|
|
204
|
+
elif name == "redis":
|
|
205
|
+
try:
|
|
206
|
+
from baqueue.drivers.redis_driver import RedisDriver
|
|
207
|
+
except ImportError:
|
|
208
|
+
raise ImportError(
|
|
209
|
+
"Redis driver requires the 'redis' package.\n"
|
|
210
|
+
"Install it with: pip install baqueue[redis]"
|
|
211
|
+
) from None
|
|
212
|
+
driver = RedisDriver(config.driver.url, prefix=config.prefix, **config.driver.options)
|
|
213
|
+
elif name == "postgres":
|
|
214
|
+
try:
|
|
215
|
+
from baqueue.drivers.postgres_driver import PostgresDriver
|
|
216
|
+
except ImportError:
|
|
217
|
+
raise ImportError(
|
|
218
|
+
"PostgreSQL driver requires the 'asyncpg' package.\n"
|
|
219
|
+
"Install it with: pip install baqueue[postgres]"
|
|
220
|
+
) from None
|
|
221
|
+
driver = PostgresDriver(config.driver.url, prefix=config.prefix, **config.driver.options)
|
|
222
|
+
else:
|
|
223
|
+
raise ValueError(f"Unknown driver: {name}")
|
|
224
|
+
driver.auto_cleanup_on_disk_full = config.auto_cleanup_on_disk_full
|
|
225
|
+
return driver
|
baqueue/retry.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Backoff / retry strategies for failed jobs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import random
|
|
6
|
+
from enum import Enum
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class BackoffStrategy(str, Enum):
|
|
10
|
+
FIXED = "fixed"
|
|
11
|
+
LINEAR = "linear"
|
|
12
|
+
EXPONENTIAL = "exponential"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def compute_delay(
|
|
16
|
+
backoff: str | list[int],
|
|
17
|
+
attempt: int,
|
|
18
|
+
base_delay: float = 5.0,
|
|
19
|
+
max_delay: float = 3600.0,
|
|
20
|
+
) -> float:
|
|
21
|
+
"""Compute the retry delay in seconds for a given attempt.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
backoff: Strategy name ("fixed", "linear", "exponential") or
|
|
25
|
+
a list of explicit delays in seconds (e.g. [10, 60, 300]).
|
|
26
|
+
attempt: Current attempt number (1-based).
|
|
27
|
+
base_delay: Base delay in seconds for computed strategies.
|
|
28
|
+
max_delay: Maximum delay cap.
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
Delay in seconds before the next retry.
|
|
32
|
+
"""
|
|
33
|
+
if isinstance(backoff, list):
|
|
34
|
+
idx = min(attempt - 1, len(backoff) - 1)
|
|
35
|
+
return float(backoff[idx])
|
|
36
|
+
|
|
37
|
+
strategy = backoff.lower()
|
|
38
|
+
|
|
39
|
+
if strategy == BackoffStrategy.FIXED:
|
|
40
|
+
return base_delay
|
|
41
|
+
|
|
42
|
+
if strategy == BackoffStrategy.LINEAR:
|
|
43
|
+
return min(base_delay * attempt, max_delay)
|
|
44
|
+
|
|
45
|
+
if strategy == BackoffStrategy.EXPONENTIAL:
|
|
46
|
+
delay = base_delay * (2 ** (attempt - 1))
|
|
47
|
+
jitter = random.uniform(0, delay * 0.1)
|
|
48
|
+
return min(delay + jitter, max_delay)
|
|
49
|
+
|
|
50
|
+
return base_delay
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def should_retry(attempt: int, max_attempts: int) -> bool:
|
|
54
|
+
"""Whether the job should be retried."""
|
|
55
|
+
return attempt < max_attempts
|
baqueue/scheduler.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Scheduler - runs jobs on cron expressions or fixed intervals."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
|
|
9
|
+
from croniter import croniter
|
|
10
|
+
|
|
11
|
+
from baqueue.config import ScheduleEntry
|
|
12
|
+
from baqueue.drivers.base import BaseDriver
|
|
13
|
+
from baqueue.events import EventBus
|
|
14
|
+
from baqueue.serializer import JobPayload, resolve_job_class
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger("baqueue.scheduler")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Scheduler:
|
|
20
|
+
"""Runs scheduled jobs based on cron expressions or intervals.
|
|
21
|
+
|
|
22
|
+
Usage::
|
|
23
|
+
|
|
24
|
+
scheduler = Scheduler(driver, [
|
|
25
|
+
ScheduleEntry(job_class="myapp.jobs.Cleanup", cron="0 * * * *"),
|
|
26
|
+
ScheduleEntry(job_class="myapp.jobs.Heartbeat", every=30),
|
|
27
|
+
])
|
|
28
|
+
await scheduler.start()
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
driver: BaseDriver,
|
|
34
|
+
entries: list[ScheduleEntry] | None = None,
|
|
35
|
+
events: EventBus | None = None,
|
|
36
|
+
):
|
|
37
|
+
self.driver = driver
|
|
38
|
+
self.entries = entries or []
|
|
39
|
+
self.events = events or EventBus.default()
|
|
40
|
+
self._running = False
|
|
41
|
+
self._last_run: dict[str, float] = {}
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def is_running(self) -> bool:
|
|
45
|
+
return self._running
|
|
46
|
+
|
|
47
|
+
def add(self, entry: ScheduleEntry) -> None:
|
|
48
|
+
self.entries.append(entry)
|
|
49
|
+
|
|
50
|
+
async def start(self) -> None:
|
|
51
|
+
self._running = True
|
|
52
|
+
logger.info("Scheduler started with %d entries", len(self.entries))
|
|
53
|
+
|
|
54
|
+
while self._running:
|
|
55
|
+
now = datetime.now(timezone.utc)
|
|
56
|
+
for entry in self.entries:
|
|
57
|
+
key = f"{entry.job_class}:{entry.cron or entry.every}"
|
|
58
|
+
if self._should_run(entry, now, key):
|
|
59
|
+
await self._dispatch(entry)
|
|
60
|
+
self._last_run[key] = now.timestamp()
|
|
61
|
+
await asyncio.sleep(1)
|
|
62
|
+
|
|
63
|
+
def stop(self) -> None:
|
|
64
|
+
self._running = False
|
|
65
|
+
logger.info("Scheduler stopped")
|
|
66
|
+
|
|
67
|
+
def _should_run(self, entry: ScheduleEntry, now: datetime, key: str) -> bool:
|
|
68
|
+
last = self._last_run.get(key, 0)
|
|
69
|
+
|
|
70
|
+
if entry.cron:
|
|
71
|
+
cron = croniter(entry.cron, datetime.fromtimestamp(last, tz=timezone.utc))
|
|
72
|
+
next_run = cron.get_next(datetime)
|
|
73
|
+
if next_run.tzinfo is None:
|
|
74
|
+
next_run = next_run.replace(tzinfo=timezone.utc)
|
|
75
|
+
return now >= next_run
|
|
76
|
+
|
|
77
|
+
if entry.every > 0:
|
|
78
|
+
return (now.timestamp() - last) >= entry.every
|
|
79
|
+
|
|
80
|
+
return False
|
|
81
|
+
|
|
82
|
+
async def _dispatch(self, entry: ScheduleEntry) -> None:
|
|
83
|
+
try:
|
|
84
|
+
cls = resolve_job_class(entry.job_class)
|
|
85
|
+
if hasattr(cls, "build_payload"):
|
|
86
|
+
if isinstance(cls, type):
|
|
87
|
+
payload = cls().build_payload(**entry.payload)
|
|
88
|
+
else:
|
|
89
|
+
payload = cls.build_payload(**entry.payload)
|
|
90
|
+
else:
|
|
91
|
+
payload = JobPayload(
|
|
92
|
+
job_class=entry.job_class,
|
|
93
|
+
data=entry.payload,
|
|
94
|
+
queue=entry.queue,
|
|
95
|
+
)
|
|
96
|
+
payload.queue = entry.queue
|
|
97
|
+
await self.driver.push(payload)
|
|
98
|
+
logger.info("Scheduled job dispatched: %s", entry.job_class)
|
|
99
|
+
self.events.emit_nowait("schedule.dispatched", entry=entry)
|
|
100
|
+
except Exception:
|
|
101
|
+
logger.exception("Failed to dispatch scheduled job: %s", entry.job_class)
|