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,390 @@
|
|
|
1
|
+
"""A job backend that runs workloads as local child processes.
|
|
2
|
+
|
|
3
|
+
The zero-infrastructure `JobBackend`: what Cloud Run Jobs and Kubernetes Jobs do
|
|
4
|
+
remotely, this does with :mod:`subprocess`. Same `JobSpec`, same `Execution`,
|
|
5
|
+
same state machine — so the development loop and the production deployment
|
|
6
|
+
differ by one line of routing configuration.
|
|
7
|
+
|
|
8
|
+
```mermaid
|
|
9
|
+
flowchart LR
|
|
10
|
+
SPEC["JobSpec<br/>command · env · timeout"]
|
|
11
|
+
B["SubprocessJobBackend"]
|
|
12
|
+
P["child process"]
|
|
13
|
+
LOG["log file"]
|
|
14
|
+
|
|
15
|
+
SPEC --> B --> P
|
|
16
|
+
P -->|"stdout + stderr"| LOG
|
|
17
|
+
B -->|"exit code"| SPEC
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Security and honesty
|
|
21
|
+
--------------------
|
|
22
|
+
|
|
23
|
+
The command is executed **without a shell** (``shell=False``, argv list), so a
|
|
24
|
+
job name or argument can never turn into shell metacharacters.
|
|
25
|
+
|
|
26
|
+
``image`` is ignored — there is no container here — and the capability set says
|
|
27
|
+
so by omitting ``CPU``, ``MEMORY``, ``GPU`` and ``PARALLELISM``. A spec asking
|
|
28
|
+
for two GPUs is rejected at submit time instead of quietly running on the CPU and
|
|
29
|
+
producing results nobody can explain three days later.
|
|
30
|
+
|
|
31
|
+
Handles are process-local: the backend tracks children in memory, so an execution
|
|
32
|
+
id is only meaningful inside the process that created it.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
import os
|
|
38
|
+
import subprocess
|
|
39
|
+
import tempfile
|
|
40
|
+
import threading
|
|
41
|
+
import time
|
|
42
|
+
from collections.abc import Iterator
|
|
43
|
+
from dataclasses import dataclass
|
|
44
|
+
from datetime import UTC, datetime
|
|
45
|
+
from pathlib import Path
|
|
46
|
+
from typing import Any
|
|
47
|
+
|
|
48
|
+
from ..capabilities import Capability, CapabilitySet
|
|
49
|
+
from ..core.provider import ProviderMetadata
|
|
50
|
+
from ..errors import ExecutionNotFound, SubmissionError, TaskferryTimeoutError
|
|
51
|
+
from ..execution import (
|
|
52
|
+
Execution,
|
|
53
|
+
ExecutionId,
|
|
54
|
+
ExecutionKind,
|
|
55
|
+
ExecutionResult,
|
|
56
|
+
ExecutionState,
|
|
57
|
+
new_execution_id,
|
|
58
|
+
)
|
|
59
|
+
from ..ports import BaseBackend
|
|
60
|
+
from ..specs import ExecutionSpec, JobSpec
|
|
61
|
+
|
|
62
|
+
SUBPROCESS_CAPABILITIES = frozenset(
|
|
63
|
+
{
|
|
64
|
+
Capability.SUBMIT,
|
|
65
|
+
Capability.STATE,
|
|
66
|
+
Capability.RESULT,
|
|
67
|
+
Capability.CANCEL,
|
|
68
|
+
Capability.LOGS,
|
|
69
|
+
Capability.TIMEOUT,
|
|
70
|
+
Capability.ENVIRONMENT,
|
|
71
|
+
}
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
TERMINATE_GRACE_SECONDS = 5.0
|
|
75
|
+
"""How long a terminated child gets to exit cleanly before it is killed."""
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass
|
|
79
|
+
class _Record:
|
|
80
|
+
"""Live bookkeeping for one child process."""
|
|
81
|
+
|
|
82
|
+
process: subprocess.Popen[bytes]
|
|
83
|
+
spec: JobSpec
|
|
84
|
+
log_path: Path
|
|
85
|
+
execution: Execution
|
|
86
|
+
cancelled: bool = False
|
|
87
|
+
timed_out: bool = False
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class SubprocessJobBackend(BaseBackend):
|
|
91
|
+
"""Runs :class:`~taskferry.specs.JobSpec` workloads as child processes.
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
log_dir: Where combined stdout/stderr is written. Defaults to the system
|
|
95
|
+
temporary directory.
|
|
96
|
+
inherit_env: Whether the child inherits the parent's environment before
|
|
97
|
+
``spec.env`` is applied. Turn it off for a clean, reproducible
|
|
98
|
+
environment closer to what a container gets.
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
def __init__(
|
|
102
|
+
self,
|
|
103
|
+
*,
|
|
104
|
+
log_dir: str | os.PathLike[str] | None = None,
|
|
105
|
+
inherit_env: bool = True,
|
|
106
|
+
) -> None:
|
|
107
|
+
self._log_dir = Path(log_dir) if log_dir is not None else Path(tempfile.gettempdir())
|
|
108
|
+
self._log_dir.mkdir(parents=True, exist_ok=True)
|
|
109
|
+
self._inherit_env = inherit_env
|
|
110
|
+
self._records: dict[str, _Record] = {}
|
|
111
|
+
self._lock = threading.Lock()
|
|
112
|
+
|
|
113
|
+
@property
|
|
114
|
+
def name(self) -> str:
|
|
115
|
+
return "subprocess"
|
|
116
|
+
|
|
117
|
+
@property
|
|
118
|
+
def kind(self) -> ExecutionKind:
|
|
119
|
+
return ExecutionKind.JOB
|
|
120
|
+
|
|
121
|
+
@property
|
|
122
|
+
def capabilities(self) -> CapabilitySet:
|
|
123
|
+
return CapabilitySet(SUBPROCESS_CAPABILITIES, provider=self.name)
|
|
124
|
+
|
|
125
|
+
@property
|
|
126
|
+
def log_dir(self) -> Path:
|
|
127
|
+
return self._log_dir
|
|
128
|
+
|
|
129
|
+
# -- submission ------------------------------------------------------------ #
|
|
130
|
+
def _submit(self, spec: ExecutionSpec) -> Execution:
|
|
131
|
+
assert isinstance(spec, JobSpec)
|
|
132
|
+
argv = list(spec.argv)
|
|
133
|
+
if not argv:
|
|
134
|
+
raise SubmissionError(
|
|
135
|
+
f"job {spec.job!r} has no command; the subprocess backend needs an argv "
|
|
136
|
+
"(image-only specs need a container runtime)",
|
|
137
|
+
backend=self.name,
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
execution_id = new_execution_id(ExecutionKind.JOB)
|
|
141
|
+
log_path = self._log_dir / f"{execution_id}.log"
|
|
142
|
+
env = {**os.environ, **spec.env} if self._inherit_env else dict(spec.env)
|
|
143
|
+
started = datetime.now(UTC)
|
|
144
|
+
|
|
145
|
+
log_file = log_path.open("wb")
|
|
146
|
+
try:
|
|
147
|
+
process = subprocess.Popen(
|
|
148
|
+
argv,
|
|
149
|
+
env=env,
|
|
150
|
+
cwd=spec.working_dir,
|
|
151
|
+
stdout=log_file,
|
|
152
|
+
stderr=subprocess.STDOUT,
|
|
153
|
+
close_fds=True,
|
|
154
|
+
)
|
|
155
|
+
except OSError as exc:
|
|
156
|
+
log_file.close()
|
|
157
|
+
raise SubmissionError(
|
|
158
|
+
f"could not start job {spec.job!r} ({argv[0]!r}): {exc}", backend=self.name
|
|
159
|
+
) from exc
|
|
160
|
+
finally:
|
|
161
|
+
# The child holds its own descriptor; ours would only keep the file
|
|
162
|
+
# open past the child's exit.
|
|
163
|
+
if not log_file.closed:
|
|
164
|
+
log_file.close()
|
|
165
|
+
|
|
166
|
+
execution = Execution(
|
|
167
|
+
id=execution_id,
|
|
168
|
+
kind=ExecutionKind.JOB,
|
|
169
|
+
backend=self.name,
|
|
170
|
+
state=ExecutionState.RUNNING,
|
|
171
|
+
name=spec.name,
|
|
172
|
+
created_at=started,
|
|
173
|
+
started_at=started,
|
|
174
|
+
external_id=str(process.pid),
|
|
175
|
+
correlation=spec.correlation,
|
|
176
|
+
provider_metadata=ProviderMetadata(
|
|
177
|
+
provider="local",
|
|
178
|
+
provider_id=str(process.pid),
|
|
179
|
+
resource=str(log_path),
|
|
180
|
+
labels=dict(spec.labels),
|
|
181
|
+
),
|
|
182
|
+
metadata={"log_path": str(log_path), "profile": spec.profile},
|
|
183
|
+
)
|
|
184
|
+
with self._lock:
|
|
185
|
+
self._records[str(execution_id)] = _Record(
|
|
186
|
+
process=process, spec=spec, log_path=log_path, execution=execution
|
|
187
|
+
)
|
|
188
|
+
return execution
|
|
189
|
+
|
|
190
|
+
# -- observation ------------------------------------------------------------ #
|
|
191
|
+
def _record(self, execution_id: ExecutionId) -> _Record:
|
|
192
|
+
with self._lock:
|
|
193
|
+
record = self._records.get(str(execution_id))
|
|
194
|
+
if record is None:
|
|
195
|
+
raise ExecutionNotFound(
|
|
196
|
+
f"job {execution_id!r} is unknown to this process", backend=self.name
|
|
197
|
+
)
|
|
198
|
+
return record
|
|
199
|
+
|
|
200
|
+
def _get(self, execution_id: ExecutionId) -> Execution:
|
|
201
|
+
record = self._record(execution_id)
|
|
202
|
+
with self._lock:
|
|
203
|
+
return self._refresh_locked(record)
|
|
204
|
+
|
|
205
|
+
def _refresh_locked(self, record: _Record) -> Execution:
|
|
206
|
+
"""Recompute the execution from the child's real state. Caller holds the lock."""
|
|
207
|
+
if record.execution.is_terminal:
|
|
208
|
+
return record.execution
|
|
209
|
+
|
|
210
|
+
returncode = record.process.poll()
|
|
211
|
+
if returncode is None:
|
|
212
|
+
if self._exceeded_timeout(record):
|
|
213
|
+
self._stop(record, timed_out=True)
|
|
214
|
+
returncode = record.process.returncode
|
|
215
|
+
else:
|
|
216
|
+
return record.execution
|
|
217
|
+
|
|
218
|
+
record.execution = record.execution.evolve(
|
|
219
|
+
state=self._state_for(record, returncode),
|
|
220
|
+
finished_at=record.execution.finished_at or datetime.now(UTC),
|
|
221
|
+
result=self._result_for(record, returncode),
|
|
222
|
+
)
|
|
223
|
+
return record.execution
|
|
224
|
+
|
|
225
|
+
def _state_for(self, record: _Record, returncode: int | None) -> ExecutionState:
|
|
226
|
+
if record.timed_out:
|
|
227
|
+
return ExecutionState.TIMED_OUT
|
|
228
|
+
if record.cancelled:
|
|
229
|
+
return ExecutionState.CANCELLED
|
|
230
|
+
return ExecutionState.SUCCEEDED if returncode == 0 else ExecutionState.FAILED
|
|
231
|
+
|
|
232
|
+
def _result_for(self, record: _Record, returncode: int | None) -> ExecutionResult:
|
|
233
|
+
error: str | None = None
|
|
234
|
+
if record.timed_out:
|
|
235
|
+
error = f"timed out after {record.spec.timeout.seconds}s"
|
|
236
|
+
elif record.cancelled:
|
|
237
|
+
error = "cancelled"
|
|
238
|
+
elif returncode != 0:
|
|
239
|
+
error = f"exited with code {returncode}"
|
|
240
|
+
return ExecutionResult(
|
|
241
|
+
value=returncode,
|
|
242
|
+
error=error,
|
|
243
|
+
error_type="JobFailed" if error else None,
|
|
244
|
+
exit_code=returncode,
|
|
245
|
+
logs_uri=str(record.log_path),
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
def _exceeded_timeout(self, record: _Record) -> bool:
|
|
249
|
+
budget = record.spec.timeout.seconds
|
|
250
|
+
if budget is None:
|
|
251
|
+
return False
|
|
252
|
+
started = record.execution.started_at
|
|
253
|
+
if started is None: # pragma: no cover - always set at submit
|
|
254
|
+
return False
|
|
255
|
+
return (datetime.now(UTC) - started).total_seconds() > budget
|
|
256
|
+
|
|
257
|
+
def _wait(self, execution_id: ExecutionId, *, timeout: float | None) -> Execution:
|
|
258
|
+
record = self._record(execution_id)
|
|
259
|
+
deadline = None if timeout is None else time.monotonic() + timeout
|
|
260
|
+
while True:
|
|
261
|
+
with self._lock:
|
|
262
|
+
execution = self._refresh_locked(record)
|
|
263
|
+
if execution.is_terminal:
|
|
264
|
+
return execution
|
|
265
|
+
if deadline is not None and time.monotonic() >= deadline:
|
|
266
|
+
raise TaskferryTimeoutError(
|
|
267
|
+
f"job {execution_id!r} did not finish within {timeout}s"
|
|
268
|
+
)
|
|
269
|
+
time.sleep(0.02)
|
|
270
|
+
|
|
271
|
+
def _result(self, execution_id: ExecutionId, *, timeout: float | None) -> ExecutionResult:
|
|
272
|
+
execution = self._wait(execution_id, timeout=timeout)
|
|
273
|
+
assert execution.result is not None # every terminal state sets one
|
|
274
|
+
return execution.result
|
|
275
|
+
|
|
276
|
+
def _cancel(self, execution_id: ExecutionId) -> Execution:
|
|
277
|
+
record = self._record(execution_id)
|
|
278
|
+
with self._lock:
|
|
279
|
+
if record.execution.is_terminal:
|
|
280
|
+
return record.execution
|
|
281
|
+
if record.process.poll() is None:
|
|
282
|
+
self._stop(record, timed_out=False)
|
|
283
|
+
record.cancelled = True
|
|
284
|
+
return self._refresh_locked(record)
|
|
285
|
+
|
|
286
|
+
def _stop(self, record: _Record, *, timed_out: bool) -> None:
|
|
287
|
+
"""Ask the child to exit, then insist. Caller holds the lock."""
|
|
288
|
+
record.timed_out = timed_out
|
|
289
|
+
record.process.terminate()
|
|
290
|
+
try:
|
|
291
|
+
record.process.wait(timeout=TERMINATE_GRACE_SECONDS)
|
|
292
|
+
except subprocess.TimeoutExpired:
|
|
293
|
+
record.process.kill()
|
|
294
|
+
record.process.wait()
|
|
295
|
+
record.execution = record.execution.evolve(finished_at=datetime.now(UTC))
|
|
296
|
+
|
|
297
|
+
def logs(self, execution_id: ExecutionId) -> str:
|
|
298
|
+
"""Read the captured output of a job. Requires ``Capability.LOGS``."""
|
|
299
|
+
self.capabilities.require(Capability.LOGS)
|
|
300
|
+
record = self._record(execution_id)
|
|
301
|
+
try:
|
|
302
|
+
return record.log_path.read_text(encoding="utf-8", errors="replace")
|
|
303
|
+
except OSError:
|
|
304
|
+
return ""
|
|
305
|
+
|
|
306
|
+
def stream_logs(
|
|
307
|
+
self,
|
|
308
|
+
execution_id: ExecutionId,
|
|
309
|
+
*,
|
|
310
|
+
poll_interval: float = 0.1,
|
|
311
|
+
timeout: float | None = None,
|
|
312
|
+
) -> Iterator[str]:
|
|
313
|
+
"""Yield the job's output line by line *as it is produced*.
|
|
314
|
+
|
|
315
|
+
Where :meth:`logs` returns the output once, this follows the growing log
|
|
316
|
+
file — the way ``tail -f`` does — so a caller can watch a long-running job
|
|
317
|
+
live::
|
|
318
|
+
|
|
319
|
+
for line in backend.stream_logs(job_id):
|
|
320
|
+
print(line)
|
|
321
|
+
|
|
322
|
+
The iterator ends when the child reaches a terminal state and the last
|
|
323
|
+
buffered output has been drained, or after ``timeout`` seconds if given.
|
|
324
|
+
Lines are yielded without their trailing newline. Requires
|
|
325
|
+
``Capability.LOGS``.
|
|
326
|
+
|
|
327
|
+
This is a genuine stream, not a poll-and-diff: a job backend that could
|
|
328
|
+
only report a log *location* (Cloud Run, AWS Batch) does not advertise it,
|
|
329
|
+
and callers reach for the provider's own log stream there.
|
|
330
|
+
"""
|
|
331
|
+
self.capabilities.require(Capability.LOGS)
|
|
332
|
+
record = self._record(execution_id)
|
|
333
|
+
deadline = None if timeout is None else time.monotonic() + timeout
|
|
334
|
+
try:
|
|
335
|
+
reader = record.log_path.open("r", encoding="utf-8", errors="replace")
|
|
336
|
+
except OSError: # pragma: no cover - the file is created before the record
|
|
337
|
+
return
|
|
338
|
+
pending = ""
|
|
339
|
+
with reader:
|
|
340
|
+
while True:
|
|
341
|
+
chunk = reader.read()
|
|
342
|
+
if chunk:
|
|
343
|
+
pending += chunk
|
|
344
|
+
lines = pending.split("\n")
|
|
345
|
+
pending = lines.pop() # keep the trailing partial line
|
|
346
|
+
yield from lines
|
|
347
|
+
continue
|
|
348
|
+
with self._lock:
|
|
349
|
+
terminal = self._refresh_locked(record).is_terminal
|
|
350
|
+
if terminal:
|
|
351
|
+
pending += reader.read()
|
|
352
|
+
remaining = pending.split("\n")
|
|
353
|
+
if remaining and remaining[-1] == "":
|
|
354
|
+
remaining.pop() # no spurious empty line from a final newline
|
|
355
|
+
yield from remaining
|
|
356
|
+
return
|
|
357
|
+
if deadline is not None and time.monotonic() > deadline:
|
|
358
|
+
if pending:
|
|
359
|
+
yield pending
|
|
360
|
+
return
|
|
361
|
+
time.sleep(poll_interval)
|
|
362
|
+
|
|
363
|
+
def close(self) -> None:
|
|
364
|
+
"""Terminate every child this backend still owns, then forget them."""
|
|
365
|
+
with self._lock:
|
|
366
|
+
records = list(self._records.values())
|
|
367
|
+
self._records.clear()
|
|
368
|
+
for record in records:
|
|
369
|
+
if record.process.poll() is None:
|
|
370
|
+
record.process.terminate()
|
|
371
|
+
try:
|
|
372
|
+
record.process.wait(timeout=TERMINATE_GRACE_SECONDS)
|
|
373
|
+
except subprocess.TimeoutExpired: # pragma: no cover - stubborn child
|
|
374
|
+
record.process.kill()
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def make_subprocess_backend(**options: Any) -> SubprocessJobBackend:
|
|
378
|
+
"""Factory for configuration (``{"factory": "subprocess", "log_dir": ...}``)."""
|
|
379
|
+
return SubprocessJobBackend(
|
|
380
|
+
log_dir=options.get("log_dir"),
|
|
381
|
+
inherit_env=bool(options.get("inherit_env", True)),
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
__all__ = [
|
|
386
|
+
"SUBPROCESS_CAPABILITIES",
|
|
387
|
+
"TERMINATE_GRACE_SECONDS",
|
|
388
|
+
"SubprocessJobBackend",
|
|
389
|
+
"make_subprocess_backend",
|
|
390
|
+
]
|