taskferry 0.2.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.
- taskferry/__init__.py +211 -0
- taskferry/aio.py +486 -0
- taskferry/backends/__init__.py +38 -0
- taskferry/backends/inline.py +235 -0
- taskferry/backends/process.py +292 -0
- taskferry/backends/subprocess.py +390 -0
- taskferry/backends/thread.py +351 -0
- taskferry/capabilities.py +90 -0
- taskferry/cli.py +445 -0
- taskferry/config.py +360 -0
- taskferry/contract/__init__.py +56 -0
- taskferry/contract/base.py +179 -0
- taskferry/contract/inline.py +89 -0
- taskferry/contract/job.py +91 -0
- taskferry/contract/task.py +91 -0
- taskferry/core/__init__.py +130 -0
- taskferry/core/capabilities.py +89 -0
- taskferry/core/config.py +167 -0
- taskferry/core/correlation.py +120 -0
- taskferry/core/delivery.py +36 -0
- taskferry/core/errors.py +55 -0
- taskferry/core/ids.py +37 -0
- taskferry/core/observability.py +136 -0
- taskferry/core/otel.py +83 -0
- taskferry/core/provider.py +50 -0
- taskferry/core/py.typed +0 -0
- taskferry/core/registry.py +92 -0
- taskferry/core/serialization.py +79 -0
- taskferry/core/typing.py +16 -0
- taskferry/envelope.py +197 -0
- taskferry/errors.py +144 -0
- taskferry/execution.py +239 -0
- taskferry/functions.py +290 -0
- taskferry/handle.py +186 -0
- taskferry/hooks.py +238 -0
- taskferry/plugins.py +183 -0
- taskferry/ports.py +356 -0
- taskferry/py.typed +0 -0
- taskferry/retry.py +205 -0
- taskferry/router.py +160 -0
- taskferry/runtime.py +609 -0
- taskferry/specs.py +353 -0
- taskferry/tracking.py +129 -0
- taskferry-0.2.0.dist-info/METADATA +109 -0
- taskferry-0.2.0.dist-info/RECORD +48 -0
- taskferry-0.2.0.dist-info/WHEEL +4 -0
- taskferry-0.2.0.dist-info/entry_points.txt +2 -0
- taskferry-0.2.0.dist-info/licenses/LICENSE +201 -0
taskferry/envelope.py
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""The portable task envelope — one wire format, every transport.
|
|
2
|
+
|
|
3
|
+
```mermaid
|
|
4
|
+
flowchart LR
|
|
5
|
+
SPEC["TaskSpec"]
|
|
6
|
+
ENV["envelope<br/>{taskferry, task, args, kwargs, correlation, retry}"]
|
|
7
|
+
|
|
8
|
+
ENV --> PG["Procrastinate job argument"]
|
|
9
|
+
ENV --> CT["Cloud Tasks HTTP body"]
|
|
10
|
+
ENV --> SQS["SQS message body"]
|
|
11
|
+
ENV --> SB["Service Bus message body"]
|
|
12
|
+
ENV --> DQ["Dramatiq actor argument"]
|
|
13
|
+
|
|
14
|
+
SPEC --> ENV
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Five adapters were about to grow the same twenty lines — build a dict, read the
|
|
18
|
+
correlation back, check the version, encode the retry policy. So it lives here
|
|
19
|
+
once, and every transport that can carry a JSON object gets a consistent,
|
|
20
|
+
inspectable payload.
|
|
21
|
+
|
|
22
|
+
What it is not
|
|
23
|
+
--------------
|
|
24
|
+
|
|
25
|
+
This is **not** a queue protocol. It carries no routing, no priority, no
|
|
26
|
+
scheduling and no delivery metadata, because every engine has its own and
|
|
27
|
+
better mechanisms for those — a `queueing_lock`, a `schedule_time`, a
|
|
28
|
+
`MessageGroupId`. The envelope carries only what the *worker* needs in order to
|
|
29
|
+
run the right function with the right arguments. Anything the engine can express
|
|
30
|
+
natively is translated by the adapter, not smuggled through here.
|
|
31
|
+
|
|
32
|
+
Two rules
|
|
33
|
+
---------
|
|
34
|
+
|
|
35
|
+
**JSON only, never pickle.** A payload sits in a PostgreSQL table or an SQS queue
|
|
36
|
+
where an operator can read it, survives a Python upgrade, and cannot become
|
|
37
|
+
arbitrary code execution on the way back in. See
|
|
38
|
+
[ADR-0009](../../../docs/adr/0009-serialization.md).
|
|
39
|
+
|
|
40
|
+
**Version it, and reject what you do not know.** A worker running old code must
|
|
41
|
+
not silently drop a field a newer producer added. :func:`read_envelope` refuses
|
|
42
|
+
an unfamiliar version rather than guessing.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
from __future__ import annotations
|
|
46
|
+
|
|
47
|
+
from typing import Any, TypedDict
|
|
48
|
+
|
|
49
|
+
from .core.correlation import Correlation
|
|
50
|
+
from .core.typing import JSONValue
|
|
51
|
+
from .errors import SerializationError
|
|
52
|
+
from .retry import NO_RETRY, Backoff, RetryPolicy
|
|
53
|
+
from .specs import TaskSpec
|
|
54
|
+
|
|
55
|
+
ENVELOPE_VERSION = "2"
|
|
56
|
+
"""Bumped when the shape changes. Workers reject versions they do not know."""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class Envelope(TypedDict, total=False):
|
|
60
|
+
"""The on-the-wire shape of a task."""
|
|
61
|
+
|
|
62
|
+
taskferry: str
|
|
63
|
+
task: str
|
|
64
|
+
args: list[JSONValue]
|
|
65
|
+
kwargs: dict[str, JSONValue]
|
|
66
|
+
name: str
|
|
67
|
+
queue: str
|
|
68
|
+
correlation: dict[str, str] | None
|
|
69
|
+
labels: dict[str, str]
|
|
70
|
+
retry: dict[str, JSONValue] | None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def build_envelope(spec: TaskSpec) -> Envelope:
|
|
74
|
+
"""Turn a :class:`~taskferry.specs.TaskSpec` into the wire envelope.
|
|
75
|
+
|
|
76
|
+
Arguments were validated as JSON-shaped when the spec was constructed, so
|
|
77
|
+
nothing can fail here that would not already have failed at the call site.
|
|
78
|
+
"""
|
|
79
|
+
return {
|
|
80
|
+
"taskferry": ENVELOPE_VERSION,
|
|
81
|
+
"task": spec.task,
|
|
82
|
+
"args": list(spec.args),
|
|
83
|
+
"kwargs": dict(spec.kwargs),
|
|
84
|
+
"name": spec.name,
|
|
85
|
+
"queue": spec.queue,
|
|
86
|
+
"correlation": spec.correlation.to_headers() if spec.correlation else None,
|
|
87
|
+
"labels": dict(spec.labels),
|
|
88
|
+
"retry": encode_retry(spec.retry),
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def read_envelope(payload: Any) -> Envelope:
|
|
93
|
+
"""Validate an incoming payload and return it as an :class:`Envelope`.
|
|
94
|
+
|
|
95
|
+
Raises:
|
|
96
|
+
SerializationError: when the payload is not an envelope of a version this
|
|
97
|
+
code understands, or carries no task. Failing loudly is deliberate: a
|
|
98
|
+
malformed body means a misconfigured endpoint or a version skew, and
|
|
99
|
+
quietly doing nothing would hide both.
|
|
100
|
+
"""
|
|
101
|
+
if not isinstance(payload, dict):
|
|
102
|
+
raise SerializationError(
|
|
103
|
+
f"a taskferry envelope must be a JSON object, got {type(payload).__name__}"
|
|
104
|
+
)
|
|
105
|
+
version = payload.get("taskferry")
|
|
106
|
+
if version != ENVELOPE_VERSION:
|
|
107
|
+
raise SerializationError(
|
|
108
|
+
f"unsupported taskferry envelope version {version!r} "
|
|
109
|
+
f"(this worker speaks {ENVELOPE_VERSION!r}); upgrade the worker or the producer"
|
|
110
|
+
)
|
|
111
|
+
if not payload.get("task"):
|
|
112
|
+
raise SerializationError("taskferry envelope has no 'task'")
|
|
113
|
+
return payload # type: ignore[return-value]
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def read_correlation(envelope: Envelope) -> Correlation:
|
|
117
|
+
"""Rebuild the correlation, starting a fresh flow when the envelope has none."""
|
|
118
|
+
headers = envelope.get("correlation")
|
|
119
|
+
return Correlation.from_headers(headers) if headers else Correlation.start()
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def encode_retry(policy: RetryPolicy) -> dict[str, JSONValue] | None:
|
|
123
|
+
"""Serialize the portable retry intent, or ``None`` when nothing is asked for.
|
|
124
|
+
|
|
125
|
+
Only what a worker needs to compute the *next* delay travels. The policy
|
|
126
|
+
object itself is not shipped, because a queue payload must not depend on the
|
|
127
|
+
producer and the consumer running the same Taskferry version.
|
|
128
|
+
"""
|
|
129
|
+
if not policy.enabled:
|
|
130
|
+
return None
|
|
131
|
+
return {
|
|
132
|
+
"max_attempts": policy.max_attempts,
|
|
133
|
+
"backoff": policy.backoff.value,
|
|
134
|
+
"initial_delay": policy.initial_delay,
|
|
135
|
+
"max_delay": policy.max_delay,
|
|
136
|
+
"retry_on": list(policy.retry_on),
|
|
137
|
+
"no_retry_on": list(policy.no_retry_on),
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def decode_retry(envelope: Envelope) -> RetryPolicy:
|
|
142
|
+
"""Rebuild the policy on the worker side. Unknown fields are ignored."""
|
|
143
|
+
raw = envelope.get("retry")
|
|
144
|
+
if not raw:
|
|
145
|
+
return NO_RETRY
|
|
146
|
+
return RetryPolicy(
|
|
147
|
+
max_attempts=int(raw.get("max_attempts", 1) or 1), # type: ignore[arg-type]
|
|
148
|
+
backoff=Backoff(str(raw.get("backoff", Backoff.EXPONENTIAL.value))),
|
|
149
|
+
initial_delay=float(raw.get("initial_delay", 1.0) or 0.0), # type: ignore[arg-type]
|
|
150
|
+
max_delay=(
|
|
151
|
+
None if raw.get("max_delay") is None else float(raw["max_delay"]) # type: ignore[arg-type]
|
|
152
|
+
),
|
|
153
|
+
retry_on=tuple(str(name) for name in (raw.get("retry_on") or [])), # type: ignore[union-attr]
|
|
154
|
+
no_retry_on=tuple(str(name) for name in (raw.get("no_retry_on") or [])), # type: ignore[union-attr]
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def execute_envelope(payload: Any, *, registry: Any = None) -> Any:
|
|
159
|
+
"""Validate, resolve and run one envelope. The whole worker side, in one call.
|
|
160
|
+
|
|
161
|
+
Args:
|
|
162
|
+
payload: The decoded JSON body.
|
|
163
|
+
registry: A :class:`~taskferry.functions.FunctionRegistry`. **Give it an
|
|
164
|
+
allowlist** when envelopes arrive from a queue anything untrusted can
|
|
165
|
+
write to — resolving a name means importing a module. See
|
|
166
|
+
``docs/security.md``.
|
|
167
|
+
|
|
168
|
+
Returns:
|
|
169
|
+
Whatever the task returned. Most engines discard it.
|
|
170
|
+
"""
|
|
171
|
+
from .core.correlation import use_correlation
|
|
172
|
+
from .functions import FunctionRegistry, is_async_callable
|
|
173
|
+
|
|
174
|
+
envelope = read_envelope(payload)
|
|
175
|
+
resolver = registry if registry is not None else FunctionRegistry()
|
|
176
|
+
func = resolver.resolve(str(envelope["task"]))
|
|
177
|
+
args = list(envelope.get("args") or [])
|
|
178
|
+
kwargs = dict(envelope.get("kwargs") or {})
|
|
179
|
+
|
|
180
|
+
with use_correlation(read_correlation(envelope)):
|
|
181
|
+
if is_async_callable(func):
|
|
182
|
+
import asyncio
|
|
183
|
+
|
|
184
|
+
return asyncio.run(func(*args, **kwargs))
|
|
185
|
+
return func(*args, **kwargs)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
__all__ = [
|
|
189
|
+
"ENVELOPE_VERSION",
|
|
190
|
+
"Envelope",
|
|
191
|
+
"build_envelope",
|
|
192
|
+
"decode_retry",
|
|
193
|
+
"encode_retry",
|
|
194
|
+
"execute_envelope",
|
|
195
|
+
"read_correlation",
|
|
196
|
+
"read_envelope",
|
|
197
|
+
]
|
taskferry/errors.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""The portable Taskferry error hierarchy.
|
|
2
|
+
|
|
3
|
+
Every error a caller can reasonably catch is a :class:`TaskferryError`. Adapters
|
|
4
|
+
translate provider exceptions into these types and **always** chain the original
|
|
5
|
+
as ``__cause__``::
|
|
6
|
+
|
|
7
|
+
raise SubmissionError("defer failed", backend="procrastinate") from exc
|
|
8
|
+
|
|
9
|
+
Provider exceptions (``google.api_core.GoogleAPIError``, ``psycopg.Error``,
|
|
10
|
+
``kubernetes.client.ApiException`` ...) are never part of Taskferry's public API,
|
|
11
|
+
so application code never has to import a provider SDK to handle a failure.
|
|
12
|
+
|
|
13
|
+
```mermaid
|
|
14
|
+
flowchart TD
|
|
15
|
+
E["TaskferryError"]
|
|
16
|
+
E --> C["ConfigurationError"]
|
|
17
|
+
E --> B["BackendError"]
|
|
18
|
+
B --> S["SubmissionError"]
|
|
19
|
+
B --> X["ExecutionError"]
|
|
20
|
+
B --> NF["ExecutionNotFound"]
|
|
21
|
+
E --> U["UnsupportedCapability"]
|
|
22
|
+
E --> SER["SerializationError"]
|
|
23
|
+
E --> T["TimeoutError"]
|
|
24
|
+
E --> R["RoutingError"]
|
|
25
|
+
E --> F["FunctionResolutionError"]
|
|
26
|
+
E --> CAN["ExecutionCancelled"]
|
|
27
|
+
```
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
from taskferry.core.errors import ConfigurationError, SerializationError, TaskferryError
|
|
33
|
+
from taskferry.core.errors import ProviderError as _ProviderError
|
|
34
|
+
from taskferry.core.errors import UnsupportedCapabilityError as _UnsupportedCapabilityError
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class BackendError(_ProviderError):
|
|
38
|
+
"""A backend failed to carry out an operation.
|
|
39
|
+
|
|
40
|
+
Subclasses ``ProviderError`` so code written against 0.1 keeps working. The
|
|
41
|
+
offending backend is named in :attr:`backend`.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def __init__(self, message: str, *, backend: str | None = None) -> None:
|
|
45
|
+
super().__init__(message, provider=backend)
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def backend(self) -> str | None:
|
|
49
|
+
"""Name of the backend that raised, when known."""
|
|
50
|
+
return self.provider
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class SubmissionError(BackendError):
|
|
54
|
+
"""A spec could not be handed to the backend.
|
|
55
|
+
|
|
56
|
+
Raised at submit time — nothing was enqueued, so retrying the submission is
|
|
57
|
+
safe unless the backend documents otherwise.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class ExecutionError(BackendError):
|
|
62
|
+
"""An execution ran and failed.
|
|
63
|
+
|
|
64
|
+
:attr:`cause_repr` carries the remote exception's textual form when the
|
|
65
|
+
backend can supply it (the real exception object rarely survives a process or
|
|
66
|
+
network boundary).
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
def __init__(
|
|
70
|
+
self,
|
|
71
|
+
message: str,
|
|
72
|
+
*,
|
|
73
|
+
backend: str | None = None,
|
|
74
|
+
cause_repr: str | None = None,
|
|
75
|
+
) -> None:
|
|
76
|
+
super().__init__(message, backend=backend)
|
|
77
|
+
self.cause_repr = cause_repr
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class ExecutionNotFound(BackendError):
|
|
81
|
+
"""No execution with the given id is known to the backend.
|
|
82
|
+
|
|
83
|
+
Distinct from :class:`ExecutionError`: the execution may never have existed,
|
|
84
|
+
or the backend may have expired its record.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class ExecutionCancelled(BackendError):
|
|
89
|
+
"""The execution was cancelled before it could produce a result."""
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
UnsupportedCapability = _UnsupportedCapabilityError
|
|
93
|
+
"""The operation needs a capability the backend does not advertise.
|
|
94
|
+
|
|
95
|
+
Taskferry raises this rather than emulating the capability, because an emulated
|
|
96
|
+
cancel or an emulated timeout is a correctness bug waiting for production.
|
|
97
|
+
|
|
98
|
+
This is an **alias**, not a subclass, of
|
|
99
|
+
:class:`taskferry.core.errors.UnsupportedCapabilityError`. It has to be: a
|
|
100
|
+
subclass would mean ``CapabilitySet.require()`` raises the parent while every
|
|
101
|
+
caller is told to catch the child, so the documented ``except
|
|
102
|
+
UnsupportedCapability`` would silently miss the most common source of the error.
|
|
103
|
+
One concept, one class, two names."""
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class RoutingError(ConfigurationError):
|
|
107
|
+
"""No backend could be selected for a spec, or the selected one is unknown."""
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class FunctionResolutionError(TaskferryError):
|
|
111
|
+
"""A ``package.module:function`` reference could not be resolved.
|
|
112
|
+
|
|
113
|
+
Also raised when a reference is rejected by the import allowlist — the
|
|
114
|
+
message says which, so a deployment problem is never mistaken for a typo.
|
|
115
|
+
"""
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class TaskferryTimeoutError(TaskferryError, TimeoutError):
|
|
119
|
+
"""A ``wait``/``result`` call exceeded its timeout.
|
|
120
|
+
|
|
121
|
+
Subclasses the built-in :class:`TimeoutError` so ``except TimeoutError``
|
|
122
|
+
keeps working for callers that do not import Taskferry's hierarchy.
|
|
123
|
+
"""
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
# Public alias. ``taskferry.TimeoutError`` shadows the builtin only inside an
|
|
127
|
+
# explicit ``from taskferry import TimeoutError``, which is opt-in and explicit.
|
|
128
|
+
TimeoutError = TaskferryTimeoutError
|
|
129
|
+
|
|
130
|
+
__all__ = [
|
|
131
|
+
"BackendError",
|
|
132
|
+
"ConfigurationError",
|
|
133
|
+
"ExecutionCancelled",
|
|
134
|
+
"ExecutionError",
|
|
135
|
+
"ExecutionNotFound",
|
|
136
|
+
"FunctionResolutionError",
|
|
137
|
+
"RoutingError",
|
|
138
|
+
"SerializationError",
|
|
139
|
+
"SubmissionError",
|
|
140
|
+
"TaskferryError",
|
|
141
|
+
"TaskferryTimeoutError",
|
|
142
|
+
"TimeoutError",
|
|
143
|
+
"UnsupportedCapability",
|
|
144
|
+
]
|
taskferry/execution.py
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
"""Execution — the portable identity of one unit of work in flight.
|
|
2
|
+
|
|
3
|
+
Every backend, of every kind, describes what it did in these terms. An
|
|
4
|
+
:class:`Execution` is an immutable *snapshot*: what the backend knew at the
|
|
5
|
+
moment it was asked. An :class:`~taskferry.handle.ExecutionHandle` (see
|
|
6
|
+
``taskferry.handle``) is the live, refreshable reference built on top of it.
|
|
7
|
+
|
|
8
|
+
State machine
|
|
9
|
+
-------------
|
|
10
|
+
|
|
11
|
+
```mermaid
|
|
12
|
+
stateDiagram-v2
|
|
13
|
+
[*] --> PENDING
|
|
14
|
+
PENDING --> QUEUED
|
|
15
|
+
PENDING --> RUNNING
|
|
16
|
+
QUEUED --> RUNNING
|
|
17
|
+
QUEUED --> CANCELLED
|
|
18
|
+
RUNNING --> SUCCEEDED
|
|
19
|
+
RUNNING --> FAILED
|
|
20
|
+
RUNNING --> CANCELLED
|
|
21
|
+
RUNNING --> TIMED_OUT
|
|
22
|
+
FAILED --> QUEUED: retry (engine-owned)
|
|
23
|
+
SUCCEEDED --> [*]
|
|
24
|
+
CANCELLED --> [*]
|
|
25
|
+
TIMED_OUT --> [*]
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Not every backend can observe every transition. An inline execution goes straight
|
|
29
|
+
from ``PENDING`` to a terminal state; a fire-and-forget push queue may only ever
|
|
30
|
+
report ``QUEUED`` and then ``UNKNOWN``. Adapters map what the provider actually
|
|
31
|
+
reports and use :data:`ExecutionState.UNKNOWN` rather than guessing.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
from collections.abc import Mapping
|
|
37
|
+
from dataclasses import dataclass, field, replace
|
|
38
|
+
from datetime import datetime
|
|
39
|
+
from enum import StrEnum
|
|
40
|
+
from types import MappingProxyType
|
|
41
|
+
from typing import Any
|
|
42
|
+
|
|
43
|
+
from taskferry.core.correlation import Correlation
|
|
44
|
+
from taskferry.core.ids import TaskferryId, new_id
|
|
45
|
+
from taskferry.core.provider import ProviderMetadata
|
|
46
|
+
|
|
47
|
+
ExecutionId = TaskferryId
|
|
48
|
+
"""A Taskferry-owned execution id. Never the provider's id — that lives in
|
|
49
|
+
:attr:`Execution.external_id`, so one execution stays followable across systems."""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class ExecutionKind(StrEnum):
|
|
53
|
+
"""Which execution primitive produced this execution."""
|
|
54
|
+
|
|
55
|
+
INLINE = "inline"
|
|
56
|
+
TASK = "task"
|
|
57
|
+
JOB = "job"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class ExecutionState(StrEnum):
|
|
61
|
+
"""Portable lifecycle state. See the diagram in the module docstring."""
|
|
62
|
+
|
|
63
|
+
PENDING = "pending"
|
|
64
|
+
"""Accepted by Taskferry, not yet acknowledged by the engine."""
|
|
65
|
+
|
|
66
|
+
QUEUED = "queued"
|
|
67
|
+
"""Accepted by the engine, waiting for a worker or a slot."""
|
|
68
|
+
|
|
69
|
+
RUNNING = "running"
|
|
70
|
+
CANCELLED = "cancelled"
|
|
71
|
+
SUCCEEDED = "succeeded"
|
|
72
|
+
FAILED = "failed"
|
|
73
|
+
TIMED_OUT = "timed_out"
|
|
74
|
+
|
|
75
|
+
UNKNOWN = "unknown"
|
|
76
|
+
"""The backend cannot determine the state. Not an error and not terminal —
|
|
77
|
+
a later poll may resolve it."""
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def is_terminal(self) -> bool:
|
|
81
|
+
"""Whether no further transition is expected (barring an engine retry)."""
|
|
82
|
+
return self in _TERMINAL
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def is_successful(self) -> bool:
|
|
86
|
+
return self is ExecutionState.SUCCEEDED
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
_TERMINAL = frozenset(
|
|
90
|
+
{
|
|
91
|
+
ExecutionState.SUCCEEDED,
|
|
92
|
+
ExecutionState.FAILED,
|
|
93
|
+
ExecutionState.CANCELLED,
|
|
94
|
+
ExecutionState.TIMED_OUT,
|
|
95
|
+
}
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
#: The transitions Taskferry considers legal. Adapters are not forced through
|
|
99
|
+
#: this table (providers report what they report), but ``Execution.transitions_to``
|
|
100
|
+
#: lets tests and diagnostics flag a mapping that cannot be right.
|
|
101
|
+
_ALLOWED: Mapping[ExecutionState, frozenset[ExecutionState]] = MappingProxyType(
|
|
102
|
+
{
|
|
103
|
+
ExecutionState.PENDING: frozenset(
|
|
104
|
+
{ExecutionState.QUEUED, ExecutionState.RUNNING, ExecutionState.UNKNOWN} | _TERMINAL
|
|
105
|
+
),
|
|
106
|
+
# SUCCEEDED is reachable directly from QUEUED, and not as an edge case:
|
|
107
|
+
# a worker that finishes between the submit and the first poll means the
|
|
108
|
+
# observer never sees RUNNING at all. Every real engine does this
|
|
109
|
+
# constantly. A transition table that forbids it would flag correct
|
|
110
|
+
# adapters as broken.
|
|
111
|
+
ExecutionState.QUEUED: frozenset(
|
|
112
|
+
{ExecutionState.RUNNING, ExecutionState.UNKNOWN} | _TERMINAL
|
|
113
|
+
),
|
|
114
|
+
ExecutionState.RUNNING: frozenset(
|
|
115
|
+
{ExecutionState.UNKNOWN} | _TERMINAL,
|
|
116
|
+
),
|
|
117
|
+
# An engine-owned retry legitimately moves a failed execution back into
|
|
118
|
+
# the queue; see ADR-0011 on retry ownership.
|
|
119
|
+
ExecutionState.FAILED: frozenset({ExecutionState.QUEUED, ExecutionState.RUNNING}),
|
|
120
|
+
ExecutionState.TIMED_OUT: frozenset({ExecutionState.QUEUED}),
|
|
121
|
+
ExecutionState.SUCCEEDED: frozenset(),
|
|
122
|
+
ExecutionState.CANCELLED: frozenset(),
|
|
123
|
+
ExecutionState.UNKNOWN: frozenset(ExecutionState),
|
|
124
|
+
}
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@dataclass(frozen=True, slots=True)
|
|
129
|
+
class ExecutionResult:
|
|
130
|
+
"""The outcome of a finished execution.
|
|
131
|
+
|
|
132
|
+
``value`` is only meaningful when the backend advertises
|
|
133
|
+
:attr:`~taskferry.capabilities.Capability.RESULT`; otherwise it stays ``None``
|
|
134
|
+
and callers must not read anything into that.
|
|
135
|
+
"""
|
|
136
|
+
|
|
137
|
+
value: Any = None
|
|
138
|
+
error: str | None = None
|
|
139
|
+
error_type: str | None = None
|
|
140
|
+
traceback: str | None = None
|
|
141
|
+
exit_code: int | None = None
|
|
142
|
+
logs_uri: str | None = None
|
|
143
|
+
|
|
144
|
+
@classmethod
|
|
145
|
+
def from_exception(cls, exc: BaseException) -> ExecutionResult:
|
|
146
|
+
"""Build a failed result from a raised exception.
|
|
147
|
+
|
|
148
|
+
Captures the message, qualified type name and formatted traceback the
|
|
149
|
+
same way every in-process backend needs, so the construction lives here
|
|
150
|
+
once instead of being copied into each one.
|
|
151
|
+
"""
|
|
152
|
+
import traceback as _traceback
|
|
153
|
+
|
|
154
|
+
return cls(
|
|
155
|
+
error=str(exc) or type(exc).__name__,
|
|
156
|
+
error_type=type(exc).__qualname__,
|
|
157
|
+
traceback="".join(_traceback.format_exception(type(exc), exc, exc.__traceback__)),
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
@classmethod
|
|
161
|
+
def cancelled(cls) -> ExecutionResult:
|
|
162
|
+
"""The placeholder result for an execution cancelled before it produced one."""
|
|
163
|
+
return cls(error="execution was cancelled", error_type="ExecutionCancelled")
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
@dataclass(frozen=True, slots=True)
|
|
167
|
+
class Execution:
|
|
168
|
+
"""An immutable snapshot of one unit of work.
|
|
169
|
+
|
|
170
|
+
Attributes:
|
|
171
|
+
id: Taskferry-owned id, stable for the life of the execution.
|
|
172
|
+
kind: Which primitive produced it.
|
|
173
|
+
backend: Name of the backend that owns it (``"local"``, ``"procrastinate"``).
|
|
174
|
+
state: Portable lifecycle state at snapshot time.
|
|
175
|
+
name: Human-readable name — the task path or the job name.
|
|
176
|
+
created_at: When Taskferry accepted the submission.
|
|
177
|
+
started_at: When the engine began running it, if reported.
|
|
178
|
+
finished_at: When it reached a terminal state, if reported.
|
|
179
|
+
external_id: The engine's own id (Procrastinate job id, Cloud Run
|
|
180
|
+
execution name, Cloud Tasks task name).
|
|
181
|
+
attempt: 1-based attempt counter, when the engine reports one.
|
|
182
|
+
result: Outcome, populated once terminal and if the backend can supply it.
|
|
183
|
+
correlation: Correlation propagated from the spec.
|
|
184
|
+
provider_metadata: Region/resource/labels for the underlying resource.
|
|
185
|
+
metadata: Free-form backend annotations. JSON-shaped by convention.
|
|
186
|
+
"""
|
|
187
|
+
|
|
188
|
+
id: ExecutionId
|
|
189
|
+
kind: ExecutionKind
|
|
190
|
+
backend: str
|
|
191
|
+
state: ExecutionState
|
|
192
|
+
name: str = ""
|
|
193
|
+
created_at: datetime | None = None
|
|
194
|
+
started_at: datetime | None = None
|
|
195
|
+
finished_at: datetime | None = None
|
|
196
|
+
external_id: str | None = None
|
|
197
|
+
attempt: int = 1
|
|
198
|
+
result: ExecutionResult | None = None
|
|
199
|
+
correlation: Correlation | None = None
|
|
200
|
+
provider_metadata: ProviderMetadata | None = None
|
|
201
|
+
metadata: Mapping[str, str] = field(default_factory=dict)
|
|
202
|
+
|
|
203
|
+
def __post_init__(self) -> None:
|
|
204
|
+
object.__setattr__(self, "metadata", MappingProxyType(dict(self.metadata)))
|
|
205
|
+
|
|
206
|
+
@property
|
|
207
|
+
def is_terminal(self) -> bool:
|
|
208
|
+
return self.state.is_terminal
|
|
209
|
+
|
|
210
|
+
@property
|
|
211
|
+
def is_successful(self) -> bool:
|
|
212
|
+
return self.state.is_successful
|
|
213
|
+
|
|
214
|
+
def transitions_to(self, state: ExecutionState) -> bool:
|
|
215
|
+
"""Whether moving from this snapshot's state to ``state`` is legal.
|
|
216
|
+
|
|
217
|
+
Used by the contract suite to catch adapters that map provider states
|
|
218
|
+
onto impossible transitions (``SUCCEEDED`` → ``RUNNING``, say).
|
|
219
|
+
"""
|
|
220
|
+
return state is self.state or state in _ALLOWED[self.state]
|
|
221
|
+
|
|
222
|
+
def evolve(self, **changes: Any) -> Execution:
|
|
223
|
+
"""Return a copy with ``changes`` applied. The original is never mutated."""
|
|
224
|
+
return replace(self, **changes)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def new_execution_id(kind: ExecutionKind = ExecutionKind.TASK) -> ExecutionId:
|
|
228
|
+
"""Mint a fresh execution id prefixed with the kind (``task_``, ``job_``)."""
|
|
229
|
+
return new_id(kind.value)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
__all__ = [
|
|
233
|
+
"Execution",
|
|
234
|
+
"ExecutionId",
|
|
235
|
+
"ExecutionKind",
|
|
236
|
+
"ExecutionResult",
|
|
237
|
+
"ExecutionState",
|
|
238
|
+
"new_execution_id",
|
|
239
|
+
]
|