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/runtime.py
ADDED
|
@@ -0,0 +1,609 @@
|
|
|
1
|
+
"""The Taskferry runtime — one object an application holds and passes around.
|
|
2
|
+
|
|
3
|
+
```mermaid
|
|
4
|
+
flowchart TD
|
|
5
|
+
APP["Application"]
|
|
6
|
+
RT["Taskferry runtime"]
|
|
7
|
+
ROUTER["Router"]
|
|
8
|
+
REG["Backend registry<br/>(lazy)"]
|
|
9
|
+
|
|
10
|
+
INLINE["inline backend"]
|
|
11
|
+
TASK["task backend"]
|
|
12
|
+
JOB["job backend"]
|
|
13
|
+
|
|
14
|
+
APP -->|"runtime.tasks.submit(...)"| RT
|
|
15
|
+
RT --> ROUTER
|
|
16
|
+
ROUTER -->|"backend name"| REG
|
|
17
|
+
REG --> INLINE
|
|
18
|
+
REG --> TASK
|
|
19
|
+
REG --> JOB
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
The runtime coordinates and does nothing else. It routes, it builds backends
|
|
23
|
+
lazily, it validates capabilities, it wraps results in handles, it fires hooks —
|
|
24
|
+
and then it gets out of the way. It never queues, never polls a broker, never
|
|
25
|
+
runs a worker loop. Those belong to the engines.
|
|
26
|
+
|
|
27
|
+
Three facades read the way the three primitives are actually used::
|
|
28
|
+
|
|
29
|
+
runtime.inline.submit(add, 20, 22) # a callable, now
|
|
30
|
+
runtime.tasks.submit("myapp.tasks:refresh", 42) # a name, on an engine
|
|
31
|
+
runtime.jobs.submit("build-cog", image="gdal:latest") # a container, somewhere
|
|
32
|
+
|
|
33
|
+
Backends are built on first use and cached. Configuring a Cloud Run job backend
|
|
34
|
+
therefore costs a web process nothing until a job is actually routed to it, and
|
|
35
|
+
``import taskferry`` never reaches a provider SDK.
|
|
36
|
+
|
|
37
|
+
Thread safety
|
|
38
|
+
-------------
|
|
39
|
+
|
|
40
|
+
A runtime is safe to share across threads: backend construction is guarded by a
|
|
41
|
+
lock (so a backend is built exactly once), the router and config are immutable,
|
|
42
|
+
and the execution index is lock-protected. Whether a *backend* is thread-safe is
|
|
43
|
+
that backend's own promise; all built-ins are.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
from __future__ import annotations
|
|
47
|
+
|
|
48
|
+
import threading
|
|
49
|
+
from collections import OrderedDict
|
|
50
|
+
from collections.abc import Callable, Iterable, Mapping, Sequence
|
|
51
|
+
from datetime import datetime, timedelta
|
|
52
|
+
from types import TracebackType
|
|
53
|
+
from typing import Any, Self
|
|
54
|
+
|
|
55
|
+
from .capabilities import CapabilitySet
|
|
56
|
+
from .config import BackendConfig, TaskferryConfig
|
|
57
|
+
from .core.correlation import Correlation, ensure_correlation
|
|
58
|
+
from .core.errors import ConfigurationError
|
|
59
|
+
from .core.serialization import JsonSerializer, Serializer
|
|
60
|
+
from .core.typing import JSONValue
|
|
61
|
+
from .errors import ExecutionNotFound, RoutingError
|
|
62
|
+
from .execution import Execution, ExecutionId, ExecutionKind, ExecutionResult
|
|
63
|
+
from .functions import FunctionRef, FunctionRegistry
|
|
64
|
+
from .handle import ExecutionHandle
|
|
65
|
+
from .hooks import Hook, HookChain
|
|
66
|
+
from .plugins import build_backend
|
|
67
|
+
from .ports import BaseBackend, ExecutionBackend
|
|
68
|
+
from .retry import NO_RETRY, NO_TIMEOUT, RetryPolicy, TimeoutPolicy
|
|
69
|
+
from .router import Router
|
|
70
|
+
from .specs import AnySpec, BackendOptions, InlineSpec, JobSpec, Resources, TaskSpec
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class Taskferry:
|
|
74
|
+
"""The portable execution layer.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
config: Where backends, routes and defaults come from. Defaults to
|
|
78
|
+
:meth:`TaskferryConfig.from_env`, so a Twelve-Factor deployment needs
|
|
79
|
+
no code at all.
|
|
80
|
+
backends: Pre-built backend instances, by name. Takes precedence over
|
|
81
|
+
``config.backends`` — this is how tests inject fakes and how an
|
|
82
|
+
application that builds its own clients hands them over.
|
|
83
|
+
router: Overrides the router derived from ``config``.
|
|
84
|
+
serializer: Payload serializer handed to backends that need one.
|
|
85
|
+
registry: Function registry used to resolve task names.
|
|
86
|
+
hooks: Lifecycle observers. See :mod:`taskferry.hooks`.
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
def __init__(
|
|
90
|
+
self,
|
|
91
|
+
*,
|
|
92
|
+
config: TaskferryConfig | None = None,
|
|
93
|
+
backends: Mapping[str, ExecutionBackend] | None = None,
|
|
94
|
+
router: Router | None = None,
|
|
95
|
+
serializer: Serializer | None = None,
|
|
96
|
+
registry: FunctionRegistry | None = None,
|
|
97
|
+
hooks: Iterable[Hook] = (),
|
|
98
|
+
) -> None:
|
|
99
|
+
self._config = config if config is not None else TaskferryConfig.from_env()
|
|
100
|
+
self._router = router if router is not None else self._config.router()
|
|
101
|
+
self._serializer = serializer if serializer is not None else JsonSerializer()
|
|
102
|
+
self._registry = (
|
|
103
|
+
registry
|
|
104
|
+
if registry is not None
|
|
105
|
+
else FunctionRegistry(
|
|
106
|
+
allow_import=self._config.allow_import,
|
|
107
|
+
allowed_modules=self._config.allowed_modules,
|
|
108
|
+
)
|
|
109
|
+
)
|
|
110
|
+
self._hooks = HookChain(hooks)
|
|
111
|
+
self._instances: dict[str, ExecutionBackend] = dict(backends or {})
|
|
112
|
+
self._explicit: frozenset[str] = frozenset(self._instances)
|
|
113
|
+
self._lock = threading.RLock()
|
|
114
|
+
# Bounded id -> backend index so get()/cancel() can find the owner
|
|
115
|
+
# without the caller having to remember it.
|
|
116
|
+
self._index: OrderedDict[str, str] = OrderedDict()
|
|
117
|
+
|
|
118
|
+
for name in self._instances:
|
|
119
|
+
self._attach_hooks(self._instances[name])
|
|
120
|
+
|
|
121
|
+
self.inline = InlineFacade(self)
|
|
122
|
+
self.tasks = TaskFacade(self)
|
|
123
|
+
self.jobs = JobFacade(self)
|
|
124
|
+
|
|
125
|
+
# -- constructors ------------------------------------------------------- #
|
|
126
|
+
@classmethod
|
|
127
|
+
def local(cls, **kwargs: Any) -> Taskferry:
|
|
128
|
+
"""A runtime that needs no infrastructure whatsoever.
|
|
129
|
+
|
|
130
|
+
Inline runs here, tasks run on a thread pool, jobs run as subprocesses::
|
|
131
|
+
|
|
132
|
+
runtime = Taskferry.local()
|
|
133
|
+
assert runtime.inline.submit(add, 20, 22).result().value == 42
|
|
134
|
+
|
|
135
|
+
No Django, no PostgreSQL, no Redis, no cloud, no worker process.
|
|
136
|
+
"""
|
|
137
|
+
kwargs.setdefault("config", TaskferryConfig.local())
|
|
138
|
+
return cls(**kwargs)
|
|
139
|
+
|
|
140
|
+
@classmethod
|
|
141
|
+
def from_env(cls, environ: Mapping[str, str] | None = None, **kwargs: Any) -> Taskferry:
|
|
142
|
+
"""Build from ``TASKFERRY_*`` env vars. See :meth:`TaskferryConfig.from_env`."""
|
|
143
|
+
kwargs.setdefault("config", TaskferryConfig.from_env(environ))
|
|
144
|
+
return cls(**kwargs)
|
|
145
|
+
|
|
146
|
+
@classmethod
|
|
147
|
+
def from_mapping(cls, data: Mapping[str, Any], **kwargs: Any) -> Taskferry:
|
|
148
|
+
"""Build from a plain mapping (TOML, YAML, Django settings, a literal dict)."""
|
|
149
|
+
kwargs.setdefault("config", TaskferryConfig.from_mapping(data))
|
|
150
|
+
return cls(**kwargs)
|
|
151
|
+
|
|
152
|
+
# -- introspection ------------------------------------------------------ #
|
|
153
|
+
@property
|
|
154
|
+
def config(self) -> TaskferryConfig:
|
|
155
|
+
return self._config
|
|
156
|
+
|
|
157
|
+
@property
|
|
158
|
+
def router(self) -> Router:
|
|
159
|
+
return self._router
|
|
160
|
+
|
|
161
|
+
@property
|
|
162
|
+
def registry(self) -> FunctionRegistry:
|
|
163
|
+
"""Where task names are resolved. Register callables here for local runs."""
|
|
164
|
+
return self._registry
|
|
165
|
+
|
|
166
|
+
@property
|
|
167
|
+
def serializer(self) -> Serializer:
|
|
168
|
+
return self._serializer
|
|
169
|
+
|
|
170
|
+
@property
|
|
171
|
+
def hooks(self) -> HookChain:
|
|
172
|
+
return self._hooks
|
|
173
|
+
|
|
174
|
+
def backend_names(self) -> tuple[str, ...]:
|
|
175
|
+
"""Every configured backend name, built or not."""
|
|
176
|
+
return tuple(sorted({*self._config.backends, *self._instances}))
|
|
177
|
+
|
|
178
|
+
def backend(self, name: str) -> ExecutionBackend:
|
|
179
|
+
"""Return the named backend, building it on first use.
|
|
180
|
+
|
|
181
|
+
Raises:
|
|
182
|
+
ConfigurationError: if no backend is configured under ``name``.
|
|
183
|
+
"""
|
|
184
|
+
with self._lock:
|
|
185
|
+
existing = self._instances.get(name)
|
|
186
|
+
if existing is not None:
|
|
187
|
+
return existing
|
|
188
|
+
definition: BackendConfig | None = self._config.backends.get(name)
|
|
189
|
+
if definition is None:
|
|
190
|
+
known = ", ".join(self.backend_names()) or "<none>"
|
|
191
|
+
raise ConfigurationError(
|
|
192
|
+
f"no backend configured under {name!r} (configured: {known})"
|
|
193
|
+
)
|
|
194
|
+
instance = build_backend(definition.factory, definition.options)
|
|
195
|
+
self._attach_hooks(instance)
|
|
196
|
+
self._instances[name] = instance
|
|
197
|
+
return instance
|
|
198
|
+
|
|
199
|
+
def capabilities(self, name: str) -> CapabilitySet:
|
|
200
|
+
"""What the named backend can do. Builds it if necessary."""
|
|
201
|
+
return self.backend(name).capabilities
|
|
202
|
+
|
|
203
|
+
def describe(self) -> dict[str, Any]:
|
|
204
|
+
"""A JSON-shaped summary of the runtime, used by the CLI and by ``doctor``.
|
|
205
|
+
|
|
206
|
+
Backends are *not* built to produce this — the point is to be able to
|
|
207
|
+
inspect a configuration in an environment where a provider SDK may be
|
|
208
|
+
missing.
|
|
209
|
+
"""
|
|
210
|
+
return {
|
|
211
|
+
"backends": {
|
|
212
|
+
name: {
|
|
213
|
+
"factory": definition.factory,
|
|
214
|
+
"options": sorted(definition.options),
|
|
215
|
+
"built": name in self._instances,
|
|
216
|
+
}
|
|
217
|
+
for name, definition in sorted(self._config.backends.items())
|
|
218
|
+
}
|
|
219
|
+
| {
|
|
220
|
+
name: {"factory": "<injected>", "options": [], "built": True}
|
|
221
|
+
for name in sorted(self._explicit)
|
|
222
|
+
},
|
|
223
|
+
"routes": [route.describe() for route in self._router.routes],
|
|
224
|
+
"defaults": {
|
|
225
|
+
kind.value: backend for kind, backend in sorted(self._router.defaults.items())
|
|
226
|
+
},
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
# -- submission ---------------------------------------------------------- #
|
|
230
|
+
def submit(
|
|
231
|
+
self,
|
|
232
|
+
spec: AnySpec,
|
|
233
|
+
*,
|
|
234
|
+
backend: str | None = None,
|
|
235
|
+
idempotency_key: str | None = None,
|
|
236
|
+
) -> ExecutionHandle:
|
|
237
|
+
"""Route ``spec`` to a backend, submit it, and return a live handle.
|
|
238
|
+
|
|
239
|
+
Args:
|
|
240
|
+
spec: What to run.
|
|
241
|
+
backend: Bypass routing and use this backend by name. For a CLI flag
|
|
242
|
+
or a deliberate one-off; application code should route.
|
|
243
|
+
idempotency_key: Convenience for ``spec.evolve(idempotency_key=...)``.
|
|
244
|
+
Requires the target backend to advertise ``DEDUPLICATION``, and
|
|
245
|
+
is **not** an exactly-once promise — see ADR-0010.
|
|
246
|
+
|
|
247
|
+
Raises:
|
|
248
|
+
RoutingError: no backend matched and the kind has no default.
|
|
249
|
+
UnsupportedCapability: the chosen backend cannot honour the spec.
|
|
250
|
+
"""
|
|
251
|
+
target, name, prepared = self._prepare(
|
|
252
|
+
spec, backend=backend, idempotency_key=idempotency_key
|
|
253
|
+
)
|
|
254
|
+
execution = target.submit(prepared)
|
|
255
|
+
self._track(execution.id, name)
|
|
256
|
+
return ExecutionHandle(execution, target)
|
|
257
|
+
|
|
258
|
+
def _prepare(
|
|
259
|
+
self,
|
|
260
|
+
spec: AnySpec,
|
|
261
|
+
*,
|
|
262
|
+
backend: str | None,
|
|
263
|
+
idempotency_key: str | None,
|
|
264
|
+
) -> tuple[ExecutionBackend, str, AnySpec]:
|
|
265
|
+
"""Route and validate, without submitting.
|
|
266
|
+
|
|
267
|
+
Shared verbatim by the sync runtime and by
|
|
268
|
+
:class:`~taskferry.aio.AsyncTaskferry`, so the two surfaces cannot drift on
|
|
269
|
+
routing, correlation or the kind check — the parts where a divergence
|
|
270
|
+
would be silent and expensive.
|
|
271
|
+
"""
|
|
272
|
+
if idempotency_key is not None:
|
|
273
|
+
spec = spec.evolve(idempotency_key=idempotency_key)
|
|
274
|
+
if spec.correlation is None:
|
|
275
|
+
spec = spec.evolve(correlation=ensure_correlation())
|
|
276
|
+
|
|
277
|
+
name = backend if backend is not None else self._router.resolve(spec)
|
|
278
|
+
target = self.backend(name)
|
|
279
|
+
if target.kind is not spec.kind:
|
|
280
|
+
raise RoutingError(
|
|
281
|
+
f"{name!r} is a {target.kind.value} backend but was asked to run a "
|
|
282
|
+
f"{spec.kind.value} spec ({spec.name!r}); check the routes for "
|
|
283
|
+
f"queue={spec.queue!r} profile={spec.profile!r}"
|
|
284
|
+
)
|
|
285
|
+
return target, name, spec
|
|
286
|
+
|
|
287
|
+
# -- lookup ------------------------------------------------------------- #
|
|
288
|
+
def get(
|
|
289
|
+
self, execution_id: ExecutionId | str, *, backend: str | None = None
|
|
290
|
+
) -> ExecutionHandle:
|
|
291
|
+
"""Return a handle for a previously submitted execution.
|
|
292
|
+
|
|
293
|
+
The owning backend is remembered from submission. Across a process
|
|
294
|
+
boundary that memory is gone, so pass ``backend=`` — Taskferry does not
|
|
295
|
+
broadcast a lookup to every configured engine, because polling a cloud
|
|
296
|
+
API for an id it has never seen is slow, costly, and misleading.
|
|
297
|
+
|
|
298
|
+
Raises:
|
|
299
|
+
ExecutionNotFound: when the owner is unknown or the backend has no
|
|
300
|
+
record of the id.
|
|
301
|
+
"""
|
|
302
|
+
target = self._owner_backend(execution_id, backend)
|
|
303
|
+
execution = target.get(ExecutionId(str(execution_id)))
|
|
304
|
+
return ExecutionHandle(execution, target)
|
|
305
|
+
|
|
306
|
+
def _owner_backend(
|
|
307
|
+
self, execution_id: ExecutionId | str, backend: str | None
|
|
308
|
+
) -> ExecutionBackend:
|
|
309
|
+
"""The backend that owns ``execution_id``. Shared with the async surface."""
|
|
310
|
+
key = str(execution_id)
|
|
311
|
+
name = backend if backend is not None else self._owner_of(key)
|
|
312
|
+
if name is None:
|
|
313
|
+
raise ExecutionNotFound(
|
|
314
|
+
f"{key!r} was not submitted by this runtime; pass backend='<name>' to say "
|
|
315
|
+
f"which engine owns it (configured: {', '.join(self.backend_names()) or '<none>'})"
|
|
316
|
+
)
|
|
317
|
+
return self.backend(name)
|
|
318
|
+
|
|
319
|
+
def cancel(self, execution_id: ExecutionId | str, *, backend: str | None = None) -> Execution:
|
|
320
|
+
"""Cancel an execution. Requires the owning backend to advertise ``CANCEL``."""
|
|
321
|
+
return self.get(execution_id, backend=backend).cancel()
|
|
322
|
+
|
|
323
|
+
def wait(
|
|
324
|
+
self,
|
|
325
|
+
execution_id: ExecutionId | str,
|
|
326
|
+
*,
|
|
327
|
+
timeout: float | None = None,
|
|
328
|
+
backend: str | None = None,
|
|
329
|
+
) -> Execution:
|
|
330
|
+
"""Block until an execution is terminal, then return the final snapshot."""
|
|
331
|
+
return self.get(execution_id, backend=backend).wait(timeout)
|
|
332
|
+
|
|
333
|
+
def result(
|
|
334
|
+
self,
|
|
335
|
+
execution_id: ExecutionId | str,
|
|
336
|
+
*,
|
|
337
|
+
timeout: float | None = None,
|
|
338
|
+
backend: str | None = None,
|
|
339
|
+
) -> ExecutionResult:
|
|
340
|
+
"""Return an execution's outcome, waiting up to ``timeout``."""
|
|
341
|
+
return self.get(execution_id, backend=backend).result(timeout)
|
|
342
|
+
|
|
343
|
+
# -- lifecycle ----------------------------------------------------------- #
|
|
344
|
+
def close(self) -> None:
|
|
345
|
+
"""Release every backend's resources and forget them. Idempotent.
|
|
346
|
+
|
|
347
|
+
Injected backends are closed too: passing one in hands the runtime
|
|
348
|
+
ownership of it. Keep your own reference and skip ``close()`` if you need
|
|
349
|
+
a different lifetime. A closed runtime rebuilds configured backends on
|
|
350
|
+
the next submit; injected ones are gone, so it is not reusable.
|
|
351
|
+
"""
|
|
352
|
+
with self._lock:
|
|
353
|
+
instances = list(self._instances.values())
|
|
354
|
+
self._instances.clear()
|
|
355
|
+
self._index.clear()
|
|
356
|
+
for instance in instances:
|
|
357
|
+
closer = getattr(instance, "close", None)
|
|
358
|
+
if callable(closer):
|
|
359
|
+
closer()
|
|
360
|
+
|
|
361
|
+
def __enter__(self) -> Self:
|
|
362
|
+
return self
|
|
363
|
+
|
|
364
|
+
def __exit__(
|
|
365
|
+
self,
|
|
366
|
+
exc_type: type[BaseException] | None,
|
|
367
|
+
exc: BaseException | None,
|
|
368
|
+
tb: TracebackType | None,
|
|
369
|
+
) -> None:
|
|
370
|
+
self.close()
|
|
371
|
+
|
|
372
|
+
def __repr__(self) -> str:
|
|
373
|
+
return f"<Taskferry backends={list(self.backend_names())}>"
|
|
374
|
+
|
|
375
|
+
# -- internals ----------------------------------------------------------- #
|
|
376
|
+
def _attach_hooks(self, backend: ExecutionBackend) -> None:
|
|
377
|
+
"""Give a backend the runtime's hook chain, if it can hold one."""
|
|
378
|
+
if isinstance(backend, BaseBackend):
|
|
379
|
+
backend.hooks = self._hooks
|
|
380
|
+
|
|
381
|
+
def _track(self, execution_id: ExecutionId, backend: str) -> None:
|
|
382
|
+
limit = self._config.max_tracked_executions
|
|
383
|
+
if limit == 0:
|
|
384
|
+
return
|
|
385
|
+
with self._lock:
|
|
386
|
+
self._index[str(execution_id)] = backend
|
|
387
|
+
self._index.move_to_end(str(execution_id))
|
|
388
|
+
while len(self._index) > limit:
|
|
389
|
+
self._index.popitem(last=False)
|
|
390
|
+
|
|
391
|
+
def _owner_of(self, execution_id: str) -> str | None:
|
|
392
|
+
with self._lock:
|
|
393
|
+
return self._index.get(execution_id)
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
class _Facade:
|
|
397
|
+
"""Shared plumbing for the three primitive facades."""
|
|
398
|
+
|
|
399
|
+
__slots__ = ("_runtime",)
|
|
400
|
+
|
|
401
|
+
def __init__(self, runtime: Taskferry) -> None:
|
|
402
|
+
self._runtime = runtime
|
|
403
|
+
|
|
404
|
+
@property
|
|
405
|
+
def backend_name(self) -> str | None:
|
|
406
|
+
"""The default backend for this kind, or ``None`` when unset."""
|
|
407
|
+
return self._runtime.router.defaults.get(self._kind)
|
|
408
|
+
|
|
409
|
+
@property
|
|
410
|
+
def _kind(self) -> ExecutionKind: # pragma: no cover - overridden
|
|
411
|
+
raise NotImplementedError
|
|
412
|
+
|
|
413
|
+
def submit_spec(self, spec: AnySpec, *, backend: str | None = None) -> ExecutionHandle:
|
|
414
|
+
"""Submit an already-built spec. The escape hatch from the shorthand."""
|
|
415
|
+
return self._runtime.submit(spec, backend=backend)
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
class InlineFacade(_Facade):
|
|
419
|
+
"""``runtime.inline`` — run a callable right now, in this process."""
|
|
420
|
+
|
|
421
|
+
__slots__ = ()
|
|
422
|
+
|
|
423
|
+
@property
|
|
424
|
+
def _kind(self) -> ExecutionKind:
|
|
425
|
+
return ExecutionKind.INLINE
|
|
426
|
+
|
|
427
|
+
def submit(
|
|
428
|
+
self,
|
|
429
|
+
func: Callable[..., Any],
|
|
430
|
+
/,
|
|
431
|
+
*args: Any,
|
|
432
|
+
backend: str | None = None,
|
|
433
|
+
name: str = "",
|
|
434
|
+
queue: str = "default",
|
|
435
|
+
retry: RetryPolicy = NO_RETRY,
|
|
436
|
+
labels: Mapping[str, str] | None = None,
|
|
437
|
+
correlation: Correlation | None = None,
|
|
438
|
+
**kwargs: Any,
|
|
439
|
+
) -> ExecutionHandle:
|
|
440
|
+
"""Run ``func(*args, **kwargs)`` immediately and return a finished handle.
|
|
441
|
+
|
|
442
|
+
The one primitive that takes a live callable: nothing crosses a process
|
|
443
|
+
boundary, so there is nothing to serialize and no importable name to
|
|
444
|
+
demand. Lambdas, closures and notebook functions all work.
|
|
445
|
+
|
|
446
|
+
``async def`` callables are awaited. Arguments are passed through
|
|
447
|
+
untouched — no JSON validation — because they are not going anywhere.
|
|
448
|
+
"""
|
|
449
|
+
spec = InlineSpec(
|
|
450
|
+
func=func,
|
|
451
|
+
args=args,
|
|
452
|
+
kwargs=kwargs,
|
|
453
|
+
name=name,
|
|
454
|
+
queue=queue,
|
|
455
|
+
retry=retry,
|
|
456
|
+
labels=labels or {},
|
|
457
|
+
correlation=correlation,
|
|
458
|
+
)
|
|
459
|
+
return self._runtime.submit(spec, backend=backend)
|
|
460
|
+
|
|
461
|
+
def run(self, func: Callable[..., Any], /, *args: Any, **kwargs: Any) -> Any:
|
|
462
|
+
"""Run ``func`` and return its value directly, raising on failure.
|
|
463
|
+
|
|
464
|
+
Sugar over ``submit(...).result().value`` for scripts and tests.
|
|
465
|
+
"""
|
|
466
|
+
return self.submit(func, *args, **kwargs).result().value
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
class TaskFacade(_Facade):
|
|
470
|
+
"""``runtime.tasks`` — hand a named function to a task engine."""
|
|
471
|
+
|
|
472
|
+
__slots__ = ()
|
|
473
|
+
|
|
474
|
+
@property
|
|
475
|
+
def _kind(self) -> ExecutionKind:
|
|
476
|
+
return ExecutionKind.TASK
|
|
477
|
+
|
|
478
|
+
def submit(
|
|
479
|
+
self,
|
|
480
|
+
task: str | Callable[..., Any] | FunctionRef,
|
|
481
|
+
/,
|
|
482
|
+
*args: JSONValue,
|
|
483
|
+
backend: str | None = None,
|
|
484
|
+
queue: str = "default",
|
|
485
|
+
priority: int = 0,
|
|
486
|
+
delay: timedelta | float | None = None,
|
|
487
|
+
run_at: datetime | None = None,
|
|
488
|
+
retry: RetryPolicy = NO_RETRY,
|
|
489
|
+
timeout: TimeoutPolicy = NO_TIMEOUT,
|
|
490
|
+
idempotency_key: str | None = None,
|
|
491
|
+
labels: Mapping[str, str] | None = None,
|
|
492
|
+
backend_options: BackendOptions | Mapping[str, Any] | None = None,
|
|
493
|
+
correlation: Correlation | None = None,
|
|
494
|
+
**kwargs: JSONValue,
|
|
495
|
+
) -> ExecutionHandle:
|
|
496
|
+
"""Enqueue ``task`` for background execution.
|
|
497
|
+
|
|
498
|
+
``task`` may be the portable ``"package.module:function"`` string, or the
|
|
499
|
+
callable itself — in which case its importable name is derived and it is
|
|
500
|
+
registered locally, so the same code works whether the engine runs it
|
|
501
|
+
here or on a worker three availability zones away. Lambdas and closures
|
|
502
|
+
are rejected: a worker could never find them.
|
|
503
|
+
|
|
504
|
+
Arguments must be JSON-shaped; they cross a process boundary. Pass an id,
|
|
505
|
+
not an ORM instance.
|
|
506
|
+
"""
|
|
507
|
+
ref = self._runtime.registry.reference(task)
|
|
508
|
+
spec = TaskSpec(
|
|
509
|
+
task=ref.path,
|
|
510
|
+
args=args,
|
|
511
|
+
kwargs=kwargs,
|
|
512
|
+
queue=queue,
|
|
513
|
+
priority=priority,
|
|
514
|
+
delay=_as_timedelta(delay),
|
|
515
|
+
run_at=run_at,
|
|
516
|
+
retry=retry,
|
|
517
|
+
timeout=timeout,
|
|
518
|
+
idempotency_key=idempotency_key,
|
|
519
|
+
labels=labels or {},
|
|
520
|
+
backend_options=_as_backend_options(backend_options),
|
|
521
|
+
correlation=correlation,
|
|
522
|
+
)
|
|
523
|
+
return self._runtime.submit(spec, backend=backend)
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
class JobFacade(_Facade):
|
|
527
|
+
"""``runtime.jobs`` — run an isolated workload to completion."""
|
|
528
|
+
|
|
529
|
+
__slots__ = ()
|
|
530
|
+
|
|
531
|
+
@property
|
|
532
|
+
def _kind(self) -> ExecutionKind:
|
|
533
|
+
return ExecutionKind.JOB
|
|
534
|
+
|
|
535
|
+
def submit(
|
|
536
|
+
self,
|
|
537
|
+
job: str,
|
|
538
|
+
/,
|
|
539
|
+
*,
|
|
540
|
+
backend: str | None = None,
|
|
541
|
+
image: str | None = None,
|
|
542
|
+
command: Sequence[str] = (),
|
|
543
|
+
args: Sequence[str] = (),
|
|
544
|
+
env: Mapping[str, str] | None = None,
|
|
545
|
+
resources: Resources | None = None,
|
|
546
|
+
profile: str = "default",
|
|
547
|
+
parallelism: int = 1,
|
|
548
|
+
working_dir: str | None = None,
|
|
549
|
+
retry: RetryPolicy = NO_RETRY,
|
|
550
|
+
timeout: TimeoutPolicy | float | None = None,
|
|
551
|
+
idempotency_key: str | None = None,
|
|
552
|
+
labels: Mapping[str, str] | None = None,
|
|
553
|
+
backend_options: BackendOptions | Mapping[str, Any] | None = None,
|
|
554
|
+
correlation: Correlation | None = None,
|
|
555
|
+
) -> ExecutionHandle:
|
|
556
|
+
"""Submit an isolated workload — a container or a process.
|
|
557
|
+
|
|
558
|
+
``profile`` is the routing key for jobs the way ``queue`` is for tasks:
|
|
559
|
+
``profile="gpu"`` says what the work needs, and the deployment's routes
|
|
560
|
+
decide whether that means a Kubernetes GPU node pool or a local process.
|
|
561
|
+
"""
|
|
562
|
+
spec = JobSpec(
|
|
563
|
+
job=job,
|
|
564
|
+
image=image,
|
|
565
|
+
command=command,
|
|
566
|
+
args=args,
|
|
567
|
+
env=env or {},
|
|
568
|
+
resources=resources if resources is not None else Resources(),
|
|
569
|
+
profile=profile,
|
|
570
|
+
parallelism=parallelism,
|
|
571
|
+
working_dir=working_dir,
|
|
572
|
+
retry=retry,
|
|
573
|
+
timeout=_as_timeout(timeout),
|
|
574
|
+
idempotency_key=idempotency_key,
|
|
575
|
+
labels=labels or {},
|
|
576
|
+
backend_options=_as_backend_options(backend_options),
|
|
577
|
+
correlation=correlation,
|
|
578
|
+
)
|
|
579
|
+
return self._runtime.submit(spec, backend=backend)
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
def _as_timedelta(value: timedelta | float | None) -> timedelta | None:
|
|
583
|
+
if value is None or isinstance(value, timedelta):
|
|
584
|
+
return value
|
|
585
|
+
return timedelta(seconds=float(value))
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def _as_timeout(value: TimeoutPolicy | float | None) -> TimeoutPolicy:
|
|
589
|
+
if value is None:
|
|
590
|
+
return NO_TIMEOUT
|
|
591
|
+
if isinstance(value, TimeoutPolicy):
|
|
592
|
+
return value
|
|
593
|
+
return TimeoutPolicy(seconds=float(value))
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
def _as_backend_options(value: BackendOptions | Mapping[str, Any] | None) -> BackendOptions:
|
|
597
|
+
if value is None:
|
|
598
|
+
return BackendOptions()
|
|
599
|
+
if isinstance(value, BackendOptions):
|
|
600
|
+
return value
|
|
601
|
+
return BackendOptions(dict(value))
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
__all__ = [
|
|
605
|
+
"InlineFacade",
|
|
606
|
+
"JobFacade",
|
|
607
|
+
"TaskFacade",
|
|
608
|
+
"Taskferry",
|
|
609
|
+
]
|