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/__init__.py
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""Taskferry — a portable execution layer for Python.
|
|
2
|
+
|
|
3
|
+
Taskferry models units of work, picks the right kind of execution, and routes them
|
|
4
|
+
to engines that already exist. It is not a task queue, not a worker system, not a
|
|
5
|
+
scheduler and not a workflow engine. Its whole value is that your application
|
|
6
|
+
never has to name one.
|
|
7
|
+
|
|
8
|
+
```mermaid
|
|
9
|
+
flowchart LR
|
|
10
|
+
APP["Application / library"]
|
|
11
|
+
TP["Taskferry"]
|
|
12
|
+
ENGINE["Execution engine"]
|
|
13
|
+
INFRA["Infrastructure"]
|
|
14
|
+
|
|
15
|
+
APP -->|"what to run"| TP
|
|
16
|
+
TP -->|"how and where"| ENGINE
|
|
17
|
+
ENGINE --> INFRA
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Three primitives, deliberately not collapsed into one:
|
|
21
|
+
|
|
22
|
+
```mermaid
|
|
23
|
+
flowchart TD
|
|
24
|
+
TP["Taskferry"]
|
|
25
|
+
|
|
26
|
+
TP --> INLINE["Inline<br/>run it here, now"]
|
|
27
|
+
TP --> TASK["Task<br/>a named function, on an engine"]
|
|
28
|
+
TP --> JOB["Job<br/>a container/process, to completion"]
|
|
29
|
+
|
|
30
|
+
TASK --> PRO["Procrastinate"]
|
|
31
|
+
TASK --> CT["Cloud Tasks"]
|
|
32
|
+
TASK --> CELERY["Celery · Django Tasks · ..."]
|
|
33
|
+
|
|
34
|
+
JOB --> CR["Cloud Run Jobs"]
|
|
35
|
+
JOB --> K8S["Kubernetes Jobs"]
|
|
36
|
+
JOB --> LOCAL["local process"]
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Sixty seconds in
|
|
40
|
+
----------------
|
|
41
|
+
|
|
42
|
+
from taskferry import Taskferry
|
|
43
|
+
|
|
44
|
+
runtime = Taskferry.local()
|
|
45
|
+
|
|
46
|
+
def add(a: int, b: int) -> int:
|
|
47
|
+
return a + b
|
|
48
|
+
|
|
49
|
+
execution = runtime.inline.submit(add, 20, 22)
|
|
50
|
+
assert execution.result().value == 42
|
|
51
|
+
|
|
52
|
+
That needs no Django, no PostgreSQL, no Redis, no Procrastinate, no cloud account
|
|
53
|
+
and no worker process — and `import taskferry` imports none of them either, ever.
|
|
54
|
+
Adapters are separate distributions (``taskferry-procrastinate``,
|
|
55
|
+
``taskferry-cloudrun``, ``taskferry-django``, ...) that depend on Taskferry, never
|
|
56
|
+
the other way around.
|
|
57
|
+
|
|
58
|
+
Guarantees, stated plainly
|
|
59
|
+
--------------------------
|
|
60
|
+
|
|
61
|
+
Taskferry promises **at-least-once or at-most-once, depending on the backend**,
|
|
62
|
+
and never exactly-once — no distributed system can honestly offer that. Every
|
|
63
|
+
backend declares its real delivery semantics, and
|
|
64
|
+
:attr:`~taskferry.specs.ExecutionSpec.idempotency_key` is offered as a tool for
|
|
65
|
+
building idempotency, not as a guarantee. See ADR-0010.
|
|
66
|
+
|
|
67
|
+
Async
|
|
68
|
+
-----
|
|
69
|
+
|
|
70
|
+
from taskferry import AsyncTaskferry
|
|
71
|
+
|
|
72
|
+
runtime = AsyncTaskferry.local()
|
|
73
|
+
handle = await runtime.tasks.submit("myapp.tasks:send_email", 42)
|
|
74
|
+
execution = await handle.wait(30)
|
|
75
|
+
|
|
76
|
+
Same method names as the sync runtime — the difference is `await`, not the
|
|
77
|
+
vocabulary. See :mod:`taskferry.aio` and ADR-0015.
|
|
78
|
+
|
|
79
|
+
Everything a normal application needs is importable from here. Deeper modules
|
|
80
|
+
(:mod:`taskferry.ports`, :mod:`taskferry.contract`, :mod:`taskferry.core`) are for
|
|
81
|
+
people writing adapters.
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
from __future__ import annotations
|
|
85
|
+
|
|
86
|
+
from .aio import AsyncExecutionHandle, AsyncTaskferry
|
|
87
|
+
from .capabilities import Capability, CapabilitySet
|
|
88
|
+
from .config import BackendConfig, TaskferryConfig
|
|
89
|
+
from .core.correlation import Correlation, current_correlation, ensure_correlation, use_correlation
|
|
90
|
+
from .core.delivery import DeliveryGuarantee, Ordering
|
|
91
|
+
from .core.provider import ProviderMetadata
|
|
92
|
+
from .core.serialization import JsonSerializer, Serializer
|
|
93
|
+
from .errors import (
|
|
94
|
+
BackendError,
|
|
95
|
+
ConfigurationError,
|
|
96
|
+
ExecutionCancelled,
|
|
97
|
+
ExecutionError,
|
|
98
|
+
ExecutionNotFound,
|
|
99
|
+
FunctionResolutionError,
|
|
100
|
+
RoutingError,
|
|
101
|
+
SerializationError,
|
|
102
|
+
SubmissionError,
|
|
103
|
+
TaskferryError,
|
|
104
|
+
TaskferryTimeoutError,
|
|
105
|
+
UnsupportedCapability,
|
|
106
|
+
)
|
|
107
|
+
from .execution import (
|
|
108
|
+
Execution,
|
|
109
|
+
ExecutionId,
|
|
110
|
+
ExecutionKind,
|
|
111
|
+
ExecutionResult,
|
|
112
|
+
ExecutionState,
|
|
113
|
+
)
|
|
114
|
+
from .functions import FunctionRef, FunctionRegistry
|
|
115
|
+
from .handle import ExecutionHandle
|
|
116
|
+
from .hooks import BaseHook, Hook, HookChain, LoggingHook
|
|
117
|
+
from .plugins import available_backends, register_backend, unregister_backend
|
|
118
|
+
from .ports import BaseBackend, ExecutionBackend, InlineBackend, JobBackend, TaskBackend
|
|
119
|
+
from .retry import Backoff, RetryOwner, RetryPolicy, TimeoutPolicy
|
|
120
|
+
from .router import Route, Router
|
|
121
|
+
from .runtime import Taskferry
|
|
122
|
+
from .specs import (
|
|
123
|
+
AnySpec,
|
|
124
|
+
BackendOptions,
|
|
125
|
+
ExecutionSpec,
|
|
126
|
+
InlineSpec,
|
|
127
|
+
JobSpec,
|
|
128
|
+
Resources,
|
|
129
|
+
TaskSpec,
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
__version__ = "0.2.0"
|
|
133
|
+
|
|
134
|
+
# Grouped by concept rather than alphabetically: this list doubles as the map of
|
|
135
|
+
# the public API, and "everything about executions, together" is far more useful
|
|
136
|
+
# to a reader than strict alphabetical order would be.
|
|
137
|
+
__all__ = [ # noqa: RUF022 - deliberately grouped, see above
|
|
138
|
+
# runtime
|
|
139
|
+
"Taskferry",
|
|
140
|
+
"AsyncTaskferry",
|
|
141
|
+
"TaskferryConfig",
|
|
142
|
+
"BackendConfig",
|
|
143
|
+
"Route",
|
|
144
|
+
"Router",
|
|
145
|
+
# specs
|
|
146
|
+
"AnySpec",
|
|
147
|
+
"BackendOptions",
|
|
148
|
+
"ExecutionSpec",
|
|
149
|
+
"InlineSpec",
|
|
150
|
+
"JobSpec",
|
|
151
|
+
"Resources",
|
|
152
|
+
"TaskSpec",
|
|
153
|
+
# executions
|
|
154
|
+
"Execution",
|
|
155
|
+
"ExecutionHandle",
|
|
156
|
+
"AsyncExecutionHandle",
|
|
157
|
+
"ExecutionId",
|
|
158
|
+
"ExecutionKind",
|
|
159
|
+
"ExecutionResult",
|
|
160
|
+
"ExecutionState",
|
|
161
|
+
# policies
|
|
162
|
+
"Backoff",
|
|
163
|
+
"RetryOwner",
|
|
164
|
+
"RetryPolicy",
|
|
165
|
+
"TimeoutPolicy",
|
|
166
|
+
# capabilities
|
|
167
|
+
"Capability",
|
|
168
|
+
"CapabilitySet",
|
|
169
|
+
# ports (for adapter authors)
|
|
170
|
+
"BaseBackend",
|
|
171
|
+
"ExecutionBackend",
|
|
172
|
+
"InlineBackend",
|
|
173
|
+
"JobBackend",
|
|
174
|
+
"TaskBackend",
|
|
175
|
+
# functions
|
|
176
|
+
"FunctionRef",
|
|
177
|
+
"FunctionRegistry",
|
|
178
|
+
# observability
|
|
179
|
+
"BaseHook",
|
|
180
|
+
"Correlation",
|
|
181
|
+
"Hook",
|
|
182
|
+
"HookChain",
|
|
183
|
+
"LoggingHook",
|
|
184
|
+
"current_correlation",
|
|
185
|
+
"ensure_correlation",
|
|
186
|
+
"use_correlation",
|
|
187
|
+
# semantics + serialization
|
|
188
|
+
"DeliveryGuarantee",
|
|
189
|
+
"JsonSerializer",
|
|
190
|
+
"Ordering",
|
|
191
|
+
"ProviderMetadata",
|
|
192
|
+
"Serializer",
|
|
193
|
+
# plugins
|
|
194
|
+
"available_backends",
|
|
195
|
+
"register_backend",
|
|
196
|
+
"unregister_backend",
|
|
197
|
+
# errors
|
|
198
|
+
"BackendError",
|
|
199
|
+
"ConfigurationError",
|
|
200
|
+
"ExecutionCancelled",
|
|
201
|
+
"ExecutionError",
|
|
202
|
+
"ExecutionNotFound",
|
|
203
|
+
"FunctionResolutionError",
|
|
204
|
+
"RoutingError",
|
|
205
|
+
"SerializationError",
|
|
206
|
+
"SubmissionError",
|
|
207
|
+
"TaskferryError",
|
|
208
|
+
"TaskferryTimeoutError",
|
|
209
|
+
"UnsupportedCapability",
|
|
210
|
+
"__version__",
|
|
211
|
+
]
|
taskferry/aio.py
ADDED
|
@@ -0,0 +1,486 @@
|
|
|
1
|
+
"""The async surface: `AsyncTaskferry`, with the same method names.
|
|
2
|
+
|
|
3
|
+
```python
|
|
4
|
+
from taskferry import AsyncTaskferry
|
|
5
|
+
|
|
6
|
+
runtime = AsyncTaskferry.local()
|
|
7
|
+
|
|
8
|
+
handle = await runtime.tasks.submit("myapp.tasks:send_email", 42)
|
|
9
|
+
execution = await handle.wait(30)
|
|
10
|
+
value = await handle.value()
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Two runtimes, one vocabulary
|
|
14
|
+
----------------------------
|
|
15
|
+
|
|
16
|
+
`AsyncTaskferry` mirrors :class:`~taskferry.runtime.Taskferry` method for method,
|
|
17
|
+
with the *same names*. That is deliberate, and it is why there is a second class
|
|
18
|
+
rather than an `asubmit`/`aget`/`acancel` prefix soup bolted onto the first:
|
|
19
|
+
|
|
20
|
+
* `await` is a keyword, so a prefixed `wait` has nowhere to go — `await_()`,
|
|
21
|
+
`awaited()` and `wait_async()` are all worse than `wait()`;
|
|
22
|
+
* porting a module between the two surfaces becomes adding or removing `await`,
|
|
23
|
+
not rewriting every call site;
|
|
24
|
+
* it is the convention Python has settled on — `httpx.Client` /
|
|
25
|
+
`httpx.AsyncClient`, `redis.Redis` / `redis.asyncio.Redis`.
|
|
26
|
+
|
|
27
|
+
The adapter-facing port keeps the `a`-prefixed names (`asubmit`, `aget`,
|
|
28
|
+
`acancel`, `aresult`, `await_`), because there the two surfaces sit on **one**
|
|
29
|
+
object and have to be told apart. Adapter authors read those; application
|
|
30
|
+
developers do not. See ADR-0015.
|
|
31
|
+
|
|
32
|
+
```mermaid
|
|
33
|
+
flowchart TD
|
|
34
|
+
APP["async application"]
|
|
35
|
+
ART["AsyncTaskferry"]
|
|
36
|
+
RT["Taskferry<br/>routing · config · backends"]
|
|
37
|
+
BE["backend.asubmit()"]
|
|
38
|
+
|
|
39
|
+
APP -->|"await tasks.submit(...)"| ART
|
|
40
|
+
ART -->|"shares everything"| RT
|
|
41
|
+
ART --> BE
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
What is shared, and what is not
|
|
45
|
+
-------------------------------
|
|
46
|
+
|
|
47
|
+
An `AsyncTaskferry` **wraps** a sync runtime rather than duplicating it. Routing,
|
|
48
|
+
configuration, the backend cache, the execution index, hooks and the function
|
|
49
|
+
registry are the same objects — so a process can hold one runtime and expose both
|
|
50
|
+
surfaces without two connection pools, two backend instances or two views of
|
|
51
|
+
which execution went where:
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
runtime = Taskferry.local()
|
|
55
|
+
aio = AsyncTaskferry(runtime) # same backends, same everything
|
|
56
|
+
|
|
57
|
+
handle = runtime.tasks.submit(fn, 1) # from a management command
|
|
58
|
+
handle = await aio.tasks.submit(fn, 1) # from an async view
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Blocking, honestly
|
|
62
|
+
------------------
|
|
63
|
+
|
|
64
|
+
A backend's async path defaults to `asyncio.to_thread` around its sync path (see
|
|
65
|
+
:class:`~taskferry.ports.BaseBackend`). That is a real async path — the caller's
|
|
66
|
+
event loop keeps running — not an `async def` painted over a blocking call. An
|
|
67
|
+
adapter with a natively async client overrides it and skips the thread; nothing
|
|
68
|
+
here depends on whether it has.
|
|
69
|
+
|
|
70
|
+
The one thing this cannot fix is an `async def` **task function** running on an
|
|
71
|
+
in-process backend: the thread pool runs it with `asyncio.run` on its own loop,
|
|
72
|
+
because that is what a worker in another process would do too.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
from __future__ import annotations
|
|
76
|
+
|
|
77
|
+
import asyncio
|
|
78
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
79
|
+
from datetime import datetime, timedelta
|
|
80
|
+
from types import TracebackType
|
|
81
|
+
from typing import Any, Self
|
|
82
|
+
|
|
83
|
+
from .capabilities import Capability, CapabilitySet
|
|
84
|
+
from .config import TaskferryConfig
|
|
85
|
+
from .core.correlation import Correlation
|
|
86
|
+
from .core.typing import JSONValue
|
|
87
|
+
from .errors import ExecutionError, UnsupportedCapability
|
|
88
|
+
from .execution import Execution, ExecutionId, ExecutionKind, ExecutionResult, ExecutionState
|
|
89
|
+
from .functions import FunctionRef
|
|
90
|
+
from .ports import ExecutionBackend
|
|
91
|
+
from .retry import NO_RETRY, NO_TIMEOUT, RetryPolicy, TimeoutPolicy
|
|
92
|
+
from .router import Router
|
|
93
|
+
from .runtime import Taskferry, _as_backend_options, _as_timedelta, _as_timeout
|
|
94
|
+
from .specs import AnySpec, BackendOptions, InlineSpec, JobSpec, Resources, TaskSpec
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class AsyncExecutionHandle:
|
|
98
|
+
"""The async twin of :class:`~taskferry.handle.ExecutionHandle`.
|
|
99
|
+
|
|
100
|
+
Same properties, same method names; the methods that touch the backend are
|
|
101
|
+
coroutines. Cheap, non-blocking reads (:attr:`id`, :attr:`state`,
|
|
102
|
+
:attr:`done`) stay plain attributes — making them coroutines would force an
|
|
103
|
+
`await` on data already in memory.
|
|
104
|
+
"""
|
|
105
|
+
|
|
106
|
+
__slots__ = ("_backend", "_execution", "_lock")
|
|
107
|
+
|
|
108
|
+
def __init__(self, execution: Execution, backend: ExecutionBackend) -> None:
|
|
109
|
+
self._execution = execution
|
|
110
|
+
self._backend = backend
|
|
111
|
+
self._lock = asyncio.Lock()
|
|
112
|
+
|
|
113
|
+
# -- identity (no I/O, no await) ------------------------------------------ #
|
|
114
|
+
@property
|
|
115
|
+
def id(self) -> ExecutionId:
|
|
116
|
+
return self._execution.id
|
|
117
|
+
|
|
118
|
+
@property
|
|
119
|
+
def kind(self) -> ExecutionKind:
|
|
120
|
+
return self._execution.kind
|
|
121
|
+
|
|
122
|
+
@property
|
|
123
|
+
def name(self) -> str:
|
|
124
|
+
return self._execution.name
|
|
125
|
+
|
|
126
|
+
@property
|
|
127
|
+
def backend(self) -> str:
|
|
128
|
+
return self._backend.name
|
|
129
|
+
|
|
130
|
+
@property
|
|
131
|
+
def capabilities(self) -> CapabilitySet:
|
|
132
|
+
return self._backend.capabilities
|
|
133
|
+
|
|
134
|
+
@property
|
|
135
|
+
def external_id(self) -> str | None:
|
|
136
|
+
return self._execution.external_id
|
|
137
|
+
|
|
138
|
+
@property
|
|
139
|
+
def execution(self) -> Execution:
|
|
140
|
+
"""The most recent snapshot. Does not contact the backend."""
|
|
141
|
+
return self._execution
|
|
142
|
+
|
|
143
|
+
@property
|
|
144
|
+
def state(self) -> ExecutionState:
|
|
145
|
+
return self._execution.state
|
|
146
|
+
|
|
147
|
+
@property
|
|
148
|
+
def done(self) -> bool:
|
|
149
|
+
return self._execution.is_terminal
|
|
150
|
+
|
|
151
|
+
def __repr__(self) -> str:
|
|
152
|
+
return (
|
|
153
|
+
f"<AsyncExecutionHandle {self._execution.kind.value} {self._execution.id} "
|
|
154
|
+
f"backend={self.backend!r} state={self._execution.state.value!r}>"
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
# -- live operations ------------------------------------------------------- #
|
|
158
|
+
async def refresh(self) -> Execution:
|
|
159
|
+
"""Re-read from the backend. A terminal snapshot is never re-read."""
|
|
160
|
+
async with self._lock:
|
|
161
|
+
if self._execution.is_terminal:
|
|
162
|
+
return self._execution
|
|
163
|
+
fresh = await self._backend.aget(self.id)
|
|
164
|
+
async with self._lock:
|
|
165
|
+
self._execution = fresh
|
|
166
|
+
return fresh
|
|
167
|
+
|
|
168
|
+
async def status(self) -> ExecutionState:
|
|
169
|
+
return (await self.refresh()).state
|
|
170
|
+
|
|
171
|
+
async def wait(self, timeout: float | None = None) -> Execution:
|
|
172
|
+
"""Await terminal state without blocking the event loop."""
|
|
173
|
+
async with self._lock:
|
|
174
|
+
if self._execution.is_terminal:
|
|
175
|
+
return self._execution
|
|
176
|
+
final = await self._backend.await_(self.id, timeout=timeout)
|
|
177
|
+
async with self._lock:
|
|
178
|
+
self._execution = final
|
|
179
|
+
return final
|
|
180
|
+
|
|
181
|
+
async def result(self, timeout: float | None = None) -> ExecutionResult:
|
|
182
|
+
"""Return the outcome, raising :class:`~taskferry.errors.ExecutionError` on failure."""
|
|
183
|
+
outcome = await self._backend.aresult(self.id, timeout=timeout)
|
|
184
|
+
if outcome.error is not None:
|
|
185
|
+
raise ExecutionError(
|
|
186
|
+
f"{self.kind.value} {self.id} failed: {outcome.error}",
|
|
187
|
+
backend=self.backend,
|
|
188
|
+
cause_repr=outcome.traceback or outcome.error,
|
|
189
|
+
)
|
|
190
|
+
return outcome
|
|
191
|
+
|
|
192
|
+
async def value(self, timeout: float | None = None) -> Any:
|
|
193
|
+
"""The value the execution produced."""
|
|
194
|
+
return (await self.result(timeout)).value
|
|
195
|
+
|
|
196
|
+
async def cancel(self) -> Execution:
|
|
197
|
+
if not self._backend.capabilities.supports(Capability.CANCEL):
|
|
198
|
+
raise UnsupportedCapability(Capability.CANCEL.value, provider=self.backend)
|
|
199
|
+
cancelled = await self._backend.acancel(self.id)
|
|
200
|
+
async with self._lock:
|
|
201
|
+
self._execution = cancelled
|
|
202
|
+
return cancelled
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
class AsyncTaskferry:
|
|
206
|
+
"""The portable execution layer, for async applications.
|
|
207
|
+
|
|
208
|
+
Args:
|
|
209
|
+
runtime: An existing :class:`~taskferry.runtime.Taskferry` to share. When
|
|
210
|
+
omitted, one is built from ``**kwargs`` exactly as the sync
|
|
211
|
+
constructor would.
|
|
212
|
+
|
|
213
|
+
Sharing a runtime is the recommended pattern in a process that has both an
|
|
214
|
+
async web surface and sync management commands — one set of backends, one
|
|
215
|
+
connection pool, one execution index.
|
|
216
|
+
"""
|
|
217
|
+
|
|
218
|
+
def __init__(self, runtime: Taskferry | None = None, /, **kwargs: Any) -> None:
|
|
219
|
+
self._runtime = runtime if runtime is not None else Taskferry(**kwargs)
|
|
220
|
+
self.inline = AsyncInlineFacade(self)
|
|
221
|
+
self.tasks = AsyncTaskFacade(self)
|
|
222
|
+
self.jobs = AsyncJobFacade(self)
|
|
223
|
+
|
|
224
|
+
# -- constructors ---------------------------------------------------------- #
|
|
225
|
+
@classmethod
|
|
226
|
+
def local(cls, **kwargs: Any) -> AsyncTaskferry:
|
|
227
|
+
"""A runtime that needs no infrastructure. See :meth:`Taskferry.local`."""
|
|
228
|
+
return cls(Taskferry.local(**kwargs))
|
|
229
|
+
|
|
230
|
+
@classmethod
|
|
231
|
+
def from_env(cls, environ: Mapping[str, str] | None = None, **kwargs: Any) -> AsyncTaskferry:
|
|
232
|
+
return cls(Taskferry.from_env(environ, **kwargs))
|
|
233
|
+
|
|
234
|
+
@classmethod
|
|
235
|
+
def from_mapping(cls, data: Mapping[str, Any], **kwargs: Any) -> AsyncTaskferry:
|
|
236
|
+
return cls(Taskferry.from_mapping(data, **kwargs))
|
|
237
|
+
|
|
238
|
+
# -- introspection (shared, synchronous — no I/O) --------------------------- #
|
|
239
|
+
@property
|
|
240
|
+
def sync(self) -> Taskferry:
|
|
241
|
+
"""The underlying sync runtime. Use it from sync code in the same process."""
|
|
242
|
+
return self._runtime
|
|
243
|
+
|
|
244
|
+
@property
|
|
245
|
+
def config(self) -> TaskferryConfig:
|
|
246
|
+
return self._runtime.config
|
|
247
|
+
|
|
248
|
+
@property
|
|
249
|
+
def router(self) -> Router:
|
|
250
|
+
return self._runtime.router
|
|
251
|
+
|
|
252
|
+
@property
|
|
253
|
+
def registry(self) -> Any:
|
|
254
|
+
return self._runtime.registry
|
|
255
|
+
|
|
256
|
+
def backend_names(self) -> tuple[str, ...]:
|
|
257
|
+
return self._runtime.backend_names()
|
|
258
|
+
|
|
259
|
+
def backend(self, name: str) -> ExecutionBackend:
|
|
260
|
+
return self._runtime.backend(name)
|
|
261
|
+
|
|
262
|
+
def capabilities(self, name: str) -> CapabilitySet:
|
|
263
|
+
return self._runtime.capabilities(name)
|
|
264
|
+
|
|
265
|
+
def describe(self) -> dict[str, Any]:
|
|
266
|
+
return self._runtime.describe()
|
|
267
|
+
|
|
268
|
+
def __repr__(self) -> str:
|
|
269
|
+
return f"<AsyncTaskferry backends={list(self.backend_names())}>"
|
|
270
|
+
|
|
271
|
+
# -- submission -------------------------------------------------------------- #
|
|
272
|
+
async def submit(
|
|
273
|
+
self,
|
|
274
|
+
spec: AnySpec,
|
|
275
|
+
*,
|
|
276
|
+
backend: str | None = None,
|
|
277
|
+
idempotency_key: str | None = None,
|
|
278
|
+
) -> AsyncExecutionHandle:
|
|
279
|
+
"""Route ``spec`` to a backend and submit it without blocking the loop."""
|
|
280
|
+
target, resolved, prepared = self._runtime._prepare(
|
|
281
|
+
spec, backend=backend, idempotency_key=idempotency_key
|
|
282
|
+
)
|
|
283
|
+
execution = await target.asubmit(prepared)
|
|
284
|
+
self._runtime._track(execution.id, resolved)
|
|
285
|
+
return AsyncExecutionHandle(execution, target)
|
|
286
|
+
|
|
287
|
+
async def get(
|
|
288
|
+
self, execution_id: ExecutionId | str, *, backend: str | None = None
|
|
289
|
+
) -> AsyncExecutionHandle:
|
|
290
|
+
target = self._runtime._owner_backend(execution_id, backend)
|
|
291
|
+
execution = await target.aget(ExecutionId(str(execution_id)))
|
|
292
|
+
return AsyncExecutionHandle(execution, target)
|
|
293
|
+
|
|
294
|
+
async def cancel(
|
|
295
|
+
self, execution_id: ExecutionId | str, *, backend: str | None = None
|
|
296
|
+
) -> Execution:
|
|
297
|
+
return await (await self.get(execution_id, backend=backend)).cancel()
|
|
298
|
+
|
|
299
|
+
async def wait(
|
|
300
|
+
self,
|
|
301
|
+
execution_id: ExecutionId | str,
|
|
302
|
+
*,
|
|
303
|
+
timeout: float | None = None,
|
|
304
|
+
backend: str | None = None,
|
|
305
|
+
) -> Execution:
|
|
306
|
+
return await (await self.get(execution_id, backend=backend)).wait(timeout)
|
|
307
|
+
|
|
308
|
+
async def result(
|
|
309
|
+
self,
|
|
310
|
+
execution_id: ExecutionId | str,
|
|
311
|
+
*,
|
|
312
|
+
timeout: float | None = None,
|
|
313
|
+
backend: str | None = None,
|
|
314
|
+
) -> ExecutionResult:
|
|
315
|
+
return await (await self.get(execution_id, backend=backend)).result(timeout)
|
|
316
|
+
|
|
317
|
+
# -- lifecycle ---------------------------------------------------------------- #
|
|
318
|
+
async def close(self) -> None:
|
|
319
|
+
"""Release every backend's resources, off the event loop."""
|
|
320
|
+
await asyncio.to_thread(self._runtime.close)
|
|
321
|
+
|
|
322
|
+
async def __aenter__(self) -> Self:
|
|
323
|
+
return self
|
|
324
|
+
|
|
325
|
+
async def __aexit__(
|
|
326
|
+
self,
|
|
327
|
+
exc_type: type[BaseException] | None,
|
|
328
|
+
exc: BaseException | None,
|
|
329
|
+
tb: TracebackType | None,
|
|
330
|
+
) -> None:
|
|
331
|
+
await self.close()
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
class _AsyncFacade:
|
|
335
|
+
__slots__ = ("_aio",)
|
|
336
|
+
|
|
337
|
+
def __init__(self, aio: AsyncTaskferry) -> None:
|
|
338
|
+
self._aio = aio
|
|
339
|
+
|
|
340
|
+
async def submit_spec(
|
|
341
|
+
self, spec: AnySpec, *, backend: str | None = None
|
|
342
|
+
) -> AsyncExecutionHandle:
|
|
343
|
+
"""Submit an already-built spec."""
|
|
344
|
+
return await self._aio.submit(spec, backend=backend)
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
class AsyncInlineFacade(_AsyncFacade):
|
|
348
|
+
"""``runtime.inline`` — run a callable now, without blocking the loop.
|
|
349
|
+
|
|
350
|
+
"Inline" still means "this process", but on the async surface the work runs
|
|
351
|
+
on a worker thread so the loop keeps serving. An ``async def`` callable is
|
|
352
|
+
awaited on that thread's own loop.
|
|
353
|
+
|
|
354
|
+
If you are already in async code and the work is a coroutine, `await` it
|
|
355
|
+
directly — you do not need Taskferry to run something in the process you are
|
|
356
|
+
already in. Use this when the callable is *sync* and would otherwise block.
|
|
357
|
+
"""
|
|
358
|
+
|
|
359
|
+
__slots__ = ()
|
|
360
|
+
|
|
361
|
+
async def submit(
|
|
362
|
+
self,
|
|
363
|
+
func: Callable[..., Any],
|
|
364
|
+
/,
|
|
365
|
+
*args: Any,
|
|
366
|
+
backend: str | None = None,
|
|
367
|
+
name: str = "",
|
|
368
|
+
queue: str = "default",
|
|
369
|
+
retry: RetryPolicy = NO_RETRY,
|
|
370
|
+
labels: Mapping[str, str] | None = None,
|
|
371
|
+
correlation: Correlation | None = None,
|
|
372
|
+
**kwargs: Any,
|
|
373
|
+
) -> AsyncExecutionHandle:
|
|
374
|
+
spec = InlineSpec(
|
|
375
|
+
func=func,
|
|
376
|
+
args=args,
|
|
377
|
+
kwargs=kwargs,
|
|
378
|
+
name=name,
|
|
379
|
+
queue=queue,
|
|
380
|
+
retry=retry,
|
|
381
|
+
labels=labels or {},
|
|
382
|
+
correlation=correlation,
|
|
383
|
+
)
|
|
384
|
+
return await self._aio.submit(spec, backend=backend)
|
|
385
|
+
|
|
386
|
+
async def run(self, func: Callable[..., Any], /, *args: Any, **kwargs: Any) -> Any:
|
|
387
|
+
"""Run ``func`` and return its value, raising on failure."""
|
|
388
|
+
handle = await self.submit(func, *args, **kwargs)
|
|
389
|
+
return await handle.value()
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
class AsyncTaskFacade(_AsyncFacade):
|
|
393
|
+
"""``runtime.tasks`` — hand a named function to a task engine."""
|
|
394
|
+
|
|
395
|
+
__slots__ = ()
|
|
396
|
+
|
|
397
|
+
async def submit(
|
|
398
|
+
self,
|
|
399
|
+
task: str | Callable[..., Any] | FunctionRef,
|
|
400
|
+
/,
|
|
401
|
+
*args: JSONValue,
|
|
402
|
+
backend: str | None = None,
|
|
403
|
+
queue: str = "default",
|
|
404
|
+
priority: int = 0,
|
|
405
|
+
delay: timedelta | float | None = None,
|
|
406
|
+
run_at: datetime | None = None,
|
|
407
|
+
retry: RetryPolicy = NO_RETRY,
|
|
408
|
+
timeout: TimeoutPolicy = NO_TIMEOUT,
|
|
409
|
+
idempotency_key: str | None = None,
|
|
410
|
+
labels: Mapping[str, str] | None = None,
|
|
411
|
+
backend_options: BackendOptions | Mapping[str, Any] | None = None,
|
|
412
|
+
correlation: Correlation | None = None,
|
|
413
|
+
**kwargs: JSONValue,
|
|
414
|
+
) -> AsyncExecutionHandle:
|
|
415
|
+
ref = self._aio.registry.reference(task)
|
|
416
|
+
spec = TaskSpec(
|
|
417
|
+
task=ref.path,
|
|
418
|
+
args=args,
|
|
419
|
+
kwargs=kwargs,
|
|
420
|
+
queue=queue,
|
|
421
|
+
priority=priority,
|
|
422
|
+
delay=_as_timedelta(delay),
|
|
423
|
+
run_at=run_at,
|
|
424
|
+
retry=retry,
|
|
425
|
+
timeout=timeout,
|
|
426
|
+
idempotency_key=idempotency_key,
|
|
427
|
+
labels=labels or {},
|
|
428
|
+
backend_options=_as_backend_options(backend_options),
|
|
429
|
+
correlation=correlation,
|
|
430
|
+
)
|
|
431
|
+
return await self._aio.submit(spec, backend=backend)
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
class AsyncJobFacade(_AsyncFacade):
|
|
435
|
+
"""``runtime.jobs`` — run an isolated workload to completion."""
|
|
436
|
+
|
|
437
|
+
__slots__ = ()
|
|
438
|
+
|
|
439
|
+
async def submit(
|
|
440
|
+
self,
|
|
441
|
+
job: str,
|
|
442
|
+
/,
|
|
443
|
+
*,
|
|
444
|
+
backend: str | None = None,
|
|
445
|
+
image: str | None = None,
|
|
446
|
+
command: Sequence[str] = (),
|
|
447
|
+
args: Sequence[str] = (),
|
|
448
|
+
env: Mapping[str, str] | None = None,
|
|
449
|
+
resources: Resources | None = None,
|
|
450
|
+
profile: str = "default",
|
|
451
|
+
parallelism: int = 1,
|
|
452
|
+
working_dir: str | None = None,
|
|
453
|
+
retry: RetryPolicy = NO_RETRY,
|
|
454
|
+
timeout: TimeoutPolicy | float | None = None,
|
|
455
|
+
idempotency_key: str | None = None,
|
|
456
|
+
labels: Mapping[str, str] | None = None,
|
|
457
|
+
backend_options: BackendOptions | Mapping[str, Any] | None = None,
|
|
458
|
+
correlation: Correlation | None = None,
|
|
459
|
+
) -> AsyncExecutionHandle:
|
|
460
|
+
spec = JobSpec(
|
|
461
|
+
job=job,
|
|
462
|
+
image=image,
|
|
463
|
+
command=command,
|
|
464
|
+
args=args,
|
|
465
|
+
env=env or {},
|
|
466
|
+
resources=resources if resources is not None else Resources(),
|
|
467
|
+
profile=profile,
|
|
468
|
+
parallelism=parallelism,
|
|
469
|
+
working_dir=working_dir,
|
|
470
|
+
retry=retry,
|
|
471
|
+
timeout=_as_timeout(timeout),
|
|
472
|
+
idempotency_key=idempotency_key,
|
|
473
|
+
labels=labels or {},
|
|
474
|
+
backend_options=_as_backend_options(backend_options),
|
|
475
|
+
correlation=correlation,
|
|
476
|
+
)
|
|
477
|
+
return await self._aio.submit(spec, backend=backend)
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
__all__ = [
|
|
481
|
+
"AsyncExecutionHandle",
|
|
482
|
+
"AsyncInlineFacade",
|
|
483
|
+
"AsyncJobFacade",
|
|
484
|
+
"AsyncTaskFacade",
|
|
485
|
+
"AsyncTaskferry",
|
|
486
|
+
]
|