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.
Files changed (59) hide show
  1. oban/__init__.py +22 -0
  2. oban/__main__.py +12 -0
  3. oban/_backoff.py +87 -0
  4. oban/_config.py +171 -0
  5. oban/_executor.py +188 -0
  6. oban/_extensions.py +16 -0
  7. oban/_leader.py +118 -0
  8. oban/_lifeline.py +77 -0
  9. oban/_notifier.py +324 -0
  10. oban/_producer.py +334 -0
  11. oban/_pruner.py +93 -0
  12. oban/_query.py +409 -0
  13. oban/_recorded.py +34 -0
  14. oban/_refresher.py +88 -0
  15. oban/_scheduler.py +359 -0
  16. oban/_stager.py +115 -0
  17. oban/_worker.py +78 -0
  18. oban/cli.py +436 -0
  19. oban/decorators.py +218 -0
  20. oban/job.py +315 -0
  21. oban/oban.py +1084 -0
  22. oban/py.typed +0 -0
  23. oban/queries/__init__.py +0 -0
  24. oban/queries/ack_job.sql +11 -0
  25. oban/queries/all_jobs.sql +25 -0
  26. oban/queries/cancel_many_jobs.sql +37 -0
  27. oban/queries/cleanup_expired_leaders.sql +4 -0
  28. oban/queries/cleanup_expired_producers.sql +2 -0
  29. oban/queries/delete_many_jobs.sql +5 -0
  30. oban/queries/delete_producer.sql +2 -0
  31. oban/queries/elect_leader.sql +10 -0
  32. oban/queries/fetch_jobs.sql +44 -0
  33. oban/queries/get_job.sql +23 -0
  34. oban/queries/insert_job.sql +28 -0
  35. oban/queries/insert_producer.sql +2 -0
  36. oban/queries/install.sql +113 -0
  37. oban/queries/prune_jobs.sql +18 -0
  38. oban/queries/reelect_leader.sql +12 -0
  39. oban/queries/refresh_producers.sql +3 -0
  40. oban/queries/rescue_jobs.sql +18 -0
  41. oban/queries/reset.sql +5 -0
  42. oban/queries/resign_leader.sql +4 -0
  43. oban/queries/retry_many_jobs.sql +13 -0
  44. oban/queries/stage_jobs.sql +34 -0
  45. oban/queries/uninstall.sql +4 -0
  46. oban/queries/update_job.sql +54 -0
  47. oban/queries/update_producer.sql +3 -0
  48. oban/queries/verify_structure.sql +9 -0
  49. oban/schema.py +115 -0
  50. oban/telemetry/__init__.py +10 -0
  51. oban/telemetry/core.py +170 -0
  52. oban/telemetry/logger.py +147 -0
  53. oban/testing.py +439 -0
  54. oban-0.5.0.dist-info/METADATA +290 -0
  55. oban-0.5.0.dist-info/RECORD +59 -0
  56. oban-0.5.0.dist-info/WHEEL +5 -0
  57. oban-0.5.0.dist-info/entry_points.txt +2 -0
  58. oban-0.5.0.dist-info/licenses/LICENSE.txt +201 -0
  59. 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