taskscheduler-client 0.6.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.
@@ -0,0 +1,141 @@
1
+ """TaskScheduler — async Python SDK for durable, distributed background jobs.
2
+
3
+ A Python service talks to the same PostgreSQL database and RabbitMQ broker as the Kotlin
4
+ side, so its jobs show up on the same dashboard, obey the same retry and cancellation
5
+ semantics, and survive restarts the same way. Postgres holds the state; RabbitMQ only
6
+ delivers 16-byte job ids.
7
+
8
+ Producing work::
9
+
10
+ from dataclasses import dataclass
11
+ from taskscheduler import Scheduler, SchedulerConfig, job_type
12
+
13
+ @job_type
14
+ @dataclass
15
+ class SendInvoice:
16
+ order_id: int
17
+
18
+ async with Scheduler(SchedulerConfig(dsn="postgresql://...")) as scheduler:
19
+ await scheduler.enqueue(SendInvoice(order_id=42))
20
+
21
+ Consuming it::
22
+
23
+ from taskscheduler import HandlerRegistry, JobContext, WorkerPool, WorkerConfig
24
+
25
+ registry = HandlerRegistry()
26
+
27
+ @registry.handler(SendInvoice)
28
+ async def send_invoice(ctx: JobContext, job: SendInvoice) -> None:
29
+ await billing.send(job.order_id)
30
+
31
+ worker = WorkerPool(
32
+ scheduler_config=SchedulerConfig(dsn="postgresql://..."),
33
+ worker_config=WorkerConfig(node_id="billing-1").queue("billing", concurrency=8),
34
+ rabbit_config=RabbitConfig(url="amqp://...", queues=["billing"]),
35
+ registry=registry,
36
+ )
37
+ await worker.start()
38
+
39
+ The delivery guarantee is at-least-once: a job can run twice after a lease expiry or a
40
+ broker redelivery. ``ctx.job_id`` is stable across every attempt — use it as the
41
+ idempotency key for anything with side effects.
42
+
43
+ Requires a running Kotlin ``scheduler-infra`` process: it owns the schema migrations and
44
+ the background loops (outbox publisher, recurring cron, orphan recovery, retention).
45
+ """
46
+
47
+ from __future__ import annotations
48
+
49
+ from .config import (
50
+ QueueConfig,
51
+ RabbitConfig,
52
+ SchedulerConfig,
53
+ WorkerConfig,
54
+ default_node_id,
55
+ dsn_from_jdbc,
56
+ )
57
+ from .context import JobContext, ProgressBar
58
+ from .cron import next_trigger_at, validate_cron
59
+ from .errors import (
60
+ ConfigurationError,
61
+ HandlerNotRegisteredError,
62
+ JobCancellationError,
63
+ NonRetriableError,
64
+ PayloadDecodeError,
65
+ SchedulerError,
66
+ SchemaMismatchError,
67
+ )
68
+ from .job import HandlerRegistry, JobHandler, job_type
69
+ from .models import (
70
+ ConcurrencyPolicy,
71
+ JobRow,
72
+ JobState,
73
+ MisfirePolicy,
74
+ OnFailure,
75
+ RecurringOverlap,
76
+ )
77
+ from .retry import (
78
+ FULL_JITTER,
79
+ NO_JITTER,
80
+ ExponentialBackoff,
81
+ FixedDelay,
82
+ Jitter,
83
+ NoRetry,
84
+ RetryPolicy,
85
+ equal_jitter,
86
+ )
87
+ from .scheduler import Scheduler
88
+ from .storage import Storage
89
+ from .transport import RabbitTransport
90
+ from .worker import WorkerPool
91
+
92
+ __version__ = "0.6.0"
93
+
94
+ __all__ = [
95
+ "__version__",
96
+ # entry points
97
+ "Scheduler",
98
+ "WorkerPool",
99
+ "HandlerRegistry",
100
+ "JobHandler",
101
+ "JobContext",
102
+ "ProgressBar",
103
+ "job_type",
104
+ # configuration
105
+ "SchedulerConfig",
106
+ "WorkerConfig",
107
+ "QueueConfig",
108
+ "RabbitConfig",
109
+ "default_node_id",
110
+ "dsn_from_jdbc",
111
+ # retry
112
+ "RetryPolicy",
113
+ "NoRetry",
114
+ "FixedDelay",
115
+ "ExponentialBackoff",
116
+ "Jitter",
117
+ "NO_JITTER",
118
+ "FULL_JITTER",
119
+ "equal_jitter",
120
+ # enums and rows
121
+ "JobState",
122
+ "JobRow",
123
+ "OnFailure",
124
+ "ConcurrencyPolicy",
125
+ "MisfirePolicy",
126
+ "RecurringOverlap",
127
+ # cron
128
+ "next_trigger_at",
129
+ "validate_cron",
130
+ # errors
131
+ "SchedulerError",
132
+ "ConfigurationError",
133
+ "SchemaMismatchError",
134
+ "PayloadDecodeError",
135
+ "HandlerNotRegisteredError",
136
+ "JobCancellationError",
137
+ "NonRetriableError",
138
+ # lower level
139
+ "Storage",
140
+ "RabbitTransport",
141
+ ]
@@ -0,0 +1,190 @@
1
+ """Configuration objects.
2
+
3
+ Three groups, matching the Kotlin Koin modules: core scheduler defaults
4
+ (``schedulerCoreModule``), the RabbitMQ connection (``schedulerRabbitModule``), and the
5
+ worker pool (``schedulerWorkerModule``).
6
+
7
+ This client never runs migrations. The Kotlin ``scheduler-infra`` process owns the schema
8
+ and the background loops (outbox publisher, recurring cron, retention, orphan recovery);
9
+ the Python service is a peer of a user-app worker, not a replacement for infra.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import os
15
+ import socket
16
+ from dataclasses import dataclass, field
17
+
18
+ from .errors import ConfigurationError
19
+ from .retry import ExponentialBackoff, RetryPolicy
20
+
21
+ __all__ = [
22
+ "SchedulerConfig",
23
+ "RabbitConfig",
24
+ "WorkerConfig",
25
+ "QueueConfig",
26
+ "dsn_from_jdbc",
27
+ "default_node_id",
28
+ ]
29
+
30
+ #: The Flyway version this client's SQL was written against. Startup fails if the database
31
+ #: is older, because the missing columns would surface as confusing runtime errors instead.
32
+ REQUIRED_SCHEMA_VERSION = 8
33
+
34
+
35
+ def default_node_id() -> str:
36
+ """``HOSTNAME`` / ``COMPUTERNAME`` / the host name, matching Kotlin's ``defaultNodeId``."""
37
+ for var in ("HOSTNAME", "COMPUTERNAME"):
38
+ value = os.environ.get(var)
39
+ if value:
40
+ return value
41
+ try:
42
+ return socket.gethostname()
43
+ except OSError:
44
+ return "worker"
45
+
46
+
47
+ def dsn_from_jdbc(jdbc_url: str, user: str, password: str) -> str:
48
+ """Turn the ``jdbc:postgresql://host:port/db`` URL used by the Kotlin side into a libpq DSN.
49
+
50
+ Handy when both services read the same ``POSTGRES_URL`` environment variable.
51
+ """
52
+ prefix = "jdbc:postgresql://"
53
+ if not jdbc_url.startswith(prefix):
54
+ raise ConfigurationError(f"expected a URL starting with {prefix!r}, got {jdbc_url!r}")
55
+ return f"postgresql://{user}:{password}@{jdbc_url[len(prefix):]}"
56
+
57
+
58
+ @dataclass(slots=True)
59
+ class SchedulerConfig:
60
+ """Connection to Postgres plus the defaults applied to every enqueue."""
61
+
62
+ dsn: str
63
+ node_id: str = field(default_factory=default_node_id)
64
+ default_queue: str = "default"
65
+ default_max_attempts: int = 3
66
+ default_timeout_seconds: int = 300
67
+ default_retry_policy: RetryPolicy | None = None
68
+ #: Jobs further out than this are stored as SCHEDULED without an outbox row; the infra
69
+ #: fast-forward loop promotes them when they come within the window. Must match the
70
+ #: Kotlin ``fastForwardWindow`` (24h) or far-future jobs fire at the wrong time.
71
+ fast_forward_window_seconds: int = 24 * 60 * 60
72
+ min_pool_size: int = 1
73
+ max_pool_size: int = 10
74
+ #: Emit ``scheduler_events`` NOTIFY payloads so the dashboard updates live.
75
+ emit_events: bool = True
76
+ #: Verify the Flyway schema version at startup.
77
+ check_schema: bool = True
78
+
79
+ def __post_init__(self) -> None:
80
+ if not self.dsn:
81
+ raise ConfigurationError("SchedulerConfig.dsn is required")
82
+ if self.default_max_attempts < 1:
83
+ raise ConfigurationError("default_max_attempts must be >= 1")
84
+ if self.default_timeout_seconds < 1:
85
+ raise ConfigurationError("default_timeout_seconds must be >= 1")
86
+ if self.max_pool_size < self.min_pool_size:
87
+ raise ConfigurationError("max_pool_size must be >= min_pool_size")
88
+ if self.default_retry_policy is None:
89
+ self.default_retry_policy = ExponentialBackoff(max_attempts=self.default_max_attempts)
90
+
91
+
92
+ @dataclass(slots=True)
93
+ class RabbitConfig:
94
+ """AMQP connection settings. Only the worker needs these — producers write to Postgres."""
95
+
96
+ url: str = "amqp://guest:guest@localhost:5672/"
97
+ #: Logical queue names to declare. Must be a superset of the names the worker consumes,
98
+ #: and must match what the Kotlin side declares, or the bindings will not line up.
99
+ queues: list[str] = field(default_factory=lambda: ["default"])
100
+ prefetch: int = 10
101
+ reconnect_delay_seconds: float = 5.0
102
+ #: Declare the exchanges and queues on connect. Leave on unless a stricter broker policy
103
+ #: forbids clients from declaring topology.
104
+ declare_topology: bool = True
105
+
106
+ def __post_init__(self) -> None:
107
+ if not self.url:
108
+ raise ConfigurationError("RabbitConfig.url is required")
109
+ if self.prefetch < 1:
110
+ raise ConfigurationError("prefetch must be >= 1")
111
+
112
+
113
+ @dataclass(slots=True)
114
+ class QueueConfig:
115
+ """One consumed queue and how much of it runs at once."""
116
+
117
+ name: str
118
+ concurrency: int = 10
119
+ prefetch: int | None = None
120
+ default_priority: int = 0
121
+
122
+ def __post_init__(self) -> None:
123
+ if self.concurrency < 1:
124
+ raise ConfigurationError(f"queue {self.name!r}: concurrency must be >= 1")
125
+ if self.prefetch is None:
126
+ self.prefetch = self.concurrency
127
+ if self.prefetch < 1:
128
+ raise ConfigurationError(f"queue {self.name!r}: prefetch must be >= 1")
129
+ if not 0 <= self.default_priority <= 10:
130
+ raise ConfigurationError(f"queue {self.name!r}: default_priority must be within 0..10")
131
+
132
+
133
+ @dataclass(slots=True)
134
+ class WorkerConfig:
135
+ """Worker identity, lease timings and the queues it serves."""
136
+
137
+ node_id: str = field(default_factory=default_node_id)
138
+ node_tags: list[str] = field(default_factory=list)
139
+ queues: list[QueueConfig] = field(default_factory=list)
140
+ #: How often the lease on in-flight jobs is extended, and how long each extension lasts.
141
+ #: Keep ``heartbeat <= lock_duration / 3`` so two missed ticks still leave the lock valid
142
+ #: (DESIGN.md 13.4) — otherwise infra's orphan recovery re-runs a job that is still going.
143
+ heartbeat_interval_seconds: float = 30.0
144
+ lock_duration_seconds: float = 90.0
145
+ #: Grace period for in-flight jobs to finish after ``stop()`` is called.
146
+ shutdown_timeout_seconds: float = 30.0
147
+ #: How long a cancelled job is given to stop cooperatively before its task is cancelled
148
+ #: outright. Handlers that never check ``ctx.is_cancellation_requested()`` need this;
149
+ #: ones that do stop sooner.
150
+ cancel_grace_seconds: float = 30.0
151
+ #: How long a job whose type is paused waits before redelivery. Matches Kotlin's
152
+ #: ``PAUSE_REDELIVER_DELAY``.
153
+ pause_redeliver_delay_seconds: float = 60.0
154
+
155
+ def queue(
156
+ self,
157
+ name: str,
158
+ *,
159
+ concurrency: int = 10,
160
+ prefetch: int | None = None,
161
+ default_priority: int = 0,
162
+ ) -> WorkerConfig:
163
+ """Add a consumed queue. Chainable."""
164
+ self.queues.append(
165
+ QueueConfig(
166
+ name=name,
167
+ concurrency=concurrency,
168
+ prefetch=prefetch,
169
+ default_priority=default_priority,
170
+ )
171
+ )
172
+ return self
173
+
174
+ def validate(self) -> None:
175
+ if not self.queues:
176
+ raise ConfigurationError(
177
+ "WorkerConfig has no queues — call worker_config.queue('default') at least once"
178
+ )
179
+ names = [q.name for q in self.queues]
180
+ duplicates = {n for n in names if names.count(n) > 1}
181
+ if duplicates:
182
+ raise ConfigurationError(f"duplicate queue names: {', '.join(sorted(duplicates))}")
183
+ if self.heartbeat_interval_seconds <= 0 or self.lock_duration_seconds <= 0:
184
+ raise ConfigurationError("heartbeat_interval and lock_duration must be positive")
185
+ if self.heartbeat_interval_seconds > self.lock_duration_seconds / 3:
186
+ raise ConfigurationError(
187
+ f"heartbeat_interval ({self.heartbeat_interval_seconds}s) must be at most a third "
188
+ f"of lock_duration ({self.lock_duration_seconds}s) — otherwise a single missed "
189
+ f"tick lets another node steal a job that is still running"
190
+ )
@@ -0,0 +1,185 @@
1
+ """Per-execution context handed to a handler.
2
+
3
+ ``job_id`` is stable across every attempt of a job — retries, orphan recovery and broker
4
+ redelivery all reuse it. That makes it the natural idempotency key for external calls
5
+ (``Idempotency-Key: <job_id>`` on an HTTP request, a unique column in your own tables),
6
+ which matters because the delivery guarantee is at-least-once: a job can run twice, and
7
+ only the handler can make that harmless.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import time
13
+ import uuid
14
+ from datetime import datetime
15
+ from typing import TYPE_CHECKING
16
+
17
+ from .models import JobRow
18
+
19
+ if TYPE_CHECKING:
20
+ from .worker import WorkerPool
21
+
22
+ __all__ = ["JobContext", "ProgressBar"]
23
+
24
+ #: Progress writes are collapsed to at most one per second per job. Calling more often is
25
+ #: safe — only the latest value in each window reaches the database.
26
+ _PROGRESS_THROTTLE_SECONDS = 1.0
27
+
28
+
29
+ class ProgressBar:
30
+ """Counting progress over a known number of work items.
31
+
32
+ bar = ctx.progress_bar(len(orders))
33
+ for order in orders:
34
+ try:
35
+ await charge(order)
36
+ await bar.succeeded()
37
+ except PaymentDeclined:
38
+ await bar.failed()
39
+
40
+ The fraction is derived from the counters and persisted under the same throttle as
41
+ :meth:`JobContext.update_progress`. Creating a bar costs nothing — the first write
42
+ happens on the first increment.
43
+ """
44
+
45
+ __slots__ = ("_ctx", "_total", "_succeeded", "_failed")
46
+
47
+ def __init__(self, ctx: JobContext, total: int) -> None:
48
+ self._ctx = ctx
49
+ self._total = max(0, total)
50
+ self._succeeded = 0
51
+ self._failed = 0
52
+
53
+ @property
54
+ def total(self) -> int:
55
+ return self._total
56
+
57
+ @property
58
+ def succeeded_count(self) -> int:
59
+ return self._succeeded
60
+
61
+ @property
62
+ def failed_count(self) -> int:
63
+ return self._failed
64
+
65
+ @property
66
+ def processed(self) -> int:
67
+ return self._succeeded + self._failed
68
+
69
+ @property
70
+ def fraction(self) -> float:
71
+ if self._total <= 0:
72
+ return 0.0
73
+ return min(1.0, self.processed / self._total)
74
+
75
+ async def succeeded(self, count: int = 1, message: str | None = None) -> None:
76
+ self._succeeded += count
77
+ await self._flush(message)
78
+
79
+ async def failed(self, count: int = 1, message: str | None = None) -> None:
80
+ self._failed += count
81
+ await self._flush(message)
82
+
83
+ async def _flush(self, message: str | None) -> None:
84
+ await self._ctx._write_progress(
85
+ progress=self.fraction,
86
+ message=message,
87
+ succeeded=self._succeeded,
88
+ failed=self._failed,
89
+ total=self._total,
90
+ )
91
+
92
+
93
+ class JobContext:
94
+ """What a handler knows about the execution it is in."""
95
+
96
+ __slots__ = ("_row", "_pool", "_last_progress_at", "_cancel_flag")
97
+
98
+ def __init__(self, row: JobRow, pool: WorkerPool) -> None:
99
+ self._row = row
100
+ self._pool = pool
101
+ self._last_progress_at = 0.0
102
+ self._cancel_flag = False
103
+
104
+ @property
105
+ def job_id(self) -> uuid.UUID:
106
+ return self._row.id
107
+
108
+ @property
109
+ def attempt(self) -> int:
110
+ """1-based: the first execution is attempt 1."""
111
+ return self._row.attempts
112
+
113
+ @property
114
+ def max_attempts(self) -> int:
115
+ return self._row.max_attempts
116
+
117
+ @property
118
+ def queue(self) -> str:
119
+ return self._row.queue
120
+
121
+ @property
122
+ def payload_type(self) -> str:
123
+ return self._row.payload_type
124
+
125
+ @property
126
+ def enqueued_at(self) -> datetime | None:
127
+ return self._row.created_at
128
+
129
+ @property
130
+ def is_last_attempt(self) -> bool:
131
+ return self._row.attempts >= self._row.max_attempts
132
+
133
+ def progress_bar(self, total: int) -> ProgressBar:
134
+ return ProgressBar(self, total)
135
+
136
+ async def update_progress(self, progress: float, message: str | None = None) -> None:
137
+ """Report a 0.0..1.0 fraction. Throttled to one write per second."""
138
+ await self._write_progress(progress=max(0.0, min(1.0, progress)), message=message)
139
+
140
+ async def is_cancellation_requested(self) -> bool:
141
+ """Whether someone asked this job to stop.
142
+
143
+ Poll it inside long loops and raise :class:`JobCancellationError` to end as CANCELLED::
144
+
145
+ if await ctx.is_cancellation_requested():
146
+ raise JobCancellationError()
147
+
148
+ Returning normally after a cancel request is also fine — the job lands in SUCCEEDED
149
+ because the work actually finished.
150
+ """
151
+ if self._cancel_flag:
152
+ return True
153
+ self._cancel_flag = await self._pool._check_cancelled(self._row.id)
154
+ return self._cancel_flag
155
+
156
+ def _mark_cancelled(self) -> None:
157
+ """Set by the ``job_cancel`` listener so the next poll answers without a query."""
158
+ self._cancel_flag = True
159
+
160
+ async def _write_progress(
161
+ self,
162
+ *,
163
+ progress: float,
164
+ message: str | None,
165
+ succeeded: int | None = None,
166
+ failed: int | None = None,
167
+ total: int | None = None,
168
+ ) -> None:
169
+ now = time.monotonic()
170
+ complete = total is not None and succeeded is not None and failed is not None and (
171
+ succeeded + failed >= total
172
+ )
173
+ # Always let the final tick through, so a bar that finishes inside the throttle
174
+ # window doesn't leave the dashboard stuck at 90%.
175
+ if not complete and now - self._last_progress_at < _PROGRESS_THROTTLE_SECONDS:
176
+ return
177
+ self._last_progress_at = now
178
+ await self._pool._report_progress(
179
+ job_id=self._row.id,
180
+ progress=progress,
181
+ message=message,
182
+ succeeded=succeeded,
183
+ failed=failed,
184
+ total=total,
185
+ )
taskscheduler/cron.py ADDED
@@ -0,0 +1,76 @@
1
+ """Cron parsing for recurring definitions.
2
+
3
+ Only used to compute the *first* ``next_trigger_at`` when a definition is registered. Every
4
+ subsequent occurrence is computed by the Kotlin infra leader when it fires the job, so this
5
+ never drives execution — but it must agree with the Kotlin dialect or the first run lands at
6
+ the wrong time.
7
+
8
+ Kotlin (``core/backend/.../cron/CronExpr.kt``) picks its parser by field count:
9
+
10
+ * **5 fields** — classic UNIX ``m h dom month dow``.
11
+ * **6 fields** — the same with a *leading* seconds field (Spring 5.3 dialect, not Quartz:
12
+ day-of-week keeps UNIX numbering and there is no ``?`` placeholder).
13
+
14
+ ``croniter`` defaults to seconds *last* for 6-field expressions, so ``second_at_beginning``
15
+ is required here.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from datetime import datetime, timezone
21
+ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
22
+
23
+ from croniter import CroniterBadCronError, croniter
24
+
25
+ from .errors import ConfigurationError
26
+
27
+ __all__ = ["next_trigger_at", "validate_cron"]
28
+
29
+
30
+ def _resolve_zone(timezone_name: str | None) -> timezone | ZoneInfo:
31
+ if not timezone_name:
32
+ return timezone.utc
33
+ try:
34
+ return ZoneInfo(timezone_name)
35
+ except (ZoneInfoNotFoundError, ValueError) as exc:
36
+ raise ConfigurationError(
37
+ f"unknown IANA timezone {timezone_name!r} — use names like 'Europe/Berlin'"
38
+ ) from exc
39
+
40
+
41
+ def _build(cron: str, base: datetime) -> croniter:
42
+ fields = cron.split()
43
+ if len(fields) not in (5, 6):
44
+ raise ConfigurationError(
45
+ f"cron {cron!r} has {len(fields)} fields — expected 5 (m h dom mon dow) "
46
+ f"or 6 (with leading seconds)"
47
+ )
48
+ try:
49
+ return croniter(cron, base, second_at_beginning=True)
50
+ except (CroniterBadCronError, ValueError) as exc:
51
+ raise ConfigurationError(f"invalid cron expression {cron!r}: {exc}") from exc
52
+
53
+
54
+ def validate_cron(cron: str, timezone_name: str | None = None) -> None:
55
+ """Raise :class:`ConfigurationError` if the expression will not parse."""
56
+ next_trigger_at(cron, timezone_name=timezone_name)
57
+
58
+
59
+ def next_trigger_at(
60
+ cron: str, after: datetime | None = None, timezone_name: str | None = None
61
+ ) -> datetime:
62
+ """First occurrence strictly after ``after``, returned as an aware UTC datetime.
63
+
64
+ ``timezone_name`` is an IANA name; ``None`` means UTC, matching a NULL
65
+ ``recurring_job.timezone``. Evaluating in the target zone is what makes "every day at
66
+ 09:00 Europe/Berlin" hold across a DST change.
67
+ """
68
+ zone = _resolve_zone(timezone_name)
69
+ base = after or datetime.now(timezone.utc)
70
+ if base.tzinfo is None:
71
+ base = base.replace(tzinfo=timezone.utc)
72
+ local_base = base.astimezone(zone)
73
+ nxt: datetime = _build(cron, local_base).get_next(datetime)
74
+ if nxt.tzinfo is None:
75
+ nxt = nxt.replace(tzinfo=zone)
76
+ return nxt.astimezone(timezone.utc)
@@ -0,0 +1,56 @@
1
+ """Exceptions raised by the TaskScheduler Python SDK."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "SchedulerError",
7
+ "ConfigurationError",
8
+ "SchemaMismatchError",
9
+ "PayloadDecodeError",
10
+ "HandlerNotRegisteredError",
11
+ "JobCancellationError",
12
+ "NonRetriableError",
13
+ ]
14
+
15
+
16
+ class SchedulerError(Exception):
17
+ """Base class for every error this SDK raises."""
18
+
19
+
20
+ class ConfigurationError(SchedulerError):
21
+ """Invalid or incomplete configuration — raised eagerly at construction time."""
22
+
23
+
24
+ class SchemaMismatchError(SchedulerError):
25
+ """The database schema is older than this client expects.
26
+
27
+ The Kotlin ``scheduler-infra`` process owns the Flyway schema; this client only
28
+ verifies it. Deploy a matching infra version before starting the Python service.
29
+ """
30
+
31
+
32
+ class PayloadDecodeError(SchedulerError):
33
+ """``payload_json`` could not be turned back into a payload object.
34
+
35
+ Treated as terminal: the bytes will not change, so retrying is pointless. Mirrors the
36
+ Kotlin worker's ``SerializationException`` handling (DESIGN.md 22.9).
37
+ """
38
+
39
+
40
+ class HandlerNotRegisteredError(SchedulerError):
41
+ """No handler is registered for a job's ``payload_type`` on this node.
42
+
43
+ Also terminal — the job is marked FAILED so the dashboard surfaces the
44
+ misconfiguration instead of the message bouncing between nodes.
45
+ """
46
+
47
+
48
+ class JobCancellationError(SchedulerError):
49
+ """Raise from a handler to end the job as CANCELLED rather than FAILED.
50
+
51
+ No retry is scheduled and ``on_final_failure`` is not invoked.
52
+ """
53
+
54
+
55
+ class NonRetriableError(SchedulerError):
56
+ """Raise from a handler to fail terminally, skipping the remaining attempt budget."""