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
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
"""A task backend backed by a thread pool.
|
|
2
|
+
|
|
3
|
+
The reference `TaskBackend` implementation: enough to develop against, run tests
|
|
4
|
+
against and ship a single-process tool with, and small enough to read in one
|
|
5
|
+
sitting.
|
|
6
|
+
|
|
7
|
+
```mermaid
|
|
8
|
+
flowchart LR
|
|
9
|
+
SPEC["TaskSpec"]
|
|
10
|
+
B["ThreadTaskBackend"]
|
|
11
|
+
Q["ThreadPoolExecutor"]
|
|
12
|
+
REG["FunctionRegistry"]
|
|
13
|
+
F["your function"]
|
|
14
|
+
|
|
15
|
+
SPEC --> B --> Q
|
|
16
|
+
B -->|"resolve 'pkg.mod:fn'"| REG --> F
|
|
17
|
+
Q --> F
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
What it is not
|
|
21
|
+
--------------
|
|
22
|
+
|
|
23
|
+
It is not a queue. Work lives in this process's memory: nothing survives a
|
|
24
|
+
restart, nothing is visible to another process, and a crash loses every pending
|
|
25
|
+
task. Delivery is therefore **at-most-once**. When that is not good enough —
|
|
26
|
+
which is most of production — route the queue to Procrastinate or Cloud Tasks.
|
|
27
|
+
Taskferry exists precisely so that is a configuration change.
|
|
28
|
+
|
|
29
|
+
What it does support, honestly: state, results, cancellation of tasks that have
|
|
30
|
+
not started, deferred execution, in-process retries with real backoff, and
|
|
31
|
+
``async def`` task functions.
|
|
32
|
+
|
|
33
|
+
It does **not** advertise ``PRIORITY``. A ``ThreadPoolExecutor`` is strictly
|
|
34
|
+
FIFO, so accepting a priority and then ignoring it would be exactly the quiet
|
|
35
|
+
lie the capability model exists to prevent. A spec with a non-zero priority is
|
|
36
|
+
rejected here and routed to an engine that can honour it.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
from __future__ import annotations
|
|
40
|
+
|
|
41
|
+
import contextlib
|
|
42
|
+
import threading
|
|
43
|
+
import time
|
|
44
|
+
from concurrent.futures import Future, ThreadPoolExecutor
|
|
45
|
+
from datetime import UTC, datetime
|
|
46
|
+
from typing import Any
|
|
47
|
+
|
|
48
|
+
from ..capabilities import Capability, CapabilitySet
|
|
49
|
+
from ..core.correlation import use_correlation
|
|
50
|
+
from ..errors import ExecutionNotFound, TaskferryTimeoutError
|
|
51
|
+
from ..execution import (
|
|
52
|
+
Execution,
|
|
53
|
+
ExecutionId,
|
|
54
|
+
ExecutionKind,
|
|
55
|
+
ExecutionResult,
|
|
56
|
+
ExecutionState,
|
|
57
|
+
new_execution_id,
|
|
58
|
+
)
|
|
59
|
+
from ..functions import FunctionRegistry, is_async_callable
|
|
60
|
+
from ..ports import BaseBackend
|
|
61
|
+
from ..specs import ExecutionSpec, TaskSpec
|
|
62
|
+
|
|
63
|
+
THREAD_CAPABILITIES = frozenset(
|
|
64
|
+
{
|
|
65
|
+
Capability.SUBMIT,
|
|
66
|
+
Capability.STATE,
|
|
67
|
+
Capability.RESULT,
|
|
68
|
+
Capability.CANCEL,
|
|
69
|
+
Capability.DELAY,
|
|
70
|
+
Capability.RETRY,
|
|
71
|
+
Capability.ASYNC_CALLABLE,
|
|
72
|
+
}
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class _Entry:
|
|
77
|
+
"""Everything the backend knows about one submitted task."""
|
|
78
|
+
|
|
79
|
+
__slots__ = ("execution", "future", "result", "spec")
|
|
80
|
+
|
|
81
|
+
def __init__(self, execution: Execution, spec: TaskSpec) -> None:
|
|
82
|
+
self.execution = execution
|
|
83
|
+
self.spec = spec
|
|
84
|
+
self.future: Future[Any] | None = None
|
|
85
|
+
self.result: ExecutionResult | None = None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class ThreadTaskBackend(BaseBackend):
|
|
89
|
+
"""Runs tasks on a :class:`~concurrent.futures.ThreadPoolExecutor`.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
max_workers: Pool size. ``None`` uses the interpreter default.
|
|
93
|
+
registry: Where ``"package.module:function"`` names are resolved. Share
|
|
94
|
+
the runtime's registry to pick up locally registered callables.
|
|
95
|
+
history: How many finished executions to keep for ``get``/``result``.
|
|
96
|
+
|
|
97
|
+
Thread-safe: all mutable state is guarded by one lock.
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
def __init__(
|
|
101
|
+
self,
|
|
102
|
+
*,
|
|
103
|
+
max_workers: int | None = None,
|
|
104
|
+
registry: FunctionRegistry | None = None,
|
|
105
|
+
history: int = 1024,
|
|
106
|
+
) -> None:
|
|
107
|
+
self._pool = ThreadPoolExecutor(
|
|
108
|
+
max_workers=max_workers, thread_name_prefix="taskferry-task"
|
|
109
|
+
)
|
|
110
|
+
self._registry = registry if registry is not None else FunctionRegistry()
|
|
111
|
+
self._history = history
|
|
112
|
+
self._entries: dict[str, _Entry] = {}
|
|
113
|
+
self._order: list[str] = []
|
|
114
|
+
self._lock = threading.Lock()
|
|
115
|
+
self._closed = False
|
|
116
|
+
|
|
117
|
+
@property
|
|
118
|
+
def name(self) -> str:
|
|
119
|
+
return "thread"
|
|
120
|
+
|
|
121
|
+
@property
|
|
122
|
+
def kind(self) -> ExecutionKind:
|
|
123
|
+
return ExecutionKind.TASK
|
|
124
|
+
|
|
125
|
+
@property
|
|
126
|
+
def capabilities(self) -> CapabilitySet:
|
|
127
|
+
return CapabilitySet(THREAD_CAPABILITIES, provider=self.name)
|
|
128
|
+
|
|
129
|
+
@property
|
|
130
|
+
def registry(self) -> FunctionRegistry:
|
|
131
|
+
return self._registry
|
|
132
|
+
|
|
133
|
+
# -- submission ---------------------------------------------------------- #
|
|
134
|
+
def _submit(self, spec: ExecutionSpec) -> Execution:
|
|
135
|
+
assert isinstance(spec, TaskSpec)
|
|
136
|
+
if self._closed:
|
|
137
|
+
from ..errors import SubmissionError
|
|
138
|
+
|
|
139
|
+
raise SubmissionError("this ThreadTaskBackend is closed", backend=self.name)
|
|
140
|
+
|
|
141
|
+
execution = Execution(
|
|
142
|
+
id=new_execution_id(ExecutionKind.TASK),
|
|
143
|
+
kind=ExecutionKind.TASK,
|
|
144
|
+
backend=self.name,
|
|
145
|
+
state=ExecutionState.QUEUED,
|
|
146
|
+
name=spec.name,
|
|
147
|
+
created_at=datetime.now(UTC),
|
|
148
|
+
correlation=spec.correlation,
|
|
149
|
+
metadata={"queue": spec.queue, "priority": str(spec.priority)},
|
|
150
|
+
)
|
|
151
|
+
entry = _Entry(execution, spec)
|
|
152
|
+
with self._lock:
|
|
153
|
+
self._entries[str(execution.id)] = entry
|
|
154
|
+
self._remember_locked(str(execution.id))
|
|
155
|
+
entry.future = self._pool.submit(self._run, str(execution.id))
|
|
156
|
+
return execution
|
|
157
|
+
|
|
158
|
+
# -- the worker ----------------------------------------------------------- #
|
|
159
|
+
def _run(self, key: str) -> Any:
|
|
160
|
+
with self._lock:
|
|
161
|
+
entry = self._entries.get(key)
|
|
162
|
+
if entry is None: # evicted or cancelled before we started
|
|
163
|
+
return None
|
|
164
|
+
spec = entry.spec
|
|
165
|
+
|
|
166
|
+
self._wait_until_eligible(spec)
|
|
167
|
+
if self._is_cancelled(key):
|
|
168
|
+
return None
|
|
169
|
+
|
|
170
|
+
self._transition(key, ExecutionState.RUNNING, started_at=datetime.now(UTC))
|
|
171
|
+
with self._lock:
|
|
172
|
+
running = self._entries[key].execution
|
|
173
|
+
self.hooks.before_execute(running)
|
|
174
|
+
|
|
175
|
+
try:
|
|
176
|
+
func = self._registry.resolve(spec.task)
|
|
177
|
+
except Exception as exc:
|
|
178
|
+
# Resolution failure is a real outcome, not an internal error. Left
|
|
179
|
+
# to propagate it would complete the future while the execution sat
|
|
180
|
+
# in RUNNING forever, and every wait() on it would block until its
|
|
181
|
+
# timeout with no explanation.
|
|
182
|
+
self._finish(key, error=exc, attempt=1)
|
|
183
|
+
return None
|
|
184
|
+
|
|
185
|
+
attempt = 1
|
|
186
|
+
bind: contextlib.AbstractContextManager[object] = (
|
|
187
|
+
use_correlation(spec.correlation)
|
|
188
|
+
if spec.correlation is not None
|
|
189
|
+
else contextlib.nullcontext()
|
|
190
|
+
)
|
|
191
|
+
while True:
|
|
192
|
+
try:
|
|
193
|
+
with bind:
|
|
194
|
+
value = self._call(func, spec)
|
|
195
|
+
except Exception as exc:
|
|
196
|
+
if spec.retry.should_retry(exc, attempt):
|
|
197
|
+
self.hooks.on_retry(running, attempt + 1, exc)
|
|
198
|
+
delay = spec.retry.delay_for(attempt + 1)
|
|
199
|
+
if delay:
|
|
200
|
+
time.sleep(delay)
|
|
201
|
+
attempt += 1
|
|
202
|
+
continue
|
|
203
|
+
self._finish(key, error=exc, attempt=attempt)
|
|
204
|
+
return None
|
|
205
|
+
self._finish(key, value=value, attempt=attempt)
|
|
206
|
+
return value
|
|
207
|
+
|
|
208
|
+
def _call(self, func: Any, spec: TaskSpec) -> Any:
|
|
209
|
+
args = list(spec.args)
|
|
210
|
+
kwargs = dict(spec.kwargs)
|
|
211
|
+
if not is_async_callable(func):
|
|
212
|
+
return func(*args, **kwargs)
|
|
213
|
+
import asyncio
|
|
214
|
+
|
|
215
|
+
return asyncio.run(func(*args, **kwargs))
|
|
216
|
+
|
|
217
|
+
def _wait_until_eligible(self, spec: TaskSpec) -> None:
|
|
218
|
+
"""Honour ``delay``/``run_at`` by holding the worker thread.
|
|
219
|
+
|
|
220
|
+
Sleeping a *pool* thread is real deferral, not a fake one: the task
|
|
221
|
+
genuinely does not run before its time. It does occupy a worker while it
|
|
222
|
+
waits, which is why a production deployment should route deferred work to
|
|
223
|
+
an engine that can schedule properly.
|
|
224
|
+
"""
|
|
225
|
+
eligible_at = spec.scheduled_for()
|
|
226
|
+
if eligible_at is None:
|
|
227
|
+
return
|
|
228
|
+
remaining = (eligible_at - datetime.now(UTC)).total_seconds()
|
|
229
|
+
while remaining > 0:
|
|
230
|
+
time.sleep(min(remaining, 0.25))
|
|
231
|
+
remaining = (eligible_at - datetime.now(UTC)).total_seconds()
|
|
232
|
+
|
|
233
|
+
# -- state transitions ---------------------------------------------------- #
|
|
234
|
+
def _transition(self, key: str, state: ExecutionState, **changes: Any) -> None:
|
|
235
|
+
with self._lock:
|
|
236
|
+
entry = self._entries.get(key)
|
|
237
|
+
if entry is None or entry.execution.state is ExecutionState.CANCELLED:
|
|
238
|
+
return
|
|
239
|
+
entry.execution = entry.execution.evolve(state=state, **changes)
|
|
240
|
+
|
|
241
|
+
def _is_cancelled(self, key: str) -> bool:
|
|
242
|
+
with self._lock:
|
|
243
|
+
entry = self._entries.get(key)
|
|
244
|
+
return entry is None or entry.execution.state is ExecutionState.CANCELLED
|
|
245
|
+
|
|
246
|
+
def _finish(
|
|
247
|
+
self,
|
|
248
|
+
key: str,
|
|
249
|
+
*,
|
|
250
|
+
value: Any = None,
|
|
251
|
+
error: BaseException | None = None,
|
|
252
|
+
attempt: int,
|
|
253
|
+
) -> None:
|
|
254
|
+
if error is None:
|
|
255
|
+
result = ExecutionResult(value=value)
|
|
256
|
+
state = ExecutionState.SUCCEEDED
|
|
257
|
+
else:
|
|
258
|
+
result = ExecutionResult.from_exception(error)
|
|
259
|
+
state = ExecutionState.FAILED
|
|
260
|
+
with self._lock:
|
|
261
|
+
entry = self._entries.get(key)
|
|
262
|
+
if entry is None:
|
|
263
|
+
return
|
|
264
|
+
entry.result = result
|
|
265
|
+
entry.execution = entry.execution.evolve(
|
|
266
|
+
state=state, finished_at=datetime.now(UTC), attempt=attempt, result=result
|
|
267
|
+
)
|
|
268
|
+
execution = entry.execution
|
|
269
|
+
self.hooks.after_execute(execution, result)
|
|
270
|
+
if error is None:
|
|
271
|
+
self.hooks.on_success(execution, result)
|
|
272
|
+
else:
|
|
273
|
+
self.hooks.on_failure(execution, error)
|
|
274
|
+
|
|
275
|
+
# -- observation ----------------------------------------------------------- #
|
|
276
|
+
def _get(self, execution_id: ExecutionId) -> Execution:
|
|
277
|
+
with self._lock:
|
|
278
|
+
entry = self._entries.get(str(execution_id))
|
|
279
|
+
if entry is None:
|
|
280
|
+
raise ExecutionNotFound(
|
|
281
|
+
f"task {execution_id!r} is not in this backend's {self._history}-entry history",
|
|
282
|
+
backend=self.name,
|
|
283
|
+
)
|
|
284
|
+
return entry.execution
|
|
285
|
+
|
|
286
|
+
def _result(self, execution_id: ExecutionId, *, timeout: float | None) -> ExecutionResult:
|
|
287
|
+
self._wait(execution_id, timeout=timeout)
|
|
288
|
+
with self._lock:
|
|
289
|
+
entry = self._entries[str(execution_id)]
|
|
290
|
+
result = entry.result
|
|
291
|
+
if result is None: # cancelled before it ran
|
|
292
|
+
return ExecutionResult.cancelled()
|
|
293
|
+
return result
|
|
294
|
+
|
|
295
|
+
def _wait(self, execution_id: ExecutionId, *, timeout: float | None) -> Execution:
|
|
296
|
+
entry_future = self._future_for(execution_id)
|
|
297
|
+
if entry_future is not None:
|
|
298
|
+
try:
|
|
299
|
+
entry_future.result(timeout=timeout)
|
|
300
|
+
except TimeoutError as exc:
|
|
301
|
+
raise TaskferryTimeoutError(
|
|
302
|
+
f"task {execution_id!r} did not finish within {timeout}s"
|
|
303
|
+
) from exc
|
|
304
|
+
except Exception:
|
|
305
|
+
pass # the failure is recorded on the execution, not raised here
|
|
306
|
+
return self._get(execution_id)
|
|
307
|
+
|
|
308
|
+
def _future_for(self, execution_id: ExecutionId) -> Future[Any] | None:
|
|
309
|
+
with self._lock:
|
|
310
|
+
entry = self._entries.get(str(execution_id))
|
|
311
|
+
if entry is None:
|
|
312
|
+
raise ExecutionNotFound(f"task {execution_id!r} is unknown", backend=self.name)
|
|
313
|
+
return entry.future
|
|
314
|
+
|
|
315
|
+
def _cancel(self, execution_id: ExecutionId) -> Execution:
|
|
316
|
+
key = str(execution_id)
|
|
317
|
+
with self._lock:
|
|
318
|
+
entry = self._entries.get(key)
|
|
319
|
+
if entry is None:
|
|
320
|
+
raise ExecutionNotFound(f"task {execution_id!r} is unknown", backend=self.name)
|
|
321
|
+
if entry.execution.is_terminal:
|
|
322
|
+
return entry.execution
|
|
323
|
+
future = entry.future
|
|
324
|
+
# A task already running cannot be interrupted — Python has no safe way to
|
|
325
|
+
# stop a thread mid-call. Cancelling only works before it starts, and the
|
|
326
|
+
# returned state says which happened rather than claiming success.
|
|
327
|
+
stopped = future.cancel() if future is not None else True
|
|
328
|
+
with self._lock:
|
|
329
|
+
entry = self._entries[key]
|
|
330
|
+
if stopped or entry.execution.state is ExecutionState.QUEUED:
|
|
331
|
+
entry.execution = entry.execution.evolve(
|
|
332
|
+
state=ExecutionState.CANCELLED, finished_at=datetime.now(UTC)
|
|
333
|
+
)
|
|
334
|
+
return entry.execution
|
|
335
|
+
|
|
336
|
+
def _remember_locked(self, key: str) -> None:
|
|
337
|
+
self._order.append(key)
|
|
338
|
+
while len(self._order) > self._history:
|
|
339
|
+
evicted = self._order.pop(0)
|
|
340
|
+
if evicted != key:
|
|
341
|
+
self._entries.pop(evicted, None)
|
|
342
|
+
|
|
343
|
+
def close(self) -> None:
|
|
344
|
+
self._closed = True
|
|
345
|
+
self._pool.shutdown(wait=True, cancel_futures=True)
|
|
346
|
+
with self._lock:
|
|
347
|
+
self._entries.clear()
|
|
348
|
+
self._order.clear()
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
__all__ = ["THREAD_CAPABILITIES", "ThreadTaskBackend"]
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""What a backend can actually do.
|
|
2
|
+
|
|
3
|
+
Backends differ. Procrastinate can cancel a queued job; Cloud Tasks cannot. Cloud
|
|
4
|
+
Run Jobs can allocate a GPU; a thread pool cannot. Taskferry refuses to paper over
|
|
5
|
+
this: every backend advertises an immutable :class:`~taskferry.core.CapabilitySet`
|
|
6
|
+
and any operation requiring a capability it does not advertise raises
|
|
7
|
+
:class:`~taskferry.errors.UnsupportedCapability` instead of being silently faked
|
|
8
|
+
or approximated.
|
|
9
|
+
|
|
10
|
+
One enum is shared by all three execution kinds so that a router, a CLI or an
|
|
11
|
+
application can ask the same question of any backend::
|
|
12
|
+
|
|
13
|
+
if Capability.CANCEL in backend.capabilities:
|
|
14
|
+
...
|
|
15
|
+
|
|
16
|
+
Domain-specific capability enums may still subclass
|
|
17
|
+
:class:`taskferry.core.Capability`; the values here are the portable vocabulary.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from taskferry.core.capabilities import Capability as _CapabilityBase
|
|
23
|
+
from taskferry.core.capabilities import CapabilitySet
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Capability(_CapabilityBase):
|
|
27
|
+
"""The portable capability vocabulary shared by every Taskferry backend."""
|
|
28
|
+
|
|
29
|
+
# -- submission --------------------------------------------------------- #
|
|
30
|
+
SUBMIT = "submit"
|
|
31
|
+
"""Accept a spec and return an :class:`~taskferry.execution.Execution`.
|
|
32
|
+
Every backend has this; it is listed so capability sets are self-describing."""
|
|
33
|
+
|
|
34
|
+
DELAY = "delay"
|
|
35
|
+
"""Honour ``TaskSpec.delay`` / ``run_at`` — deferred execution."""
|
|
36
|
+
|
|
37
|
+
PRIORITY = "priority"
|
|
38
|
+
"""Honour ``TaskSpec.priority``."""
|
|
39
|
+
|
|
40
|
+
DEDUPLICATION = "deduplication"
|
|
41
|
+
"""Honour ``idempotency_key`` by collapsing duplicate submissions."""
|
|
42
|
+
|
|
43
|
+
# -- observation -------------------------------------------------------- #
|
|
44
|
+
STATE = "state"
|
|
45
|
+
"""Report the state of a previously submitted execution via ``get()``."""
|
|
46
|
+
|
|
47
|
+
RESULT = "result"
|
|
48
|
+
"""Return the value a successful execution produced."""
|
|
49
|
+
|
|
50
|
+
LOGS = "logs"
|
|
51
|
+
"""Expose a log location for an execution."""
|
|
52
|
+
|
|
53
|
+
PROGRESS = "progress"
|
|
54
|
+
"""Report incremental progress while an execution is running."""
|
|
55
|
+
|
|
56
|
+
# -- control ------------------------------------------------------------ #
|
|
57
|
+
CANCEL = "cancel"
|
|
58
|
+
"""Cancel a queued or running execution."""
|
|
59
|
+
|
|
60
|
+
RETRY = "retry"
|
|
61
|
+
"""Apply a :class:`~taskferry.retry.RetryPolicy` natively."""
|
|
62
|
+
|
|
63
|
+
TIMEOUT = "timeout"
|
|
64
|
+
"""Enforce a wall-clock timeout on an execution."""
|
|
65
|
+
|
|
66
|
+
SCHEDULE = "schedule"
|
|
67
|
+
"""Register a recurring schedule (distinct from a one-off delay)."""
|
|
68
|
+
|
|
69
|
+
# -- resources (jobs) ---------------------------------------------------- #
|
|
70
|
+
PARALLELISM = "parallelism"
|
|
71
|
+
"""Run more than one task instance per submission (array/indexed jobs)."""
|
|
72
|
+
|
|
73
|
+
CPU = "cpu"
|
|
74
|
+
"""Honour a per-execution CPU request."""
|
|
75
|
+
|
|
76
|
+
MEMORY = "memory"
|
|
77
|
+
"""Honour a per-execution memory request."""
|
|
78
|
+
|
|
79
|
+
GPU = "gpu"
|
|
80
|
+
"""Allocate GPUs to an execution."""
|
|
81
|
+
|
|
82
|
+
ENVIRONMENT = "environment"
|
|
83
|
+
"""Inject environment variables into an execution."""
|
|
84
|
+
|
|
85
|
+
# -- calling conventions -------------------------------------------------- #
|
|
86
|
+
ASYNC_CALLABLE = "async_callable"
|
|
87
|
+
"""Execute ``async def`` callables natively."""
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
__all__ = ["Capability", "CapabilitySet"]
|