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 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
+ """