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/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
|
oban/telemetry/logger.py
ADDED
|
@@ -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")
|