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/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)