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,38 @@
|
|
|
1
|
+
"""Backends that ship with Taskferry and need nothing installed.
|
|
2
|
+
|
|
3
|
+
```mermaid
|
|
4
|
+
flowchart TD
|
|
5
|
+
RT["Taskferry runtime"]
|
|
6
|
+
|
|
7
|
+
RT -->|"inline"| I["InlineExecutionBackend<br/>this thread, right now"]
|
|
8
|
+
RT -->|"task"| T["ThreadTaskBackend<br/>ThreadPoolExecutor"]
|
|
9
|
+
RT -->|"task"| P["ProcessTaskBackend<br/>ProcessPoolExecutor"]
|
|
10
|
+
RT -->|"job"| S["SubprocessJobBackend<br/>child processes"]
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
These exist so that Taskferry is useful with zero infrastructure — in a test, a
|
|
14
|
+
notebook, a CLI, a script, a small application — and so that every port has at
|
|
15
|
+
least one honest reference implementation to test adapters against.
|
|
16
|
+
|
|
17
|
+
They are **not** a queue. There is no durability, no cross-process visibility and
|
|
18
|
+
no delivery guarantee beyond "this process tried". A thread pool that dies takes
|
|
19
|
+
its pending work with it. That is stated plainly rather than papered over, and it
|
|
20
|
+
is exactly why the Procrastinate and Cloud Run adapters exist.
|
|
21
|
+
|
|
22
|
+
Each module imports lazily from this package, so ``import taskferry`` does not
|
|
23
|
+
create a process pool or touch the filesystem.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
from .inline import InlineExecutionBackend
|
|
29
|
+
from .process import ProcessTaskBackend
|
|
30
|
+
from .subprocess import SubprocessJobBackend
|
|
31
|
+
from .thread import ThreadTaskBackend
|
|
32
|
+
|
|
33
|
+
__all__ = [
|
|
34
|
+
"InlineExecutionBackend",
|
|
35
|
+
"ProcessTaskBackend",
|
|
36
|
+
"SubprocessJobBackend",
|
|
37
|
+
"ThreadTaskBackend",
|
|
38
|
+
]
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"""Inline execution — run it here, run it now.
|
|
2
|
+
|
|
3
|
+
The simplest backend and the one that makes Taskferry usable before any
|
|
4
|
+
infrastructure exists. ``submit`` runs the callable synchronously and returns an
|
|
5
|
+
already-terminal execution:
|
|
6
|
+
|
|
7
|
+
```mermaid
|
|
8
|
+
sequenceDiagram
|
|
9
|
+
participant App
|
|
10
|
+
participant TP as Taskferry
|
|
11
|
+
participant IB as InlineExecutionBackend
|
|
12
|
+
participant F as your function
|
|
13
|
+
|
|
14
|
+
App->>TP: inline.submit(add, 20, 22)
|
|
15
|
+
TP->>IB: submit(InlineSpec)
|
|
16
|
+
IB->>F: add(20, 22)
|
|
17
|
+
F-->>IB: 42
|
|
18
|
+
IB-->>TP: Execution(SUCCEEDED, result=42)
|
|
19
|
+
TP-->>App: ExecutionHandle (already done)
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
What it honestly does **not** do
|
|
23
|
+
--------------------------------
|
|
24
|
+
|
|
25
|
+
* **No timeout.** Nothing can preempt a synchronous Python call in the calling
|
|
26
|
+
thread. ``TIMEOUT`` is not advertised, so a spec asking for one is rejected
|
|
27
|
+
rather than silently ignored.
|
|
28
|
+
* **No cancel.** By the time ``submit`` returns, the work is over.
|
|
29
|
+
* **No delay.** Sleeping the caller's thread to honour ``delay`` would be a
|
|
30
|
+
denial of service dressed up as a feature.
|
|
31
|
+
|
|
32
|
+
Retries *are* real here: this backend is the engine, so it owns the retry and
|
|
33
|
+
applies the :class:`~taskferry.retry.RetryPolicy` itself, backoff included.
|
|
34
|
+
|
|
35
|
+
Async callables
|
|
36
|
+
---------------
|
|
37
|
+
|
|
38
|
+
An ``async def`` is awaited with :func:`asyncio.run`. If a loop is already
|
|
39
|
+
running in this thread, the coroutine is run on a private loop in a worker
|
|
40
|
+
thread — blocking the caller either way, because ``submit`` is synchronous by
|
|
41
|
+
contract. Inside async code, use ``await runtime.inline.submit_async(...)``
|
|
42
|
+
instead of blocking the loop.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
from __future__ import annotations
|
|
46
|
+
|
|
47
|
+
import asyncio
|
|
48
|
+
import concurrent.futures
|
|
49
|
+
import contextlib
|
|
50
|
+
import time
|
|
51
|
+
from collections.abc import Coroutine
|
|
52
|
+
from datetime import UTC, datetime
|
|
53
|
+
from typing import Any
|
|
54
|
+
|
|
55
|
+
from ..capabilities import Capability, CapabilitySet
|
|
56
|
+
from ..core.correlation import use_correlation
|
|
57
|
+
from ..execution import (
|
|
58
|
+
Execution,
|
|
59
|
+
ExecutionId,
|
|
60
|
+
ExecutionKind,
|
|
61
|
+
ExecutionResult,
|
|
62
|
+
ExecutionState,
|
|
63
|
+
new_execution_id,
|
|
64
|
+
)
|
|
65
|
+
from ..functions import is_async_callable
|
|
66
|
+
from ..ports import BaseBackend
|
|
67
|
+
from ..retry import RetryPolicy
|
|
68
|
+
from ..specs import ExecutionSpec, InlineSpec
|
|
69
|
+
|
|
70
|
+
INLINE_CAPABILITIES = frozenset(
|
|
71
|
+
{
|
|
72
|
+
Capability.SUBMIT,
|
|
73
|
+
Capability.STATE,
|
|
74
|
+
Capability.RESULT,
|
|
75
|
+
Capability.RETRY,
|
|
76
|
+
Capability.ASYNC_CALLABLE,
|
|
77
|
+
}
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class InlineExecutionBackend(BaseBackend):
|
|
82
|
+
"""Runs :class:`~taskferry.specs.InlineSpec` callables in the calling thread.
|
|
83
|
+
|
|
84
|
+
Finished executions are kept in a bounded ring so ``get()``/``result()`` work
|
|
85
|
+
for a while after submission. The bound matters: a long-running process that
|
|
86
|
+
submits inline work in a loop must not accumulate results forever. Once an id
|
|
87
|
+
ages out, ``get()`` raises :class:`~taskferry.errors.ExecutionNotFound` — the
|
|
88
|
+
handle returned by ``submit`` still carries the terminal snapshot and the
|
|
89
|
+
result, so the common path never depends on the cache.
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
def __init__(self, *, history: int = 1024) -> None:
|
|
93
|
+
if history < 0:
|
|
94
|
+
raise ValueError("history must be >= 0")
|
|
95
|
+
self._history = history
|
|
96
|
+
self._executions: dict[str, Execution] = {}
|
|
97
|
+
self._results: dict[str, ExecutionResult] = {}
|
|
98
|
+
self._order: list[str] = []
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def name(self) -> str:
|
|
102
|
+
return "inline"
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def kind(self) -> ExecutionKind:
|
|
106
|
+
return ExecutionKind.INLINE
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def capabilities(self) -> CapabilitySet:
|
|
110
|
+
return CapabilitySet(INLINE_CAPABILITIES, provider=self.name)
|
|
111
|
+
|
|
112
|
+
# -- submission ---------------------------------------------------------- #
|
|
113
|
+
def _submit(self, spec: ExecutionSpec) -> Execution:
|
|
114
|
+
assert isinstance(spec, InlineSpec) # guaranteed by BaseBackend.validate
|
|
115
|
+
execution_id = new_execution_id(ExecutionKind.INLINE)
|
|
116
|
+
started = datetime.now(UTC)
|
|
117
|
+
base = Execution(
|
|
118
|
+
id=execution_id,
|
|
119
|
+
kind=ExecutionKind.INLINE,
|
|
120
|
+
backend=self.name,
|
|
121
|
+
state=ExecutionState.RUNNING,
|
|
122
|
+
name=spec.name,
|
|
123
|
+
created_at=started,
|
|
124
|
+
started_at=started,
|
|
125
|
+
correlation=spec.correlation,
|
|
126
|
+
)
|
|
127
|
+
self.hooks.before_execute(base)
|
|
128
|
+
|
|
129
|
+
value, error, attempts = self._call_with_retries(spec, base)
|
|
130
|
+
finished = datetime.now(UTC)
|
|
131
|
+
|
|
132
|
+
if error is None:
|
|
133
|
+
result = ExecutionResult(value=value)
|
|
134
|
+
state = ExecutionState.SUCCEEDED
|
|
135
|
+
else:
|
|
136
|
+
result = ExecutionResult.from_exception(error)
|
|
137
|
+
state = ExecutionState.FAILED
|
|
138
|
+
|
|
139
|
+
execution = base.evolve(
|
|
140
|
+
state=state,
|
|
141
|
+
finished_at=finished,
|
|
142
|
+
attempt=attempts,
|
|
143
|
+
result=result,
|
|
144
|
+
)
|
|
145
|
+
self.hooks.after_execute(execution, result)
|
|
146
|
+
if error is None:
|
|
147
|
+
self.hooks.on_success(execution, result)
|
|
148
|
+
else:
|
|
149
|
+
self.hooks.on_failure(execution, error)
|
|
150
|
+
self._remember(execution, result)
|
|
151
|
+
return execution
|
|
152
|
+
|
|
153
|
+
def _call_with_retries(
|
|
154
|
+
self, spec: InlineSpec, execution: Execution
|
|
155
|
+
) -> tuple[Any, BaseException | None, int]:
|
|
156
|
+
"""Run the callable, applying the retry policy. This backend owns retries."""
|
|
157
|
+
policy = spec.retry
|
|
158
|
+
attempt = 1
|
|
159
|
+
bind = (
|
|
160
|
+
use_correlation(spec.correlation)
|
|
161
|
+
if spec.correlation is not None
|
|
162
|
+
else contextlib.nullcontext()
|
|
163
|
+
)
|
|
164
|
+
while True:
|
|
165
|
+
try:
|
|
166
|
+
with bind:
|
|
167
|
+
return self._call(spec), None, attempt
|
|
168
|
+
except Exception as exc:
|
|
169
|
+
if not policy.should_retry(exc, attempt):
|
|
170
|
+
return None, exc, attempt
|
|
171
|
+
self.hooks.on_retry(execution, attempt + 1, exc)
|
|
172
|
+
_sleep(policy, attempt + 1)
|
|
173
|
+
attempt += 1
|
|
174
|
+
|
|
175
|
+
def _call(self, spec: InlineSpec) -> Any:
|
|
176
|
+
if not is_async_callable(spec.func):
|
|
177
|
+
return spec.func(*spec.args, **spec.kwargs)
|
|
178
|
+
coro: Coroutine[Any, Any, Any] = spec.func(*spec.args, **spec.kwargs)
|
|
179
|
+
return _run_coroutine(coro)
|
|
180
|
+
|
|
181
|
+
# -- observation ---------------------------------------------------------- #
|
|
182
|
+
def _get(self, execution_id: ExecutionId) -> Execution:
|
|
183
|
+
execution = self._executions.get(str(execution_id))
|
|
184
|
+
if execution is None:
|
|
185
|
+
from ..errors import ExecutionNotFound
|
|
186
|
+
|
|
187
|
+
raise ExecutionNotFound(
|
|
188
|
+
f"inline execution {execution_id!r} is not in this backend's "
|
|
189
|
+
f"{self._history}-entry history",
|
|
190
|
+
backend=self.name,
|
|
191
|
+
)
|
|
192
|
+
return execution
|
|
193
|
+
|
|
194
|
+
def _result(self, execution_id: ExecutionId, *, timeout: float | None) -> ExecutionResult:
|
|
195
|
+
self._get(execution_id) # raises ExecutionNotFound with a good message
|
|
196
|
+
return self._results[str(execution_id)]
|
|
197
|
+
|
|
198
|
+
def _remember(self, execution: Execution, result: ExecutionResult) -> None:
|
|
199
|
+
if self._history == 0:
|
|
200
|
+
return
|
|
201
|
+
key = str(execution.id)
|
|
202
|
+
self._executions[key] = execution
|
|
203
|
+
self._results[key] = result
|
|
204
|
+
self._order.append(key)
|
|
205
|
+
while len(self._order) > self._history:
|
|
206
|
+
evicted = self._order.pop(0)
|
|
207
|
+
self._executions.pop(evicted, None)
|
|
208
|
+
self._results.pop(evicted, None)
|
|
209
|
+
|
|
210
|
+
def close(self) -> None:
|
|
211
|
+
self._executions.clear()
|
|
212
|
+
self._results.clear()
|
|
213
|
+
self._order.clear()
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _sleep(policy: RetryPolicy, attempt: int) -> None:
|
|
217
|
+
delay = policy.delay_for(attempt)
|
|
218
|
+
if delay > 0:
|
|
219
|
+
time.sleep(delay)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _run_coroutine(coro: Coroutine[Any, Any, Any]) -> Any:
|
|
223
|
+
"""Await ``coro`` from synchronous code, whether or not a loop is running."""
|
|
224
|
+
try:
|
|
225
|
+
asyncio.get_running_loop()
|
|
226
|
+
except RuntimeError:
|
|
227
|
+
return asyncio.run(coro)
|
|
228
|
+
# A loop is already running in this thread; asyncio.run() would raise. Run
|
|
229
|
+
# the coroutine on its own loop in a worker thread instead. This blocks the
|
|
230
|
+
# caller, which is the documented contract of the synchronous submit().
|
|
231
|
+
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
|
|
232
|
+
return pool.submit(asyncio.run, coro).result()
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
__all__ = ["INLINE_CAPABILITIES", "InlineExecutionBackend"]
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
"""A task backend backed by a process pool.
|
|
2
|
+
|
|
3
|
+
Same shape as :mod:`taskferry.backends.thread`, one significant difference: the
|
|
4
|
+
work runs in a *different interpreter*, so it is a genuine rehearsal for what a
|
|
5
|
+
real worker deployment does to your code.
|
|
6
|
+
|
|
7
|
+
```mermaid
|
|
8
|
+
flowchart LR
|
|
9
|
+
P["parent process"]
|
|
10
|
+
C1["worker process 1"]
|
|
11
|
+
C2["worker process 2"]
|
|
12
|
+
|
|
13
|
+
P -->|"'pkg.mod:fn' + JSON args"| C1
|
|
14
|
+
P -->|"'pkg.mod:fn' + JSON args"| C2
|
|
15
|
+
C1 -->|"JSON result"| P
|
|
16
|
+
C2 -->|"JSON result"| P
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Nothing but the function *name* and JSON arguments crosses the boundary — the
|
|
20
|
+
same contract Procrastinate and Cloud Tasks impose. Code that works here works
|
|
21
|
+
on a worker; code that relied on a closure, a module-level global mutated at
|
|
22
|
+
import time, or a live ORM object fails here, at development time, where it is
|
|
23
|
+
cheap to find. That is the whole reason this backend exists alongside the thread
|
|
24
|
+
pool.
|
|
25
|
+
|
|
26
|
+
Because the child imports the function by name, ``__main__``-defined functions
|
|
27
|
+
and lambdas cannot be used —
|
|
28
|
+
:class:`~taskferry.functions.FunctionRef` rejects them at submit time with an
|
|
29
|
+
explanation rather than letting the pool fail cryptically.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import threading
|
|
35
|
+
from concurrent.futures import Future, ProcessPoolExecutor
|
|
36
|
+
from datetime import UTC, datetime
|
|
37
|
+
from typing import Any
|
|
38
|
+
|
|
39
|
+
from ..capabilities import Capability, CapabilitySet
|
|
40
|
+
from ..core.typing import JSONValue
|
|
41
|
+
from ..errors import ExecutionNotFound, SubmissionError, TaskferryTimeoutError
|
|
42
|
+
from ..execution import (
|
|
43
|
+
Execution,
|
|
44
|
+
ExecutionId,
|
|
45
|
+
ExecutionKind,
|
|
46
|
+
ExecutionResult,
|
|
47
|
+
ExecutionState,
|
|
48
|
+
new_execution_id,
|
|
49
|
+
)
|
|
50
|
+
from ..functions import FunctionRef, is_async_callable
|
|
51
|
+
from ..ports import BaseBackend
|
|
52
|
+
from ..retry import RetryPolicy
|
|
53
|
+
from ..specs import ExecutionSpec, TaskSpec
|
|
54
|
+
|
|
55
|
+
PROCESS_CAPABILITIES = frozenset(
|
|
56
|
+
{
|
|
57
|
+
Capability.SUBMIT,
|
|
58
|
+
Capability.STATE,
|
|
59
|
+
Capability.RESULT,
|
|
60
|
+
Capability.CANCEL,
|
|
61
|
+
Capability.RETRY,
|
|
62
|
+
Capability.ASYNC_CALLABLE,
|
|
63
|
+
}
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _execute_in_child(
|
|
68
|
+
task: str,
|
|
69
|
+
args: list[JSONValue],
|
|
70
|
+
kwargs: dict[str, JSONValue],
|
|
71
|
+
policy: RetryPolicy,
|
|
72
|
+
) -> Any:
|
|
73
|
+
"""Entry point that runs in the worker process.
|
|
74
|
+
|
|
75
|
+
Module-level and picklable on purpose: a ``ProcessPoolExecutor`` can only
|
|
76
|
+
dispatch to something importable, which is the same constraint every real
|
|
77
|
+
task engine imposes.
|
|
78
|
+
|
|
79
|
+
Retries happen here, in the child, so an attempt that dies takes only its own
|
|
80
|
+
process time and the parent is not blocked coordinating them.
|
|
81
|
+
"""
|
|
82
|
+
import asyncio
|
|
83
|
+
|
|
84
|
+
func = FunctionRef.parse(task).resolve()
|
|
85
|
+
attempt = 1
|
|
86
|
+
while True:
|
|
87
|
+
try:
|
|
88
|
+
if is_async_callable(func):
|
|
89
|
+
return asyncio.run(func(*args, **kwargs))
|
|
90
|
+
return func(*args, **kwargs)
|
|
91
|
+
except Exception as exc:
|
|
92
|
+
if not policy.should_retry(exc, attempt):
|
|
93
|
+
raise
|
|
94
|
+
attempt += 1
|
|
95
|
+
delay = policy.delay_for(attempt)
|
|
96
|
+
if delay:
|
|
97
|
+
import time
|
|
98
|
+
|
|
99
|
+
time.sleep(delay)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class _Entry:
|
|
103
|
+
__slots__ = ("execution", "future", "result")
|
|
104
|
+
|
|
105
|
+
def __init__(self, execution: Execution) -> None:
|
|
106
|
+
self.execution = execution
|
|
107
|
+
self.future: Future[Any] | None = None
|
|
108
|
+
self.result: ExecutionResult | None = None
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class ProcessTaskBackend(BaseBackend):
|
|
112
|
+
"""Runs tasks on a :class:`~concurrent.futures.ProcessPoolExecutor`.
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
max_workers: Pool size. ``None`` uses the interpreter default.
|
|
116
|
+
history: How many finished executions to keep for ``get``/``result``.
|
|
117
|
+
|
|
118
|
+
Like the thread backend this is **not** durable: pending work dies with the
|
|
119
|
+
parent. It does not advertise ``DELAY`` (nothing here schedules) or
|
|
120
|
+
``PRIORITY`` (the pool is FIFO).
|
|
121
|
+
|
|
122
|
+
The pool is created lazily on first submit, so constructing this backend —
|
|
123
|
+
which the runtime does eagerly when it is configured — never forks.
|
|
124
|
+
"""
|
|
125
|
+
|
|
126
|
+
def __init__(self, *, max_workers: int | None = None, history: int = 1024) -> None:
|
|
127
|
+
self._max_workers = max_workers
|
|
128
|
+
self._history = history
|
|
129
|
+
self._pool: ProcessPoolExecutor | None = None
|
|
130
|
+
self._entries: dict[str, _Entry] = {}
|
|
131
|
+
self._order: list[str] = []
|
|
132
|
+
self._lock = threading.Lock()
|
|
133
|
+
self._closed = False
|
|
134
|
+
|
|
135
|
+
@property
|
|
136
|
+
def name(self) -> str:
|
|
137
|
+
return "process"
|
|
138
|
+
|
|
139
|
+
@property
|
|
140
|
+
def kind(self) -> ExecutionKind:
|
|
141
|
+
return ExecutionKind.TASK
|
|
142
|
+
|
|
143
|
+
@property
|
|
144
|
+
def capabilities(self) -> CapabilitySet:
|
|
145
|
+
return CapabilitySet(PROCESS_CAPABILITIES, provider=self.name)
|
|
146
|
+
|
|
147
|
+
def _ensure_pool(self) -> ProcessPoolExecutor:
|
|
148
|
+
with self._lock:
|
|
149
|
+
if self._closed:
|
|
150
|
+
raise SubmissionError("this ProcessTaskBackend is closed", backend=self.name)
|
|
151
|
+
if self._pool is None:
|
|
152
|
+
self._pool = ProcessPoolExecutor(max_workers=self._max_workers)
|
|
153
|
+
return self._pool
|
|
154
|
+
|
|
155
|
+
# -- submission ------------------------------------------------------------ #
|
|
156
|
+
def _submit(self, spec: ExecutionSpec) -> Execution:
|
|
157
|
+
assert isinstance(spec, TaskSpec)
|
|
158
|
+
pool = self._ensure_pool()
|
|
159
|
+
execution = Execution(
|
|
160
|
+
id=new_execution_id(ExecutionKind.TASK),
|
|
161
|
+
kind=ExecutionKind.TASK,
|
|
162
|
+
backend=self.name,
|
|
163
|
+
state=ExecutionState.QUEUED,
|
|
164
|
+
name=spec.name,
|
|
165
|
+
created_at=datetime.now(UTC),
|
|
166
|
+
correlation=spec.correlation,
|
|
167
|
+
metadata={"queue": spec.queue},
|
|
168
|
+
)
|
|
169
|
+
key = str(execution.id)
|
|
170
|
+
entry = _Entry(execution)
|
|
171
|
+
with self._lock:
|
|
172
|
+
self._entries[key] = entry
|
|
173
|
+
self._remember_locked(key)
|
|
174
|
+
try:
|
|
175
|
+
future = pool.submit(
|
|
176
|
+
_execute_in_child,
|
|
177
|
+
spec.task,
|
|
178
|
+
list(spec.args),
|
|
179
|
+
dict(spec.kwargs),
|
|
180
|
+
spec.retry,
|
|
181
|
+
)
|
|
182
|
+
except Exception as exc:
|
|
183
|
+
raise SubmissionError(
|
|
184
|
+
f"could not dispatch {spec.task!r} to the process pool: {exc}", backend=self.name
|
|
185
|
+
) from exc
|
|
186
|
+
entry.future = future
|
|
187
|
+
future.add_done_callback(lambda fut: self._settle(key, fut))
|
|
188
|
+
return execution
|
|
189
|
+
|
|
190
|
+
def _settle(self, key: str, future: Future[Any]) -> None:
|
|
191
|
+
"""Record the outcome once the child reports back."""
|
|
192
|
+
if future.cancelled():
|
|
193
|
+
self._set_state(key, ExecutionState.CANCELLED)
|
|
194
|
+
return
|
|
195
|
+
error = future.exception()
|
|
196
|
+
if error is None:
|
|
197
|
+
result = ExecutionResult(value=future.result())
|
|
198
|
+
state = ExecutionState.SUCCEEDED
|
|
199
|
+
else:
|
|
200
|
+
result = ExecutionResult.from_exception(error)
|
|
201
|
+
state = ExecutionState.FAILED
|
|
202
|
+
with self._lock:
|
|
203
|
+
entry = self._entries.get(key)
|
|
204
|
+
if entry is None:
|
|
205
|
+
return
|
|
206
|
+
entry.result = result
|
|
207
|
+
entry.execution = entry.execution.evolve(
|
|
208
|
+
state=state, finished_at=datetime.now(UTC), result=result
|
|
209
|
+
)
|
|
210
|
+
execution = entry.execution
|
|
211
|
+
self.hooks.after_execute(execution, result)
|
|
212
|
+
if error is None:
|
|
213
|
+
self.hooks.on_success(execution, result)
|
|
214
|
+
else:
|
|
215
|
+
self.hooks.on_failure(execution, error)
|
|
216
|
+
|
|
217
|
+
def _set_state(self, key: str, state: ExecutionState) -> None:
|
|
218
|
+
with self._lock:
|
|
219
|
+
entry = self._entries.get(key)
|
|
220
|
+
if entry is None:
|
|
221
|
+
return
|
|
222
|
+
entry.execution = entry.execution.evolve(state=state, finished_at=datetime.now(UTC))
|
|
223
|
+
|
|
224
|
+
# -- observation ------------------------------------------------------------ #
|
|
225
|
+
def _entry(self, execution_id: ExecutionId) -> _Entry:
|
|
226
|
+
with self._lock:
|
|
227
|
+
entry = self._entries.get(str(execution_id))
|
|
228
|
+
if entry is None:
|
|
229
|
+
raise ExecutionNotFound(
|
|
230
|
+
f"task {execution_id!r} is not in this backend's {self._history}-entry history",
|
|
231
|
+
backend=self.name,
|
|
232
|
+
)
|
|
233
|
+
return entry
|
|
234
|
+
|
|
235
|
+
def _get(self, execution_id: ExecutionId) -> Execution:
|
|
236
|
+
entry = self._entry(execution_id)
|
|
237
|
+
# RUNNING is not observable across the pool boundary: a future is either
|
|
238
|
+
# pending or done, so QUEUED stands until the child reports.
|
|
239
|
+
if entry.future is not None and entry.future.running():
|
|
240
|
+
with self._lock:
|
|
241
|
+
if entry.execution.state is ExecutionState.QUEUED:
|
|
242
|
+
entry.execution = entry.execution.evolve(state=ExecutionState.RUNNING)
|
|
243
|
+
return entry.execution
|
|
244
|
+
|
|
245
|
+
def _wait(self, execution_id: ExecutionId, *, timeout: float | None) -> Execution:
|
|
246
|
+
entry = self._entry(execution_id)
|
|
247
|
+
if entry.future is not None:
|
|
248
|
+
try:
|
|
249
|
+
entry.future.result(timeout=timeout)
|
|
250
|
+
except TimeoutError as exc:
|
|
251
|
+
raise TaskferryTimeoutError(
|
|
252
|
+
f"task {execution_id!r} did not finish within {timeout}s"
|
|
253
|
+
) from exc
|
|
254
|
+
except Exception:
|
|
255
|
+
pass # recorded on the execution by _settle
|
|
256
|
+
return self._get(execution_id)
|
|
257
|
+
|
|
258
|
+
def _result(self, execution_id: ExecutionId, *, timeout: float | None) -> ExecutionResult:
|
|
259
|
+
self._wait(execution_id, timeout=timeout)
|
|
260
|
+
result = self._entry(execution_id).result
|
|
261
|
+
if result is None:
|
|
262
|
+
return ExecutionResult.cancelled()
|
|
263
|
+
return result
|
|
264
|
+
|
|
265
|
+
def _cancel(self, execution_id: ExecutionId) -> Execution:
|
|
266
|
+
entry = self._entry(execution_id)
|
|
267
|
+
if entry.execution.is_terminal:
|
|
268
|
+
return entry.execution
|
|
269
|
+
# Only a task that has not been picked up can be stopped; a running child
|
|
270
|
+
# is left alone rather than killed mid-write.
|
|
271
|
+
if entry.future is not None and entry.future.cancel():
|
|
272
|
+
self._set_state(str(execution_id), ExecutionState.CANCELLED)
|
|
273
|
+
return self._entry(execution_id).execution
|
|
274
|
+
|
|
275
|
+
def _remember_locked(self, key: str) -> None:
|
|
276
|
+
self._order.append(key)
|
|
277
|
+
while len(self._order) > self._history:
|
|
278
|
+
evicted = self._order.pop(0)
|
|
279
|
+
if evicted != key:
|
|
280
|
+
self._entries.pop(evicted, None)
|
|
281
|
+
|
|
282
|
+
def close(self) -> None:
|
|
283
|
+
with self._lock:
|
|
284
|
+
self._closed = True
|
|
285
|
+
pool, self._pool = self._pool, None
|
|
286
|
+
self._entries.clear()
|
|
287
|
+
self._order.clear()
|
|
288
|
+
if pool is not None:
|
|
289
|
+
pool.shutdown(wait=True, cancel_futures=True)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
__all__ = ["PROCESS_CAPABILITIES", "ProcessTaskBackend"]
|