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.
- taskscheduler/__init__.py +141 -0
- taskscheduler/config.py +190 -0
- taskscheduler/context.py +185 -0
- taskscheduler/cron.py +76 -0
- taskscheduler/errors.py +56 -0
- taskscheduler/events.py +159 -0
- taskscheduler/job.py +207 -0
- taskscheduler/models.py +119 -0
- taskscheduler/retry.py +108 -0
- taskscheduler/scheduler.py +558 -0
- taskscheduler/serde.py +260 -0
- taskscheduler/storage.py +854 -0
- taskscheduler/transport.py +178 -0
- taskscheduler/worker.py +551 -0
- taskscheduler_client-0.6.0.dist-info/METADATA +325 -0
- taskscheduler_client-0.6.0.dist-info/RECORD +17 -0
- taskscheduler_client-0.6.0.dist-info/WHEEL +4 -0
|
@@ -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
|
+
]
|
taskscheduler/config.py
ADDED
|
@@ -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
|
+
)
|
taskscheduler/context.py
ADDED
|
@@ -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)
|
taskscheduler/errors.py
ADDED
|
@@ -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."""
|