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/telemetry/core.py ADDED
@@ -0,0 +1,170 @@
1
+ """
2
+ Lightweight telemetry tooling for agnostic instrumentation.
3
+
4
+ Provides event emission and handler attachment for instrumentation,
5
+ similar to Elixir's `:telemetry` library, but tailored to Oban's needs.
6
+ """
7
+
8
+ import logging
9
+ import time
10
+ import traceback
11
+
12
+ from collections import defaultdict
13
+ from contextlib import contextmanager
14
+ from threading import RLock
15
+ from typing import Any, Callable, List
16
+
17
+ Handler = Callable[[str, dict[str, Any]], None]
18
+ Metadata = dict[str, Any]
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+ _handlers = defaultdict(list)
23
+ _lock = RLock()
24
+
25
+
26
+ class Collector:
27
+ """Collector for adding metadata during span execution.
28
+
29
+ Yielded by span() to allow adding additional metadata that will be
30
+ included in the stop or exception event.
31
+ """
32
+
33
+ def __init__(self):
34
+ self._metadata: dict[str, Any] = {}
35
+
36
+ def add(self, metadata: dict[str, Any]) -> None:
37
+ """Add metadata to be included in the stop or exception event.
38
+
39
+ Args:
40
+ metadata: Dictionary of metadata to merge into the event
41
+ """
42
+ self._metadata.update(metadata)
43
+
44
+ def get_all(self) -> dict[str, Any]:
45
+ return self._metadata.copy()
46
+
47
+
48
+ def attach(id: str, events: List[str], handler: Handler) -> None:
49
+ """Attach a handler function to one or more telemetry events.
50
+
51
+ Args:
52
+ id: Unique identifier for this handler (used for detaching)
53
+ events: List of event names to handle (e.g., ["oban.job.execute.start"])
54
+ handler: Function called with (name, metadata) for each event
55
+
56
+ Example:
57
+ def log_events(name, metadata):
58
+ print(f"{name}: {metadata}")
59
+
60
+ telemetry.attach("my-logger", ["oban.job.execute.stop"], log_events)
61
+ """
62
+ with _lock:
63
+ for name in events:
64
+ _handlers[name].append((id, handler))
65
+
66
+
67
+ def detach(id: str) -> None:
68
+ """Remove all handlers with the given ID.
69
+
70
+ Args:
71
+ id: The handler ID to remove
72
+
73
+ Example:
74
+ telemetry.detach("my-logger")
75
+ """
76
+ with _lock:
77
+ for name in list(_handlers.keys()):
78
+ _handlers[name] = [
79
+ (handler_id, handler)
80
+ for handler_id, handler in _handlers[name]
81
+ if handler_id != id
82
+ ]
83
+
84
+
85
+ def execute(name: str, metadata: Metadata) -> None:
86
+ """Execute all handlers registered for an event.
87
+
88
+ Handlers are called synchronously. Exceptions in handlers are caught
89
+ and logged to prevent breaking instrumented code.
90
+
91
+ Args:
92
+ name: Name of the event (e.g., "oban.job.execute.start")
93
+ metadata: Event metadata (job_id, duration, etc.)
94
+
95
+ Example:
96
+ telemetry.execute("oban.job.execute.start", {"job_id": 123, "queue": "default"})
97
+ """
98
+ with _lock:
99
+ handlers = [handler for (_id, handler) in _handlers.get(name, [])]
100
+
101
+ for handler in handlers:
102
+ try:
103
+ handler(name, metadata.copy())
104
+ except Exception as error:
105
+ logger.exception("Telemetry handler error for event '%s': %s", name, error)
106
+
107
+
108
+ @contextmanager
109
+ def span(prefix: str, start_metadata: Metadata):
110
+ """Context manager that emits start/stop/exception events.
111
+
112
+ Automatically measures duration and emits three possible events:
113
+ - {prefix}.start - When entering the span
114
+ - {prefix}.stop - When exiting normally
115
+ - {prefix}.exception - When an exception is raised
116
+
117
+ Args:
118
+ prefix: Event name prefix (e.g., "oban.job.execute")
119
+ start_metadata: Metadata included in all events
120
+
121
+ Yields:
122
+ Collector: Object for adding additional metadata during execution
123
+
124
+ Example:
125
+ with telemetry.span("oban.job.execute", {"job_id": 123}) as collector:
126
+ result = process_job()
127
+ collector.add({"state": result.state})
128
+
129
+ # Emits:
130
+ # - "oban.job.execute.start" with job_id and system_time
131
+ # - "oban.job.execute.stop" with job_id, state, duration, and system_time
132
+ """
133
+ start_time = time.monotonic_ns()
134
+
135
+ execute(f"{prefix}.start", {"system_time": start_time, **start_metadata})
136
+
137
+ collector = Collector()
138
+
139
+ try:
140
+ yield collector
141
+
142
+ end_time = time.monotonic_ns()
143
+
144
+ execute(
145
+ f"{prefix}.stop",
146
+ {
147
+ "system_time": end_time,
148
+ "duration": end_time - start_time,
149
+ **start_metadata,
150
+ **collector.get_all(),
151
+ },
152
+ )
153
+
154
+ except Exception as error:
155
+ end_time = time.monotonic_ns()
156
+
157
+ execute(
158
+ f"{prefix}.exception",
159
+ {
160
+ "system_time": end_time,
161
+ "duration": end_time - start_time,
162
+ "error_message": str(error),
163
+ "error_type": type(error).__name__,
164
+ "traceback": traceback.format_exc(),
165
+ **start_metadata,
166
+ **collector.get_all(),
167
+ },
168
+ )
169
+
170
+ raise
@@ -0,0 +1,147 @@
1
+ import logging
2
+ from typing import Any, List
3
+
4
+ import orjson
5
+
6
+ from . import core
7
+
8
+ EVENTS = [
9
+ "oban.job.start",
10
+ "oban.job.stop",
11
+ "oban.job.exception",
12
+ "oban.leader.election.stop",
13
+ "oban.leader.election.exception",
14
+ "oban.stager.stage.stop",
15
+ "oban.stager.stage.exception",
16
+ "oban.lifeline.rescue.stop",
17
+ "oban.lifeline.rescue.exception",
18
+ "oban.pruner.prune.stop",
19
+ "oban.pruner.prune.exception",
20
+ "oban.refresher.refresh.stop",
21
+ "oban.refresher.refresh.exception",
22
+ "oban.refresher.cleanup.stop",
23
+ "oban.refresher.cleanup.exception",
24
+ "oban.scheduler.evaluate.stop",
25
+ "oban.scheduler.evaluate.exception",
26
+ "oban.producer.fetch.stop",
27
+ "oban.producer.fetch.exception",
28
+ ]
29
+
30
+ JOB_FIELDS = [
31
+ "id",
32
+ "worker",
33
+ "queue",
34
+ "attempt",
35
+ "max_attempts",
36
+ "args",
37
+ "meta",
38
+ "tags",
39
+ ]
40
+
41
+
42
+ class _LoggerHandler:
43
+ def __init__(
44
+ self,
45
+ *,
46
+ level: int = logging.DEBUG,
47
+ logger: logging.Logger | None = None,
48
+ ):
49
+ self.level = level
50
+ self.logger = logger or logging.getLogger("oban")
51
+
52
+ def _handle_event(self, name: str, meta: dict[str, Any]) -> None:
53
+ data = self._format_event(name, meta)
54
+ level = self._get_level(name)
55
+ message = orjson.dumps(data).decode()
56
+
57
+ self.logger.log(level, message)
58
+
59
+ def _format_event(self, name: str, meta: dict[str, Any]) -> dict[str, Any]:
60
+ if name.startswith("oban.job"):
61
+ return self._format_job_event(name, meta)
62
+ else:
63
+ return self._format_loop_event(name, meta)
64
+
65
+ def _format_job_event(self, name: str, meta: dict[str, Any]) -> dict[str, Any]:
66
+ job = meta.get("job")
67
+
68
+ data = {field: getattr(job, field) for field in JOB_FIELDS}
69
+ data["event"] = name
70
+
71
+ match name:
72
+ case "oban.job.start":
73
+ pass
74
+
75
+ case "oban.job.stop":
76
+ data["state"] = meta["state"]
77
+ data["duration"] = self._to_ms(meta["duration"])
78
+ data["queue_time"] = self._to_ms(meta["queue_time"])
79
+
80
+ case "oban.job.exception":
81
+ data["state"] = meta["state"]
82
+ data["error_type"] = meta["error_type"]
83
+ data["error_message"] = meta["error_message"]
84
+ data["duration"] = self._to_ms(meta["duration"])
85
+ data["queue_time"] = self._to_ms(meta["queue_time"])
86
+
87
+ return data
88
+
89
+ def _format_loop_event(self, name: str, meta: dict[str, Any]) -> dict[str, Any]:
90
+ data = {key: val for key, val in meta.items() if key != "system_time"}
91
+
92
+ data["duration"] = self._to_ms(data["duration"])
93
+ data["event"] = name
94
+
95
+ return data
96
+
97
+ def _get_level(self, name: str) -> int:
98
+ # TODO: This is a mess.
99
+ if name.endswith(".exception") and name != "oban.job.exception":
100
+ return logging.ERROR
101
+ elif name.startswith("oban.job"):
102
+ return self.level
103
+ else:
104
+ return logging.DEBUG
105
+
106
+ def _to_ms(self, value: int) -> float:
107
+ return round(value / 1_000_000, 2)
108
+
109
+
110
+ def attach(
111
+ *,
112
+ level: int = logging.INFO,
113
+ logger: logging.Logger | None = None,
114
+ events: List[str] | None = None,
115
+ ) -> None:
116
+ """Attach a logger handler to telemetry events.
117
+
118
+ Emits structured logs for Oban telemetry events using Python's standard
119
+ logging module.
120
+
121
+ Args:
122
+ level: Logging level for .stop events (default: INFO)
123
+ logger: Custom logger instance (default: logging.getLogger("oban"))
124
+ events: Specific events to log (default: all job events)
125
+
126
+ Example:
127
+ >>> import logging
128
+ >>>
129
+ >>> # Configure Python logging
130
+ >>> logging.basicConfig(level=logging.INFO)
131
+ >>>
132
+ >>> # Attach logger to telemetry events
133
+ >>> oban.telemetry.logger.attach()
134
+ """
135
+ handler = _LoggerHandler(level=level, logger=logger)
136
+ events = events or EVENTS
137
+
138
+ core.attach("oban-logger", events, handler._handle_event)
139
+
140
+
141
+ def detach() -> None:
142
+ """Detach the logger handler from telemetry events.
143
+
144
+ Example:
145
+ >>> oban.telemetry.logger.detach()
146
+ """
147
+ core.detach("oban-logger")