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/job.py ADDED
@@ -0,0 +1,315 @@
1
+ """Job dataclass representing a unit of work to be processed.
2
+
3
+ Jobs hold all the information needed to execute a task: the worker to run, arguments
4
+ to pass, scheduling options, and metadata for tracking execution state.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ from dataclasses import dataclass, field, replace
11
+ from datetime import datetime, timedelta, timezone
12
+ from enum import StrEnum
13
+ from typing import Any, TypeVar
14
+
15
+ import orjson
16
+
17
+ from ._extensions import use_ext
18
+ from ._recorded import encode_recorded
19
+
20
+
21
+ class JobState(StrEnum):
22
+ """Lifecycle state of a job.
23
+
24
+ - AVAILABLE: Ready to be executed
25
+ - CANCELLED: Explicitly cancelled
26
+ - COMPLETED: Successfully finished
27
+ - DISCARDED: Exceeded max attempts
28
+ - EXECUTING: Currently running
29
+ - RETRYABLE: Failed but will be retried
30
+ - SCHEDULED: Scheduled to run in the future
31
+ - SUSPENDED: Not currently runnable
32
+ """
33
+
34
+ AVAILABLE = "available"
35
+ CANCELLED = "cancelled"
36
+ COMPLETED = "completed"
37
+ DISCARDED = "discarded"
38
+ EXECUTING = "executing"
39
+ RETRYABLE = "retryable"
40
+ SCHEDULED = "scheduled"
41
+ SUSPENDED = "suspended"
42
+
43
+
44
+ T = TypeVar("T")
45
+
46
+
47
+ @dataclass(frozen=True, slots=True)
48
+ class Snooze:
49
+ """Reschedule a job to run again after a delay.
50
+
51
+ Return this from a worker's process method to put the job back in the queue
52
+ with a delayed scheduled_at time.
53
+
54
+ Example:
55
+ >>> async def process(self, job):
56
+ ... if not ready_to_process():
57
+ ... return Snooze(60) # Try again in 60 seconds
58
+ """
59
+
60
+ seconds: int
61
+
62
+
63
+ @dataclass(frozen=True, slots=True)
64
+ class Cancel:
65
+ """Cancel a job and stop processing.
66
+
67
+ Return this from a worker's process method to mark the job as cancelled.
68
+ The reason is stored in the job's errors list.
69
+
70
+ Example:
71
+ >>> async def process(self, job):
72
+ ... if job.cancelled():
73
+ ... return Cancel("Job was cancelled by user")
74
+ """
75
+
76
+ reason: str
77
+
78
+
79
+ RECORDED_LIMIT = 64_000_000 # 64MB default limit
80
+
81
+
82
+ @dataclass(slots=True)
83
+ class Record:
84
+ """Record a value to be stored with the completed job.
85
+
86
+ Return this from a worker's process method to store a value in the job's
87
+ meta field. The value is encoded using Erlang term format for compatibility
88
+ with Oban Pro.
89
+
90
+ Args:
91
+ value: The value to record. Must be serializable by erlpack.
92
+ limit: Maximum size in bytes for the encoded value. Defaults to 64MB.
93
+
94
+ Raises:
95
+ ValueError: If the encoded value exceeds the size limit.
96
+
97
+ Example:
98
+ >>> async def process(self, job):
99
+ ... result = await compute_something()
100
+ ... return Record(result)
101
+
102
+ >>> # With custom size limit
103
+ >>> return Record(large_result, limit=32_000_000)
104
+ """
105
+
106
+ value: Any
107
+ limit: int = RECORDED_LIMIT
108
+ encoded: str = field(init=False, repr=False)
109
+
110
+ def __post_init__(self):
111
+ encoded = encode_recorded(self.value)
112
+
113
+ if len(encoded) > self.limit:
114
+ raise ValueError(
115
+ f"recorded value is {len(encoded)} bytes, exceeds limit of {self.limit}"
116
+ )
117
+
118
+ setattr(self, "encoded", encoded)
119
+
120
+
121
+ type Result[T] = Cancel | Snooze | Record | T | None
122
+
123
+
124
+ TIMESTAMP_FIELDS = [
125
+ "inserted_at",
126
+ "attempted_at",
127
+ "cancelled_at",
128
+ "completed_at",
129
+ "discarded_at",
130
+ "scheduled_at",
131
+ ]
132
+
133
+
134
+ @dataclass(slots=True)
135
+ class Job:
136
+ worker: str
137
+ id: int | None = None
138
+ state: JobState = JobState.AVAILABLE
139
+ queue: str = "default"
140
+ attempt: int = 0
141
+ max_attempts: int = 20
142
+ priority: int = 0
143
+ args: dict[str, Any] = field(default_factory=dict)
144
+ meta: dict[str, Any] = field(default_factory=dict)
145
+ errors: list[str] = field(default_factory=list)
146
+ tags: list[str] = field(default_factory=list)
147
+ attempted_by: list[str] = field(default_factory=list)
148
+ inserted_at: datetime | None = None
149
+ attempted_at: datetime | None = None
150
+ cancelled_at: datetime | None = None
151
+ completed_at: datetime | None = None
152
+ discarded_at: datetime | None = None
153
+ scheduled_at: datetime | None = None
154
+
155
+ # Virtual fields for runtime state (not persisted to database)
156
+ extra: dict[str, Any] = field(default_factory=dict, repr=False)
157
+ _cancellation: asyncio.Event | None = field(default=None, init=False, repr=False)
158
+
159
+ @staticmethod
160
+ def _handle_schedule_in(params: dict[str, Any]) -> None:
161
+ if "schedule_in" in params:
162
+ schedule_in = params.pop("schedule_in")
163
+
164
+ if isinstance(schedule_in, (int, float)):
165
+ schedule_in = timedelta(seconds=schedule_in)
166
+
167
+ params["scheduled_at"] = datetime.now(timezone.utc) + schedule_in
168
+
169
+ def __post_init__(self):
170
+ # Timestamps returned from the database are naive, which prevents comparison against
171
+ # timezone aware datetime instances.
172
+ for key in TIMESTAMP_FIELDS:
173
+ value = getattr(self, key)
174
+ if value is not None and value.tzinfo is None:
175
+ setattr(self, key, value.replace(tzinfo=timezone.utc))
176
+
177
+ def __str__(self) -> str:
178
+ worker_parts = self.worker.split(".")
179
+ worker_name = worker_parts[-1] if worker_parts else self.worker
180
+
181
+ parts = [
182
+ f"id={self.id}",
183
+ f"worker={worker_name}",
184
+ f"args={orjson.dumps(self.args)}",
185
+ f"queue={self.queue}",
186
+ f"state={self.state}",
187
+ ]
188
+
189
+ return f"Job({', '.join(parts)})"
190
+
191
+ @classmethod
192
+ def new(cls, **params) -> Job:
193
+ """Create a new job with validation and normalization.
194
+
195
+ This is a low-level method for manually constructing jobs. In most cases,
196
+ you should use the `@worker` or `@job` decorators instead, which provide
197
+ a more convenient API via `Worker.new()` and `Worker.enqueue()`.
198
+
199
+ Jobs returned from the database are constructed directly and skip
200
+ validation/normalization.
201
+
202
+ Args:
203
+ **params: Job field values including:
204
+ - worker: Required. Fully qualified worker class path
205
+ - args: Job arguments (default: {})
206
+ - queue: Queue name (default: "default")
207
+ - priority: Priority 0-9 (default: 0)
208
+ - max_attempts: Maximum retry attempts (default: 20)
209
+ - scheduled_at: When to run the job (default: now)
210
+ - schedule_in: Alternative to scheduled_at. Timedelta or seconds from now
211
+ - tags: List of tags for grouping
212
+ - meta: Arbitrary metadata dictionary
213
+
214
+ Returns:
215
+ A validated and normalized Job instance
216
+
217
+ Example:
218
+ Manual job creation (not recommended for typical use):
219
+
220
+ >>> job = Job.new(
221
+ ... worker="myapp.workers.EmailWorker",
222
+ ... args={"to": "user@example.com"},
223
+ ... queue="mailers",
224
+ ... schedule_in=60 # Run in 60 seconds
225
+ ... )
226
+
227
+ Preferred approach using decorators:
228
+
229
+ >>> from oban import worker
230
+ >>>
231
+ >>> @worker(queue="mailers")
232
+ ... class EmailWorker:
233
+ ... async def process(self, job):
234
+ ... pass
235
+ >>>
236
+ >>> job = EmailWorker.new({"to": "user@example.com"}, schedule_in=60)
237
+ """
238
+ cls._handle_schedule_in(params)
239
+
240
+ job = cls(**params)
241
+ job._normalize_tags()
242
+ job._validate()
243
+
244
+ return use_ext("job.after_new", (lambda _job: _job), job)
245
+
246
+ def update(self, changes: dict[str, Any]) -> Job:
247
+ """Update this job with the given changes, applying validation and normalization.
248
+
249
+ This method creates a new Job instance with the changes applied, then validates
250
+ and normalizes the result. It's used internally by Oban's update_job methods.
251
+
252
+ Args:
253
+ changes: Dictionary of field changes. Supports:
254
+ - args: Job arguments
255
+ - max_attempts: Maximum retry attempts
256
+ - meta: Arbitrary metadata dictionary
257
+ - priority: Priority 0-9
258
+ - queue: Queue name
259
+ - scheduled_at: When to run the job
260
+ - schedule_in: Alternative to scheduled_at. Timedelta or seconds from now
261
+ - tags: List of tags for filtering/grouping
262
+ - worker: Fully qualified worker class path
263
+
264
+ Returns:
265
+ A new Job instance with changes applied and validated
266
+
267
+ Example:
268
+ >>> job.update({"priority": 0, "tags": ["urgent"]})
269
+ """
270
+ self._handle_schedule_in(changes)
271
+
272
+ job = replace(self, **changes)
273
+ job._normalize_tags()
274
+ job._validate()
275
+
276
+ return job
277
+
278
+ def cancelled(self) -> bool:
279
+ """Check if cancellation has been requested for this job.
280
+
281
+ Workers can call this method at safe points during execution to check
282
+ if the job should stop processing and return early.
283
+
284
+ Returns:
285
+ True if cancellation has been requested, False otherwise
286
+
287
+ Example:
288
+ >>> async def process(self, job):
289
+ ... for item in large_dataset:
290
+ ... if job.cancelled():
291
+ ... return Cancel("Job was cancelled")
292
+ ... await process_item(item)
293
+ """
294
+ if self._cancellation is None:
295
+ return False
296
+
297
+ return self._cancellation.is_set()
298
+
299
+ def _normalize_tags(self) -> None:
300
+ self.tags = sorted(
301
+ {str(tag).strip().lower() for tag in self.tags if tag and str(tag).strip()}
302
+ )
303
+
304
+ def _validate(self) -> None:
305
+ if not self.queue.strip():
306
+ raise ValueError("queue must not be blank")
307
+
308
+ if not self.worker.strip():
309
+ raise ValueError("worker must not be blank")
310
+
311
+ if self.max_attempts <= 0:
312
+ raise ValueError("max_attempts must be greater than 0")
313
+
314
+ if not (0 <= self.priority <= 9):
315
+ raise ValueError("priority must be between 0 and 9")