oban 0.5.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.
- oban/__init__.py +22 -0
- oban/__main__.py +12 -0
- oban/_backoff.py +87 -0
- oban/_config.py +171 -0
- oban/_executor.py +188 -0
- oban/_extensions.py +16 -0
- oban/_leader.py +118 -0
- oban/_lifeline.py +77 -0
- oban/_notifier.py +324 -0
- oban/_producer.py +334 -0
- oban/_pruner.py +93 -0
- oban/_query.py +409 -0
- oban/_recorded.py +34 -0
- oban/_refresher.py +88 -0
- oban/_scheduler.py +359 -0
- oban/_stager.py +115 -0
- oban/_worker.py +78 -0
- oban/cli.py +436 -0
- oban/decorators.py +218 -0
- oban/job.py +315 -0
- oban/oban.py +1084 -0
- oban/py.typed +0 -0
- oban/queries/__init__.py +0 -0
- oban/queries/ack_job.sql +11 -0
- oban/queries/all_jobs.sql +25 -0
- oban/queries/cancel_many_jobs.sql +37 -0
- oban/queries/cleanup_expired_leaders.sql +4 -0
- oban/queries/cleanup_expired_producers.sql +2 -0
- oban/queries/delete_many_jobs.sql +5 -0
- oban/queries/delete_producer.sql +2 -0
- oban/queries/elect_leader.sql +10 -0
- oban/queries/fetch_jobs.sql +44 -0
- oban/queries/get_job.sql +23 -0
- oban/queries/insert_job.sql +28 -0
- oban/queries/insert_producer.sql +2 -0
- oban/queries/install.sql +113 -0
- oban/queries/prune_jobs.sql +18 -0
- oban/queries/reelect_leader.sql +12 -0
- oban/queries/refresh_producers.sql +3 -0
- oban/queries/rescue_jobs.sql +18 -0
- oban/queries/reset.sql +5 -0
- oban/queries/resign_leader.sql +4 -0
- oban/queries/retry_many_jobs.sql +13 -0
- oban/queries/stage_jobs.sql +34 -0
- oban/queries/uninstall.sql +4 -0
- oban/queries/update_job.sql +54 -0
- oban/queries/update_producer.sql +3 -0
- oban/queries/verify_structure.sql +9 -0
- oban/schema.py +115 -0
- oban/telemetry/__init__.py +10 -0
- oban/telemetry/core.py +170 -0
- oban/telemetry/logger.py +147 -0
- oban/testing.py +439 -0
- oban-0.5.0.dist-info/METADATA +290 -0
- oban-0.5.0.dist-info/RECORD +59 -0
- oban-0.5.0.dist-info/WHEEL +5 -0
- oban-0.5.0.dist-info/entry_points.txt +2 -0
- oban-0.5.0.dist-info/licenses/LICENSE.txt +201 -0
- oban-0.5.0.dist-info/top_level.txt +1 -0
oban/_scheduler.py
ADDED
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import re
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from datetime import datetime, timedelta, timezone, tzinfo
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
from zoneinfo import ZoneInfo
|
|
9
|
+
|
|
10
|
+
from . import telemetry
|
|
11
|
+
from ._worker import worker_name
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from .job import Job
|
|
15
|
+
from ._leader import Leader
|
|
16
|
+
from ._notifier import Notifier
|
|
17
|
+
from ._query import Query
|
|
18
|
+
|
|
19
|
+
DOW_ALIASES = {
|
|
20
|
+
"MON": "1",
|
|
21
|
+
"TUE": "2",
|
|
22
|
+
"WED": "3",
|
|
23
|
+
"THU": "4",
|
|
24
|
+
"FRI": "5",
|
|
25
|
+
"SAT": "6",
|
|
26
|
+
"SUN": "7",
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
MON_ALIASES = {
|
|
30
|
+
"JAN": "1",
|
|
31
|
+
"FEB": "2",
|
|
32
|
+
"MAR": "3",
|
|
33
|
+
"APR": "4",
|
|
34
|
+
"MAY": "5",
|
|
35
|
+
"JUN": "6",
|
|
36
|
+
"JUL": "7",
|
|
37
|
+
"AUG": "8",
|
|
38
|
+
"SEP": "9",
|
|
39
|
+
"OCT": "10",
|
|
40
|
+
"NOV": "11",
|
|
41
|
+
"DEC": "12",
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
MIN_SET = frozenset(range(0, 60))
|
|
45
|
+
HRS_SET = frozenset(range(0, 24))
|
|
46
|
+
DAY_SET = frozenset(range(1, 32))
|
|
47
|
+
MON_SET = frozenset(range(1, 13))
|
|
48
|
+
DOW_SET = frozenset(range(1, 8))
|
|
49
|
+
|
|
50
|
+
NICKNAMES = {
|
|
51
|
+
"@annually": "0 0 1 1 *",
|
|
52
|
+
"@yearly": "0 0 1 1 *",
|
|
53
|
+
"@monthly": "0 0 1 * *",
|
|
54
|
+
"@weekly": "0 0 * * 0",
|
|
55
|
+
"@midnight": "0 0 * * *",
|
|
56
|
+
"@daily": "0 0 * * *",
|
|
57
|
+
"@hourly": "0 * * * *",
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(slots=True, frozen=True)
|
|
62
|
+
class ScheduledEntry:
|
|
63
|
+
expression: Expression
|
|
64
|
+
worker_cls: type
|
|
65
|
+
timezone: tzinfo | None = None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
_scheduled_entries: list[ScheduledEntry] = []
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def scheduled_entries() -> list[ScheduledEntry]:
|
|
72
|
+
"""Return a copy of all registered scheduled entries.
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
A list of all scheduled entries registered via @worker or @job decorators
|
|
76
|
+
with cron expressions.
|
|
77
|
+
|
|
78
|
+
Example:
|
|
79
|
+
>>> from oban._scheduler import scheduled_entries
|
|
80
|
+
>>> entries = scheduled_entries()
|
|
81
|
+
>>> for entry in entries:
|
|
82
|
+
... print(f"{entry.expression.input} -> {entry.worker_cls.__name__}")
|
|
83
|
+
"""
|
|
84
|
+
return _scheduled_entries.copy()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def register_scheduled(cron: str | dict, worker_cls: type) -> None:
|
|
88
|
+
"""Register a worker or job for periodic execution.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
cron: Either a cron expression string (e.g., "0 0 * * *" or "@daily")
|
|
92
|
+
or a dict with "expr" and optional "timezone" keys.
|
|
93
|
+
The "timezone" must be a string timezone name (e.g., "America/Chicago").
|
|
94
|
+
worker_cls: The worker class to execute
|
|
95
|
+
|
|
96
|
+
Raises:
|
|
97
|
+
ValueError: If the cron expression is invalid
|
|
98
|
+
|
|
99
|
+
Examples:
|
|
100
|
+
>>> register_scheduled("0 0 * * *", MyWorker)
|
|
101
|
+
>>> register_scheduled({"expr": "@daily", "timezone": "America/Chicago"}, MyWorker)
|
|
102
|
+
"""
|
|
103
|
+
if isinstance(cron, str):
|
|
104
|
+
expression = cron
|
|
105
|
+
tz = None
|
|
106
|
+
else:
|
|
107
|
+
expression = cron["expr"]
|
|
108
|
+
tz_name = cron.get("timezone")
|
|
109
|
+
tz = ZoneInfo(tz_name) if tz_name else None
|
|
110
|
+
|
|
111
|
+
parsed = Expression.parse(expression)
|
|
112
|
+
entry = ScheduledEntry(expression=parsed, worker_cls=worker_cls, timezone=tz)
|
|
113
|
+
|
|
114
|
+
_scheduled_entries.append(entry)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@dataclass(slots=True, frozen=True)
|
|
118
|
+
class Expression:
|
|
119
|
+
input: str
|
|
120
|
+
minutes: set
|
|
121
|
+
hours: set
|
|
122
|
+
days: set
|
|
123
|
+
months: set
|
|
124
|
+
weekdays: set
|
|
125
|
+
|
|
126
|
+
@staticmethod
|
|
127
|
+
def _replace_aliases(expression: str, aliases: dict[str, str]) -> str:
|
|
128
|
+
for name, value in aliases.items():
|
|
129
|
+
expression = expression.replace(name, value)
|
|
130
|
+
|
|
131
|
+
return expression
|
|
132
|
+
|
|
133
|
+
@staticmethod
|
|
134
|
+
def _parse_literal(part: str) -> set[int]:
|
|
135
|
+
return {int(part)}
|
|
136
|
+
|
|
137
|
+
@staticmethod
|
|
138
|
+
def _parse_step(part: str, allowed: set[int]) -> set[int]:
|
|
139
|
+
step = int(part.replace("*/", ""))
|
|
140
|
+
|
|
141
|
+
return set(range(min(allowed), max(allowed) + 1, step))
|
|
142
|
+
|
|
143
|
+
@staticmethod
|
|
144
|
+
def _parse_range(part: str, allowed: set[int]) -> set[int]:
|
|
145
|
+
match part.split("-"):
|
|
146
|
+
case [rmin]:
|
|
147
|
+
rmin = int(rmin)
|
|
148
|
+
return set(range(rmin, max(allowed) + 1))
|
|
149
|
+
case [rmin, rmax]:
|
|
150
|
+
rmin = int(rmin)
|
|
151
|
+
rmax = int(rmax)
|
|
152
|
+
|
|
153
|
+
if rmin > rmax:
|
|
154
|
+
raise ValueError(
|
|
155
|
+
f"min of range ({rmin}) must be less than or equal to max"
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
return set(range(rmin, rmax + 1))
|
|
159
|
+
case _:
|
|
160
|
+
raise ValueError(f"unrecognized range: {part}")
|
|
161
|
+
|
|
162
|
+
@staticmethod
|
|
163
|
+
def _parse_range_step(part: str, allowed: set[int]) -> set[int]:
|
|
164
|
+
range_part, step_value = part.split("/")
|
|
165
|
+
range_set = Expression._parse_range(range_part, allowed)
|
|
166
|
+
step_part = f"*/{step_value}"
|
|
167
|
+
|
|
168
|
+
return Expression._parse_step(step_part, range_set)
|
|
169
|
+
|
|
170
|
+
@staticmethod
|
|
171
|
+
def _parse_part(part: str, allowed: set[int]) -> set[int]:
|
|
172
|
+
if part == "*":
|
|
173
|
+
return allowed
|
|
174
|
+
elif re.match(r"^\d+$", part):
|
|
175
|
+
return Expression._parse_literal(part)
|
|
176
|
+
elif re.match(r"^\*\/[1-9]\d?$", part):
|
|
177
|
+
return Expression._parse_step(part, allowed)
|
|
178
|
+
elif re.match(r"^\d+(\-\d+)?\/[1-9]\d?$", part):
|
|
179
|
+
return Expression._parse_range_step(part, allowed)
|
|
180
|
+
elif re.match(r"^\d+\-\d+$", part):
|
|
181
|
+
return Expression._parse_range(part, allowed)
|
|
182
|
+
else:
|
|
183
|
+
raise ValueError(f"unrecognized expression: {part}")
|
|
184
|
+
|
|
185
|
+
@staticmethod
|
|
186
|
+
def _parse_field(field: str, allowed: set[int]) -> set[int]:
|
|
187
|
+
parsed = set()
|
|
188
|
+
|
|
189
|
+
for part in re.split(r"\s*,\s*", field):
|
|
190
|
+
parsed.update(Expression._parse_part(part, allowed))
|
|
191
|
+
|
|
192
|
+
if not parsed.issubset(allowed):
|
|
193
|
+
raise ValueError(f"field {field} is out of range: {allowed}")
|
|
194
|
+
|
|
195
|
+
return parsed
|
|
196
|
+
|
|
197
|
+
@classmethod
|
|
198
|
+
def parse(cls, expression: str) -> Expression:
|
|
199
|
+
"""Parse a crontab expression into an Expression object.
|
|
200
|
+
|
|
201
|
+
Supports standard cron syntax with five fields: minute, hour, day, month,
|
|
202
|
+
weekday. Each field can contain:
|
|
203
|
+
|
|
204
|
+
- Literal values: "5"
|
|
205
|
+
- Wildcards: "*"
|
|
206
|
+
- Ranges: "1-5"
|
|
207
|
+
- Steps: "*/10" or "1-30/5"
|
|
208
|
+
- Lists: "1,2,5,10"
|
|
209
|
+
|
|
210
|
+
Also supports nickname expressions:
|
|
211
|
+
|
|
212
|
+
- @hourly, @daily, @midnight, @weekly, @monthly, @yearly, @annually
|
|
213
|
+
|
|
214
|
+
Month and weekday names are case-sensitive and must be uppercase:
|
|
215
|
+
|
|
216
|
+
- Months: JAN, FEB, MAR, APR, MAY, JUN, JUL, AUG, SEP, OCT, NOV, DEC
|
|
217
|
+
- Weekdays: MON, TUE, WED, THU, FRI, SAT, SUN
|
|
218
|
+
|
|
219
|
+
Args:
|
|
220
|
+
expression: A cron expression string (e.g., "0 0 * * *" or "@daily")
|
|
221
|
+
|
|
222
|
+
Returns:
|
|
223
|
+
An Expression object with parsed minute, hour, day, month, and weekday sets
|
|
224
|
+
|
|
225
|
+
Raises:
|
|
226
|
+
ValueError: If the expression format is invalid or values are out of range
|
|
227
|
+
|
|
228
|
+
Examples:
|
|
229
|
+
>>> Expression.parse("0 0 * * *") # Daily at midnight
|
|
230
|
+
>>> Expression.parse("*/15 * * * *") # Every 15 minutes
|
|
231
|
+
>>> Expression.parse("0 9-17 * * MON-FRI") # 9am-5pm on weekdays
|
|
232
|
+
>>> Expression.parse("@hourly") # Every hour
|
|
233
|
+
"""
|
|
234
|
+
normalized = NICKNAMES.get(expression, expression)
|
|
235
|
+
|
|
236
|
+
match re.split(r"\s+", normalized):
|
|
237
|
+
case [min_part, hrs_part, day_part, mon_part, dow_part]:
|
|
238
|
+
mon_part = cls._replace_aliases(mon_part, MON_ALIASES)
|
|
239
|
+
dow_part = cls._replace_aliases(dow_part, DOW_ALIASES)
|
|
240
|
+
|
|
241
|
+
return cls(
|
|
242
|
+
input=expression,
|
|
243
|
+
minutes=cls._parse_field(min_part, MIN_SET),
|
|
244
|
+
hours=cls._parse_field(hrs_part, HRS_SET),
|
|
245
|
+
days=cls._parse_field(day_part, DAY_SET),
|
|
246
|
+
months=cls._parse_field(mon_part, MON_SET),
|
|
247
|
+
weekdays=cls._parse_field(dow_part, DOW_SET),
|
|
248
|
+
)
|
|
249
|
+
case _:
|
|
250
|
+
raise ValueError(f"incorrect number of fields: {expression}")
|
|
251
|
+
|
|
252
|
+
def is_now(self, time: None | datetime = None) -> bool:
|
|
253
|
+
"""Check whether a cron expression matches the current date and time."""
|
|
254
|
+
time = time or datetime.now(timezone.utc)
|
|
255
|
+
|
|
256
|
+
return (
|
|
257
|
+
time.isoweekday() in self.weekdays
|
|
258
|
+
and time.month in self.months
|
|
259
|
+
and time.day in self.days
|
|
260
|
+
and time.hour in self.hours
|
|
261
|
+
and time.minute in self.minutes
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
class Scheduler:
|
|
266
|
+
"""Manages periodic job scheduling based on cron expressions.
|
|
267
|
+
|
|
268
|
+
This class is managed internally by Oban and shouldn't be constructed directly.
|
|
269
|
+
Instead, configure scheduling via the Oban constructor:
|
|
270
|
+
|
|
271
|
+
>>> async with Oban(
|
|
272
|
+
... conn=conn,
|
|
273
|
+
... queues={"default": 10},
|
|
274
|
+
... scheduler={"timezone": "America/Chicago"}
|
|
275
|
+
... ) as oban:
|
|
276
|
+
... # Scheduler runs automatically in the background
|
|
277
|
+
"""
|
|
278
|
+
|
|
279
|
+
def __init__(
|
|
280
|
+
self,
|
|
281
|
+
*,
|
|
282
|
+
leader: Leader,
|
|
283
|
+
notifier: Notifier,
|
|
284
|
+
query: Query,
|
|
285
|
+
timezone: str = "UTC",
|
|
286
|
+
) -> None:
|
|
287
|
+
self._leader = leader
|
|
288
|
+
self._notifier = notifier
|
|
289
|
+
self._query = query
|
|
290
|
+
self._timezone = ZoneInfo(timezone)
|
|
291
|
+
|
|
292
|
+
self._loop_task = None
|
|
293
|
+
|
|
294
|
+
async def start(self) -> None:
|
|
295
|
+
self._loop_task = asyncio.create_task(self._loop(), name="oban-cron")
|
|
296
|
+
|
|
297
|
+
async def stop(self) -> None:
|
|
298
|
+
if not self._loop_task:
|
|
299
|
+
return
|
|
300
|
+
|
|
301
|
+
self._loop_task.cancel()
|
|
302
|
+
|
|
303
|
+
try:
|
|
304
|
+
await self._loop_task
|
|
305
|
+
except asyncio.CancelledError:
|
|
306
|
+
pass
|
|
307
|
+
|
|
308
|
+
async def _loop(self) -> None:
|
|
309
|
+
while True:
|
|
310
|
+
try:
|
|
311
|
+
await asyncio.sleep(self._time_to_next_minute())
|
|
312
|
+
|
|
313
|
+
if self._leader.is_leader:
|
|
314
|
+
await self._evaluate()
|
|
315
|
+
except asyncio.CancelledError:
|
|
316
|
+
break
|
|
317
|
+
|
|
318
|
+
async def _evaluate(self) -> None:
|
|
319
|
+
with telemetry.span("oban.scheduler.evaluate", {}) as context:
|
|
320
|
+
jobs = [
|
|
321
|
+
self._build_job(entry)
|
|
322
|
+
for entry in _scheduled_entries
|
|
323
|
+
if self._is_now(entry)
|
|
324
|
+
]
|
|
325
|
+
|
|
326
|
+
context.add({"enqueued_count": len(jobs)})
|
|
327
|
+
|
|
328
|
+
if jobs:
|
|
329
|
+
result = await self._query.insert_jobs(jobs)
|
|
330
|
+
queues = {job.queue for job in result}
|
|
331
|
+
|
|
332
|
+
await self._notifier.notify(
|
|
333
|
+
"insert", [{"queue": queue} for queue in queues]
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
def _is_now(self, entry: ScheduledEntry) -> bool:
|
|
337
|
+
now = datetime.now(entry.timezone or self._timezone)
|
|
338
|
+
|
|
339
|
+
return entry.expression.is_now(now)
|
|
340
|
+
|
|
341
|
+
def _build_job(self, entry: ScheduledEntry) -> Job:
|
|
342
|
+
work_name = worker_name(entry.worker_cls)
|
|
343
|
+
cron_name = str(hash((entry.expression.input, work_name)))
|
|
344
|
+
|
|
345
|
+
job = entry.worker_cls.new() # type: ignore[attr-defined]
|
|
346
|
+
|
|
347
|
+
job.meta = {
|
|
348
|
+
"cron": True,
|
|
349
|
+
"cron_expr": entry.expression.input,
|
|
350
|
+
"cron_name": cron_name,
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
return job
|
|
354
|
+
|
|
355
|
+
def _time_to_next_minute(self, time: None | datetime = None) -> float:
|
|
356
|
+
time = time or datetime.now(timezone.utc)
|
|
357
|
+
next_minute = (time + timedelta(minutes=1)).replace(second=0, microsecond=0)
|
|
358
|
+
|
|
359
|
+
return (next_minute - time).total_seconds()
|
oban/_stager.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from . import telemetry
|
|
8
|
+
from ._extensions import use_ext
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from ._leader import Leader
|
|
14
|
+
from ._notifier import Notifier
|
|
15
|
+
from ._producer import Producer
|
|
16
|
+
from ._query import Query
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Stager:
|
|
20
|
+
"""Manages moving jobs to the 'available' state and notifying queues.
|
|
21
|
+
|
|
22
|
+
This class is managed internally by Oban and shouldn't be constructed directly.
|
|
23
|
+
Instead, configure staging via the Oban constructor:
|
|
24
|
+
|
|
25
|
+
>>> async with Oban(
|
|
26
|
+
... conn=conn,
|
|
27
|
+
... queues={"default": 10},
|
|
28
|
+
... stager={"interval": 1.0, "limit": 20_000}
|
|
29
|
+
... ) as oban:
|
|
30
|
+
... # Stager runs automatically in the background
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
*,
|
|
36
|
+
query: Query,
|
|
37
|
+
notifier: Notifier,
|
|
38
|
+
producers: dict[str, Producer],
|
|
39
|
+
leader: Leader,
|
|
40
|
+
interval: float = 1.0,
|
|
41
|
+
limit: int = 20_000,
|
|
42
|
+
) -> None:
|
|
43
|
+
self._query = query
|
|
44
|
+
self._notifier = notifier
|
|
45
|
+
self._producers = producers
|
|
46
|
+
self._leader = leader
|
|
47
|
+
self._interval = interval
|
|
48
|
+
self._limit = limit
|
|
49
|
+
|
|
50
|
+
self._loop_task = None
|
|
51
|
+
self._listen_token = None
|
|
52
|
+
|
|
53
|
+
self._validate(interval=interval, limit=limit)
|
|
54
|
+
|
|
55
|
+
@staticmethod
|
|
56
|
+
def _validate(*, interval: float, limit: int) -> None:
|
|
57
|
+
if not isinstance(interval, (int, float)):
|
|
58
|
+
raise TypeError(f"interval must be a number, got {interval}")
|
|
59
|
+
if interval <= 0:
|
|
60
|
+
raise ValueError(f"interval must be positive, got {interval}")
|
|
61
|
+
|
|
62
|
+
if not isinstance(limit, int):
|
|
63
|
+
raise TypeError(f"limit must be an integer, got {limit}")
|
|
64
|
+
if limit <= 0:
|
|
65
|
+
raise ValueError(f"limit must be positive, got {limit}")
|
|
66
|
+
|
|
67
|
+
async def start(self) -> None:
|
|
68
|
+
self._listen_token = await self._notifier.listen(
|
|
69
|
+
"insert", self._on_notification, wait=False
|
|
70
|
+
)
|
|
71
|
+
self._loop_task = asyncio.create_task(self._loop(), name="oban-stager")
|
|
72
|
+
|
|
73
|
+
async def stop(self) -> None:
|
|
74
|
+
if self._listen_token:
|
|
75
|
+
await self._notifier.unlisten(self._listen_token)
|
|
76
|
+
|
|
77
|
+
if self._loop_task:
|
|
78
|
+
self._loop_task.cancel()
|
|
79
|
+
try:
|
|
80
|
+
await self._loop_task
|
|
81
|
+
except asyncio.CancelledError:
|
|
82
|
+
pass
|
|
83
|
+
|
|
84
|
+
async def _loop(self) -> None:
|
|
85
|
+
while True:
|
|
86
|
+
try:
|
|
87
|
+
await self._stage()
|
|
88
|
+
except asyncio.CancelledError:
|
|
89
|
+
break
|
|
90
|
+
except Exception as error:
|
|
91
|
+
logger.warning("Stager failed to stage jobs: %s", error, exc_info=True)
|
|
92
|
+
|
|
93
|
+
await asyncio.sleep(self._interval)
|
|
94
|
+
|
|
95
|
+
async def _on_notification(self, channel: str, payload: dict) -> None:
|
|
96
|
+
queue = payload["queue"]
|
|
97
|
+
|
|
98
|
+
if queue in self._producers:
|
|
99
|
+
self._producers[queue].notify()
|
|
100
|
+
|
|
101
|
+
async def _noop(self, _query) -> None:
|
|
102
|
+
pass
|
|
103
|
+
|
|
104
|
+
async def _stage(self) -> None:
|
|
105
|
+
await use_ext("stager.before_stage", self._noop, self._query)
|
|
106
|
+
|
|
107
|
+
with telemetry.span("oban.stager.stage", {}) as context:
|
|
108
|
+
queues = list(self._producers.keys())
|
|
109
|
+
|
|
110
|
+
(staged, active) = await self._query.stage_jobs(self._limit, queues)
|
|
111
|
+
|
|
112
|
+
context.add({"staged_count": staged, "available_queues": active})
|
|
113
|
+
|
|
114
|
+
for queue in active:
|
|
115
|
+
self._producers[queue].notify()
|
oban/_worker.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import importlib
|
|
2
|
+
|
|
3
|
+
_registry: dict[str, type] = {}
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class WorkerResolutionError(Exception):
|
|
7
|
+
"""Raised when a worker class cannot be resolved from a path string.
|
|
8
|
+
|
|
9
|
+
This error occurs when the worker resolution process fails due to:
|
|
10
|
+
|
|
11
|
+
- Invalid path format
|
|
12
|
+
- Module not found or import errors
|
|
13
|
+
- Class not found in the module
|
|
14
|
+
- Resolved attribute is not a class
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def worker_name(cls: type) -> str:
|
|
21
|
+
"""Generate the fully qualified name for a worker class."""
|
|
22
|
+
return f"{cls.__module__}.{cls.__qualname__}"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def register_worker(cls) -> None:
|
|
26
|
+
"""Register a worker class for usage later"""
|
|
27
|
+
key = worker_name(cls)
|
|
28
|
+
|
|
29
|
+
_registry[key] = cls
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def resolve_worker(path: str) -> type:
|
|
33
|
+
"""Resolve a worker class by its path.
|
|
34
|
+
|
|
35
|
+
Loads worker classes from the local registry, falling back to importing
|
|
36
|
+
the module.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
path: Fully qualified class path (e.g., "myapp.workers.EmailWorker")
|
|
40
|
+
|
|
41
|
+
Returns:
|
|
42
|
+
The resolved worker class
|
|
43
|
+
|
|
44
|
+
Raises:
|
|
45
|
+
WorkerResolutionError: If the worker cannot be resolved
|
|
46
|
+
"""
|
|
47
|
+
if path in _registry:
|
|
48
|
+
return _registry[path]
|
|
49
|
+
|
|
50
|
+
parts = path.split(".")
|
|
51
|
+
mod_name, cls_name = ".".join(parts[:-1]), parts[-1]
|
|
52
|
+
|
|
53
|
+
try:
|
|
54
|
+
mod = importlib.import_module(mod_name)
|
|
55
|
+
except ModuleNotFoundError as error:
|
|
56
|
+
raise WorkerResolutionError(
|
|
57
|
+
f"Module '{mod_name}' not found for worker '{path}'"
|
|
58
|
+
) from error
|
|
59
|
+
except ImportError as error:
|
|
60
|
+
raise WorkerResolutionError(
|
|
61
|
+
f"Failed to import module '{mod_name}' for worker '{path}'"
|
|
62
|
+
) from error
|
|
63
|
+
|
|
64
|
+
try:
|
|
65
|
+
cls = getattr(mod, cls_name)
|
|
66
|
+
except AttributeError as error:
|
|
67
|
+
raise WorkerResolutionError(
|
|
68
|
+
f"Class '{cls_name}' not found in module '{mod_name}'"
|
|
69
|
+
) from error
|
|
70
|
+
|
|
71
|
+
if not isinstance(cls, type):
|
|
72
|
+
raise WorkerResolutionError(
|
|
73
|
+
f"'{path}' resolved to {type(cls).__name__}, expected a class"
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
register_worker(cls)
|
|
77
|
+
|
|
78
|
+
return cls
|