sidejob 0.1.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.
- sidejob/__init__.py +71 -0
- sidejob/_status.py +37 -0
- sidejob/context.py +177 -0
- sidejob/errors.py +114 -0
- sidejob/manager.py +1527 -0
- sidejob/py.typed +0 -0
- sidejob/records.py +170 -0
- sidejob/retry.py +135 -0
- sidejob/workflow.py +293 -0
- sidejob-0.1.0.dist-info/METADATA +749 -0
- sidejob-0.1.0.dist-info/RECORD +13 -0
- sidejob-0.1.0.dist-info/WHEEL +4 -0
- sidejob-0.1.0.dist-info/licenses/LICENSE +21 -0
sidejob/__init__.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""sidejob: simple, observable, fail-loud background jobs for data workflows.
|
|
2
|
+
|
|
3
|
+
Quick start::
|
|
4
|
+
|
|
5
|
+
import sidejob
|
|
6
|
+
|
|
7
|
+
manager = sidejob.initialize(max_workers=4)
|
|
8
|
+
with manager.workflow() as run:
|
|
9
|
+
run.parallel_job(upload_to_redshift, df, "analytics_table")
|
|
10
|
+
summary = analyze(df)
|
|
11
|
+
run.wait_all() # MANDATORY: raises if a required job failed
|
|
12
|
+
write_success_marker() # reached only on full success
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from ._status import JobStatus
|
|
16
|
+
from .context import (
|
|
17
|
+
JobContext,
|
|
18
|
+
cancel_requested,
|
|
19
|
+
check_cancelled,
|
|
20
|
+
current_job,
|
|
21
|
+
on_cancel,
|
|
22
|
+
sleep,
|
|
23
|
+
)
|
|
24
|
+
from .errors import (
|
|
25
|
+
JobCancelledError,
|
|
26
|
+
JobsFailedError,
|
|
27
|
+
ManagerShutdownError,
|
|
28
|
+
SidejobError,
|
|
29
|
+
TransientError,
|
|
30
|
+
UnknownJobError,
|
|
31
|
+
WaitAllNotCalledError,
|
|
32
|
+
WaitTimeoutError,
|
|
33
|
+
WorkflowClosedError,
|
|
34
|
+
)
|
|
35
|
+
from .manager import Job, JobManager, initialize
|
|
36
|
+
from .records import AttemptRecord, JobEvent, JobRun, WaitReport
|
|
37
|
+
from .retry import DEFAULT_NEVER_RETRY, DEFAULT_RETRYABLE, RetryPolicy
|
|
38
|
+
from .workflow import Workflow
|
|
39
|
+
|
|
40
|
+
__version__ = "0.1.0"
|
|
41
|
+
|
|
42
|
+
__all__ = [
|
|
43
|
+
"DEFAULT_NEVER_RETRY",
|
|
44
|
+
"DEFAULT_RETRYABLE",
|
|
45
|
+
"AttemptRecord",
|
|
46
|
+
"Job",
|
|
47
|
+
"JobCancelledError",
|
|
48
|
+
"JobContext",
|
|
49
|
+
"JobEvent",
|
|
50
|
+
"JobManager",
|
|
51
|
+
"JobRun",
|
|
52
|
+
"JobStatus",
|
|
53
|
+
"JobsFailedError",
|
|
54
|
+
"ManagerShutdownError",
|
|
55
|
+
"RetryPolicy",
|
|
56
|
+
"SidejobError",
|
|
57
|
+
"TransientError",
|
|
58
|
+
"UnknownJobError",
|
|
59
|
+
"WaitAllNotCalledError",
|
|
60
|
+
"WaitReport",
|
|
61
|
+
"WaitTimeoutError",
|
|
62
|
+
"Workflow",
|
|
63
|
+
"WorkflowClosedError",
|
|
64
|
+
"__version__",
|
|
65
|
+
"cancel_requested",
|
|
66
|
+
"check_cancelled",
|
|
67
|
+
"current_job",
|
|
68
|
+
"initialize",
|
|
69
|
+
"on_cancel",
|
|
70
|
+
"sleep",
|
|
71
|
+
]
|
sidejob/_status.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Job status enumeration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from enum import Enum
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class JobStatus(str, Enum):
|
|
9
|
+
"""Lifecycle state of a background job.
|
|
10
|
+
|
|
11
|
+
``QUEUED`` Submitted, waiting for a free worker.
|
|
12
|
+
``RUNNING`` An attempt is currently executing.
|
|
13
|
+
``RETRYING`` An attempt failed with a retryable error; waiting out the backoff
|
|
14
|
+
delay before the next attempt.
|
|
15
|
+
``COMPLETED`` The submitted function returned normally (terminal).
|
|
16
|
+
``FAILED`` The final attempt raised, or the error was not retryable (terminal).
|
|
17
|
+
``CANCELLED`` Cancelled before an attempt started (terminal).
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
QUEUED = "QUEUED"
|
|
21
|
+
RUNNING = "RUNNING"
|
|
22
|
+
RETRYING = "RETRYING"
|
|
23
|
+
COMPLETED = "COMPLETED"
|
|
24
|
+
FAILED = "FAILED"
|
|
25
|
+
CANCELLED = "CANCELLED"
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def is_terminal(self) -> bool:
|
|
29
|
+
"""True for ``COMPLETED``, ``FAILED`` and ``CANCELLED``."""
|
|
30
|
+
return self in _TERMINAL
|
|
31
|
+
|
|
32
|
+
def __str__(self) -> str:
|
|
33
|
+
return self.value
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
_TERMINAL = frozenset({JobStatus.COMPLETED, JobStatus.FAILED, JobStatus.CANCELLED})
|
|
37
|
+
TERMINAL_STATUSES = _TERMINAL
|
sidejob/context.py
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""Cooperative cancellation and per-attempt context for code running inside a job.
|
|
2
|
+
|
|
3
|
+
Python threads cannot be killed safely, so stopping running work requires the
|
|
4
|
+
job's cooperation. These helpers make that easy and are **no-ops outside a
|
|
5
|
+
job**, so the same function also works when called directly::
|
|
6
|
+
|
|
7
|
+
import sidejob
|
|
8
|
+
|
|
9
|
+
def export_partitions(df, partitions):
|
|
10
|
+
for p in partitions:
|
|
11
|
+
sidejob.check_cancelled() # raises JobCancelledError if cancel() was requested
|
|
12
|
+
write_partition(df, p)
|
|
13
|
+
|
|
14
|
+
def poll_until_ready(client, query_id):
|
|
15
|
+
while not client.is_done(query_id):
|
|
16
|
+
sidejob.sleep(5) # wakes up immediately on cancellation
|
|
17
|
+
|
|
18
|
+
def upload(df, table):
|
|
19
|
+
conn = connect()
|
|
20
|
+
sidejob.on_cancel(conn.cancel) # abort the in-flight query on cancel()
|
|
21
|
+
...
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import threading
|
|
27
|
+
import time
|
|
28
|
+
from typing import TYPE_CHECKING, Any, Callable, Optional
|
|
29
|
+
|
|
30
|
+
from .errors import JobCancelledError
|
|
31
|
+
|
|
32
|
+
if TYPE_CHECKING:
|
|
33
|
+
from .manager import JobManager, _JobRecord
|
|
34
|
+
|
|
35
|
+
__all__ = [
|
|
36
|
+
"JobContext",
|
|
37
|
+
"cancel_requested",
|
|
38
|
+
"check_cancelled",
|
|
39
|
+
"current_job",
|
|
40
|
+
"on_cancel",
|
|
41
|
+
"sleep",
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
_state = threading.local()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class JobContext:
|
|
48
|
+
"""Information about the job attempt running on the current thread.
|
|
49
|
+
|
|
50
|
+
Obtain it with :func:`sidejob.current_job` inside a job function.
|
|
51
|
+
|
|
52
|
+
Attributes:
|
|
53
|
+
job_id: The job's ID.
|
|
54
|
+
name: The job's display name.
|
|
55
|
+
attempt: The current attempt number (1 = first attempt).
|
|
56
|
+
max_attempts: The configured attempt budget.
|
|
57
|
+
workflow_id: The workflow the job belongs to, if any.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
__slots__ = ("_manager", "_rec", "attempt", "job_id", "max_attempts", "name", "workflow_id")
|
|
61
|
+
|
|
62
|
+
def __init__(self, manager: JobManager, rec: _JobRecord, attempt: int) -> None:
|
|
63
|
+
self._manager = manager
|
|
64
|
+
self._rec = rec
|
|
65
|
+
self.job_id: str = rec.job_id
|
|
66
|
+
self.name: str = rec.name
|
|
67
|
+
self.attempt = attempt
|
|
68
|
+
self.max_attempts: int = rec.policy.max_attempts
|
|
69
|
+
self.workflow_id: Optional[str] = rec.workflow_id
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def is_retry(self) -> bool:
|
|
73
|
+
"""True from the second attempt on.
|
|
74
|
+
|
|
75
|
+
Check it before repeating a side effect: an earlier attempt may have
|
|
76
|
+
committed its write before the error reached your code.
|
|
77
|
+
"""
|
|
78
|
+
return self.attempt > 1
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def idempotency_key(self) -> str:
|
|
82
|
+
"""A key that stays the same across all attempts of this job.
|
|
83
|
+
|
|
84
|
+
Store it with the data you write (or use it as a deduplication key) so a
|
|
85
|
+
retry can detect that an earlier attempt already succeeded. It is the
|
|
86
|
+
job ID, which is unique per submission; for a key that survives process
|
|
87
|
+
restarts, pass your own (for example ``f"{table}:{run_date}"``).
|
|
88
|
+
"""
|
|
89
|
+
return self.job_id
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def cancel_requested(self) -> bool:
|
|
93
|
+
"""True once cancellation of this job has been requested."""
|
|
94
|
+
return self._rec.cancel_event.is_set()
|
|
95
|
+
|
|
96
|
+
def check_cancelled(self) -> None:
|
|
97
|
+
"""Raise :class:`JobCancelledError` if cancellation was requested."""
|
|
98
|
+
if self._rec.cancel_event.is_set():
|
|
99
|
+
raise JobCancelledError(self.job_id)
|
|
100
|
+
|
|
101
|
+
def sleep(self, seconds: float) -> None:
|
|
102
|
+
"""Sleep, but wake up and raise :class:`JobCancelledError` on cancellation."""
|
|
103
|
+
if seconds < 0:
|
|
104
|
+
raise ValueError("sleep length must be non-negative")
|
|
105
|
+
if self._rec.cancel_event.wait(seconds):
|
|
106
|
+
raise JobCancelledError(self.job_id)
|
|
107
|
+
|
|
108
|
+
def on_cancel(self, callback: Callable[[], Any]) -> None:
|
|
109
|
+
"""Register ``callback()`` to run when cancellation is requested.
|
|
110
|
+
|
|
111
|
+
Use it to abort blocking work from the outside, for example
|
|
112
|
+
``conn.cancel`` (psycopg2/psycopg, many DB-API drivers) or
|
|
113
|
+
``lambda: spark.sparkContext.cancelJobGroup(group)``.
|
|
114
|
+
|
|
115
|
+
* Callbacks run on the thread that requested cancellation (or on the
|
|
116
|
+
scheduler thread for ``cancel_on_deadline``), not on the job's thread.
|
|
117
|
+
* They apply to the current attempt only and are discarded when it ends.
|
|
118
|
+
* If cancellation was already requested, ``callback`` runs immediately.
|
|
119
|
+
* Exceptions raised by a callback are logged, never propagated.
|
|
120
|
+
* A callback may race with the attempt finishing, so it must be safe to
|
|
121
|
+
call on an already-closed resource.
|
|
122
|
+
"""
|
|
123
|
+
self._manager._register_cancel_callback(self._rec, self.attempt, callback)
|
|
124
|
+
|
|
125
|
+
def __repr__(self) -> str:
|
|
126
|
+
return (
|
|
127
|
+
f"JobContext(job_id={self.job_id!r}, name={self.name!r}, "
|
|
128
|
+
f"attempt={self.attempt}/{self.max_attempts})"
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def current_job() -> Optional[JobContext]:
|
|
133
|
+
"""The :class:`JobContext` of the job running on this thread, or ``None``.
|
|
134
|
+
|
|
135
|
+
The context is thread-local: threads that a job starts itself do not see
|
|
136
|
+
it. Pass the context object to them explicitly if needed.
|
|
137
|
+
"""
|
|
138
|
+
ctx: Optional[JobContext] = getattr(_state, "context", None)
|
|
139
|
+
return ctx
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def cancel_requested() -> bool:
|
|
143
|
+
"""True if the current job has a pending cancellation request (False outside jobs)."""
|
|
144
|
+
ctx = current_job()
|
|
145
|
+
return ctx is not None and ctx.cancel_requested
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def check_cancelled() -> None:
|
|
149
|
+
"""Raise :class:`JobCancelledError` if the current job should stop (no-op outside jobs)."""
|
|
150
|
+
ctx = current_job()
|
|
151
|
+
if ctx is not None:
|
|
152
|
+
ctx.check_cancelled()
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def sleep(seconds: float) -> None:
|
|
156
|
+
"""Cancellation-aware ``time.sleep`` (plain ``time.sleep`` outside jobs)."""
|
|
157
|
+
ctx = current_job()
|
|
158
|
+
if ctx is None:
|
|
159
|
+
time.sleep(seconds)
|
|
160
|
+
else:
|
|
161
|
+
ctx.sleep(seconds)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def on_cancel(callback: Callable[[], Any]) -> None:
|
|
165
|
+
"""Register a cancellation hook for the current attempt (no-op outside jobs).
|
|
166
|
+
|
|
167
|
+
See :meth:`JobContext.on_cancel`.
|
|
168
|
+
"""
|
|
169
|
+
ctx = current_job()
|
|
170
|
+
if ctx is not None:
|
|
171
|
+
ctx.on_cancel(callback)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _set_context(ctx: Optional[JobContext]) -> Optional[JobContext]:
|
|
175
|
+
prev: Optional[JobContext] = getattr(_state, "context", None)
|
|
176
|
+
_state.context = ctx
|
|
177
|
+
return prev
|
sidejob/errors.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Exception types raised by sidejob."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, Optional, Sequence, Tuple
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from .records import JobRun, WaitReport
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class SidejobError(Exception):
|
|
12
|
+
"""Base class for all sidejob errors."""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class TransientError(SidejobError):
|
|
16
|
+
"""Raise (or subclass) inside a job to mark a failure as retryable.
|
|
17
|
+
|
|
18
|
+
It is part of the default retryable set, so wrapping a driver error is an
|
|
19
|
+
easy way to opt into retries::
|
|
20
|
+
|
|
21
|
+
try:
|
|
22
|
+
cursor.execute(sql)
|
|
23
|
+
except redshift_connector.InterfaceError as exc:
|
|
24
|
+
raise sidejob.TransientError("connection dropped") from exc
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class UnknownJobError(SidejobError, LookupError):
|
|
29
|
+
"""The job ID (or handle) does not belong to this manager."""
|
|
30
|
+
|
|
31
|
+
def __init__(self, job_id: str, hint: str = "") -> None:
|
|
32
|
+
super().__init__(f"Unknown job id: {job_id!r}{hint}")
|
|
33
|
+
self.job_id = job_id
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ManagerShutdownError(SidejobError, RuntimeError):
|
|
37
|
+
"""A job was submitted to a manager that has been shut down."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class WorkflowClosedError(SidejobError, RuntimeError):
|
|
41
|
+
"""A job was submitted to a workflow that is not open (not entered or already exited)."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class JobCancelledError(SidejobError):
|
|
45
|
+
"""The job was cancelled.
|
|
46
|
+
|
|
47
|
+
Raised by ``get_result()`` for a CANCELLED job, and inside a job by
|
|
48
|
+
:func:`sidejob.check_cancelled` / :func:`sidejob.sleep` once cancellation
|
|
49
|
+
has been requested.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
def __init__(self, job_id: str) -> None:
|
|
53
|
+
super().__init__(f"Job {job_id} was cancelled before it ran to completion")
|
|
54
|
+
self.job_id = job_id
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class WaitTimeoutError(SidejobError, TimeoutError):
|
|
58
|
+
"""Waiting for job(s) exceeded the allowed time.
|
|
59
|
+
|
|
60
|
+
A wait timeout is *not* a job failure: the jobs listed in ``pending`` are
|
|
61
|
+
still queued, running or retrying, and their status is left untouched.
|
|
62
|
+
A timeout never means success.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
def __init__(
|
|
66
|
+
self,
|
|
67
|
+
message: str,
|
|
68
|
+
pending: Sequence[JobRun] = (),
|
|
69
|
+
failures: Sequence[JobRun] = (),
|
|
70
|
+
) -> None:
|
|
71
|
+
super().__init__(message)
|
|
72
|
+
self.pending: Tuple[JobRun, ...] = tuple(pending)
|
|
73
|
+
self.failures: Tuple[JobRun, ...] = tuple(failures)
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def pending_job_ids(self) -> Tuple[str, ...]:
|
|
77
|
+
return tuple(r.job_id for r in self.pending)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class JobsFailedError(SidejobError):
|
|
81
|
+
"""One or more *required* jobs failed or were cancelled.
|
|
82
|
+
|
|
83
|
+
Attributes:
|
|
84
|
+
failures: Snapshots of the required jobs that did not complete.
|
|
85
|
+
job_ids: Their IDs.
|
|
86
|
+
exceptions: The original final exception of each FAILED job.
|
|
87
|
+
report: The full :class:`~sidejob.WaitReport` (all selected jobs).
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
def __init__(self, failures: Sequence[JobRun], report: Optional[WaitReport] = None) -> None:
|
|
91
|
+
self.failures: Tuple[JobRun, ...] = tuple(failures)
|
|
92
|
+
self.report = report
|
|
93
|
+
super().__init__(self._build_message())
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def job_ids(self) -> Tuple[str, ...]:
|
|
97
|
+
return tuple(r.job_id for r in self.failures)
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def exceptions(self) -> Tuple[BaseException, ...]:
|
|
101
|
+
return tuple(r.exception for r in self.failures if r.exception is not None)
|
|
102
|
+
|
|
103
|
+
def _build_message(self) -> str:
|
|
104
|
+
lines = [f"{len(self.failures)} required job(s) did not complete successfully:"]
|
|
105
|
+
lines.extend(f" - {r.describe()}" for r in self.failures)
|
|
106
|
+
return "\n".join(lines)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class WaitAllNotCalledError(SidejobError):
|
|
110
|
+
"""A strict workflow exited without a successful explicit ``wait_all()``.
|
|
111
|
+
|
|
112
|
+
The jobs were still waited for and checked; this error exists so that a
|
|
113
|
+
forgotten safety barrier is caught instead of silently tolerated.
|
|
114
|
+
"""
|