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.
Files changed (48) hide show
  1. taskferry/__init__.py +211 -0
  2. taskferry/aio.py +486 -0
  3. taskferry/backends/__init__.py +38 -0
  4. taskferry/backends/inline.py +235 -0
  5. taskferry/backends/process.py +292 -0
  6. taskferry/backends/subprocess.py +390 -0
  7. taskferry/backends/thread.py +351 -0
  8. taskferry/capabilities.py +90 -0
  9. taskferry/cli.py +445 -0
  10. taskferry/config.py +360 -0
  11. taskferry/contract/__init__.py +56 -0
  12. taskferry/contract/base.py +179 -0
  13. taskferry/contract/inline.py +89 -0
  14. taskferry/contract/job.py +91 -0
  15. taskferry/contract/task.py +91 -0
  16. taskferry/core/__init__.py +130 -0
  17. taskferry/core/capabilities.py +89 -0
  18. taskferry/core/config.py +167 -0
  19. taskferry/core/correlation.py +120 -0
  20. taskferry/core/delivery.py +36 -0
  21. taskferry/core/errors.py +55 -0
  22. taskferry/core/ids.py +37 -0
  23. taskferry/core/observability.py +136 -0
  24. taskferry/core/otel.py +83 -0
  25. taskferry/core/provider.py +50 -0
  26. taskferry/core/py.typed +0 -0
  27. taskferry/core/registry.py +92 -0
  28. taskferry/core/serialization.py +79 -0
  29. taskferry/core/typing.py +16 -0
  30. taskferry/envelope.py +197 -0
  31. taskferry/errors.py +144 -0
  32. taskferry/execution.py +239 -0
  33. taskferry/functions.py +290 -0
  34. taskferry/handle.py +186 -0
  35. taskferry/hooks.py +238 -0
  36. taskferry/plugins.py +183 -0
  37. taskferry/ports.py +356 -0
  38. taskferry/py.typed +0 -0
  39. taskferry/retry.py +205 -0
  40. taskferry/router.py +160 -0
  41. taskferry/runtime.py +609 -0
  42. taskferry/specs.py +353 -0
  43. taskferry/tracking.py +129 -0
  44. taskferry-0.2.0.dist-info/METADATA +109 -0
  45. taskferry-0.2.0.dist-info/RECORD +48 -0
  46. taskferry-0.2.0.dist-info/WHEEL +4 -0
  47. taskferry-0.2.0.dist-info/entry_points.txt +2 -0
  48. 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"]