async-task-api-worker 0.1.0rc2__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.
- async_task_api_worker/__init__.py +42 -0
- async_task_api_worker/__main__.py +5 -0
- async_task_api_worker/_runtime/__init__.py +1 -0
- async_task_api_worker/_runtime/amqp_transport.py +251 -0
- async_task_api_worker/_runtime/declare.py +40 -0
- async_task_api_worker/_runtime/engine.py +543 -0
- async_task_api_worker/_runtime/inflight.py +38 -0
- async_task_api_worker/_runtime/lifecycle.py +463 -0
- async_task_api_worker/_runtime/log_context.py +20 -0
- async_task_api_worker/_runtime/observer.py +78 -0
- async_task_api_worker/_runtime/progress.py +143 -0
- async_task_api_worker/_runtime/retry.py +29 -0
- async_task_api_worker/_runtime/sync_bridge.py +68 -0
- async_task_api_worker/_runtime/transport.py +62 -0
- async_task_api_worker/_version.py +11 -0
- async_task_api_worker/cli.py +88 -0
- async_task_api_worker/context.py +70 -0
- async_task_api_worker/errors.py +116 -0
- async_task_api_worker/observability/__init__.py +1 -0
- async_task_api_worker/observability/health.py +299 -0
- async_task_api_worker/observability/logging.py +89 -0
- async_task_api_worker/observability/loop_lag.py +56 -0
- async_task_api_worker/observability/metrics.py +188 -0
- async_task_api_worker/protocol/__init__.py +41 -0
- async_task_api_worker/protocol/envelope.py +103 -0
- async_task_api_worker/protocol/json_types.py +7 -0
- async_task_api_worker/protocol/messages.py +121 -0
- async_task_api_worker/protocol/version.py +11 -0
- async_task_api_worker/py.typed +0 -0
- async_task_api_worker/settings.py +127 -0
- async_task_api_worker/testing/__init__.py +27 -0
- async_task_api_worker/testing/_context.py +93 -0
- async_task_api_worker/testing/_harness.py +251 -0
- async_task_api_worker/testing/_memory_broker.py +162 -0
- async_task_api_worker/worker.py +254 -0
- async_task_api_worker-0.1.0rc2.dist-info/METADATA +269 -0
- async_task_api_worker-0.1.0rc2.dist-info/RECORD +40 -0
- async_task_api_worker-0.1.0rc2.dist-info/WHEEL +4 -0
- async_task_api_worker-0.1.0rc2.dist-info/entry_points.txt +2 -0
- async_task_api_worker-0.1.0rc2.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Progress reporting: validation and a time-based throttle.
|
|
2
|
+
|
|
3
|
+
Progress is the **latest state**, not an event stream. Rules:
|
|
4
|
+
|
|
5
|
+
- leading edge: the first update, and any update arriving after ``min_interval_s`` of silence, is published at once;
|
|
6
|
+
- trailing flush: an update arriving sooner is held, a later one replaces it (latest wins), and the survivor is
|
|
7
|
+
published when the interval has elapsed;
|
|
8
|
+
- the fraction stays in ``[0, 1)``: the terminal success or failure message is the 100%;
|
|
9
|
+
- closing the reporter cancels any pending flush and refuses every later update, so no progress is ever published
|
|
10
|
+
after the terminal message.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import asyncio
|
|
14
|
+
import logging
|
|
15
|
+
import time
|
|
16
|
+
from collections.abc import Awaitable, Callable
|
|
17
|
+
from typing import Final
|
|
18
|
+
|
|
19
|
+
from async_task_api_worker._runtime.observer import ProgressDropReason, RuntimeObserver
|
|
20
|
+
from async_task_api_worker.protocol.json_types import ProgressPayload
|
|
21
|
+
from async_task_api_worker.protocol.messages import InvalidProgressError, MessageEncoder
|
|
22
|
+
|
|
23
|
+
_LOGGER: Final[logging.Logger] = logging.getLogger("async_task_api_worker")
|
|
24
|
+
# A reason that is normal operation, not worth a log line.
|
|
25
|
+
_SILENT_REASONS: Final[frozenset[ProgressDropReason]] = frozenset({ProgressDropReason.COALESCED})
|
|
26
|
+
|
|
27
|
+
type Clock = Callable[[], float]
|
|
28
|
+
type Sleep = Callable[[float], Awaitable[None]]
|
|
29
|
+
type PublishBody = Callable[[bytes], Awaitable[None]]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ProgressDropLog:
|
|
33
|
+
"""Counts every dropped update and warns once per reason per process."""
|
|
34
|
+
|
|
35
|
+
def __init__(self, observer: RuntimeObserver) -> None:
|
|
36
|
+
self._observer = observer
|
|
37
|
+
self._warned: set[ProgressDropReason] = set()
|
|
38
|
+
|
|
39
|
+
def dropped(self, reason: ProgressDropReason, task_id: str) -> None:
|
|
40
|
+
self._observer.progress_dropped(reason)
|
|
41
|
+
if reason in _SILENT_REASONS or reason in self._warned:
|
|
42
|
+
return
|
|
43
|
+
self._warned.add(reason)
|
|
44
|
+
_LOGGER.warning(
|
|
45
|
+
"Progress update dropped (%s), task_id=%s. Further drops of this kind are not logged.",
|
|
46
|
+
reason,
|
|
47
|
+
task_id,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class ProgressReporter:
|
|
52
|
+
"""Per-task progress gate. ``publish`` must never raise: a failed progress publish never fails the task."""
|
|
53
|
+
|
|
54
|
+
def __init__(
|
|
55
|
+
self,
|
|
56
|
+
*,
|
|
57
|
+
task_id: str,
|
|
58
|
+
encoder: MessageEncoder,
|
|
59
|
+
publish: PublishBody,
|
|
60
|
+
min_interval_s: float,
|
|
61
|
+
drops: ProgressDropLog,
|
|
62
|
+
clock: Clock = time.monotonic,
|
|
63
|
+
sleep: Sleep = asyncio.sleep,
|
|
64
|
+
) -> None:
|
|
65
|
+
self._task_id = task_id
|
|
66
|
+
self._encoder = encoder
|
|
67
|
+
self._publish = publish
|
|
68
|
+
self._min_interval_s = min_interval_s
|
|
69
|
+
self._drops = drops
|
|
70
|
+
self._clock = clock
|
|
71
|
+
self._sleep = sleep
|
|
72
|
+
self._lock = asyncio.Lock()
|
|
73
|
+
self._closed = False
|
|
74
|
+
self._high_water = 0.0
|
|
75
|
+
self._last_publish: float | None = None
|
|
76
|
+
self._pending: bytes | None = None
|
|
77
|
+
self._flush_task: asyncio.Task[None] | None = None
|
|
78
|
+
|
|
79
|
+
async def report(self, fraction: float, payload: ProgressPayload | None = None) -> None:
|
|
80
|
+
"""Validate, then publish now or hold for the trailing flush. Never raises."""
|
|
81
|
+
if self._closed:
|
|
82
|
+
self._drops.dropped(ProgressDropReason.AFTER_CONCLUSION, self._task_id)
|
|
83
|
+
return
|
|
84
|
+
try:
|
|
85
|
+
body = self._encoder.progress(self._task_id, fraction, payload)
|
|
86
|
+
except InvalidProgressError:
|
|
87
|
+
self._drops.dropped(ProgressDropReason.INVALID_FRACTION, self._task_id)
|
|
88
|
+
return
|
|
89
|
+
except ValueError:
|
|
90
|
+
self._drops.dropped(ProgressDropReason.INVALID_PAYLOAD, self._task_id)
|
|
91
|
+
return
|
|
92
|
+
if fraction < self._high_water:
|
|
93
|
+
self._drops.dropped(ProgressDropReason.NOT_INCREASING, self._task_id)
|
|
94
|
+
return
|
|
95
|
+
self._high_water = fraction
|
|
96
|
+
|
|
97
|
+
now = self._clock()
|
|
98
|
+
if self._flush_task is None and self._is_due(now):
|
|
99
|
+
self._last_publish = now
|
|
100
|
+
await self._publish_now(body)
|
|
101
|
+
return
|
|
102
|
+
if self._pending is not None:
|
|
103
|
+
self._drops.dropped(ProgressDropReason.COALESCED, self._task_id)
|
|
104
|
+
self._pending = body
|
|
105
|
+
if self._flush_task is None:
|
|
106
|
+
self._flush_task = asyncio.get_running_loop().create_task(self._flush_loop())
|
|
107
|
+
|
|
108
|
+
async def close(self) -> None:
|
|
109
|
+
"""Cancel the pending flush and wait for a publish in progress: nothing is published afterwards."""
|
|
110
|
+
self._closed = True
|
|
111
|
+
task = self._flush_task
|
|
112
|
+
if task is not None:
|
|
113
|
+
task.cancel()
|
|
114
|
+
await asyncio.wait({task})
|
|
115
|
+
if self._pending is not None:
|
|
116
|
+
self._pending = None
|
|
117
|
+
self._drops.dropped(ProgressDropReason.AFTER_CONCLUSION, self._task_id)
|
|
118
|
+
async with self._lock:
|
|
119
|
+
pass
|
|
120
|
+
|
|
121
|
+
def _is_due(self, now: float) -> bool:
|
|
122
|
+
return self._last_publish is None or now - self._last_publish >= self._min_interval_s
|
|
123
|
+
|
|
124
|
+
async def _publish_now(self, body: bytes) -> None:
|
|
125
|
+
async with self._lock:
|
|
126
|
+
if self._closed:
|
|
127
|
+
self._drops.dropped(ProgressDropReason.AFTER_CONCLUSION, self._task_id)
|
|
128
|
+
return
|
|
129
|
+
await self._publish(body)
|
|
130
|
+
|
|
131
|
+
async def _flush_loop(self) -> None:
|
|
132
|
+
try:
|
|
133
|
+
while self._pending is not None:
|
|
134
|
+
last = self._last_publish if self._last_publish is not None else self._clock()
|
|
135
|
+
delay = last + self._min_interval_s - self._clock()
|
|
136
|
+
if delay > 0:
|
|
137
|
+
await self._sleep(delay)
|
|
138
|
+
body, self._pending = self._pending, None
|
|
139
|
+
assert body is not None # only close() clears it, and close() cancels this task first
|
|
140
|
+
self._last_publish = self._clock()
|
|
141
|
+
await self._publish_now(body)
|
|
142
|
+
finally:
|
|
143
|
+
self._flush_task = None
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Attempt numbering and retry backoff."""
|
|
2
|
+
|
|
3
|
+
import random
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
from typing import Final
|
|
6
|
+
|
|
7
|
+
from async_task_api_worker._runtime.transport import Delivery
|
|
8
|
+
|
|
9
|
+
_FIRST_ATTEMPT: Final[int] = 1
|
|
10
|
+
_REDELIVERED_ATTEMPT: Final[int] = 2
|
|
11
|
+
# Beyond this the exponential term overflows a float long before it matters: the cap is reached much earlier.
|
|
12
|
+
_MAX_EXPONENT: Final[int] = 60
|
|
13
|
+
_JITTER_LOW: Final[float] = 0.5
|
|
14
|
+
_JITTER_HIGH: Final[float] = 1.0
|
|
15
|
+
|
|
16
|
+
type Uniform = Callable[[float, float], float]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def attempt_of(delivery: Delivery) -> int:
|
|
20
|
+
"""1-based delivery attempt: ``x-delivery-count + 1`` when the broker provides it, else a redelivery guess."""
|
|
21
|
+
if delivery.delivery_count is not None:
|
|
22
|
+
return delivery.delivery_count + 1
|
|
23
|
+
return _REDELIVERED_ATTEMPT if delivery.redelivered else _FIRST_ATTEMPT
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def backoff_seconds(attempt: int, *, base_s: float, max_s: float, uniform: Uniform = random.uniform) -> float:
|
|
27
|
+
"""``min(base * 2**(attempt-1), max) * uniform(0.5, 1.0)``."""
|
|
28
|
+
exponent = min(max(attempt - 1, 0), _MAX_EXPONENT)
|
|
29
|
+
return min(base_s * 2.0**exponent, max_s) * uniform(_JITTER_LOW, _JITTER_HIGH)
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Running ``def`` handlers off the event loop, with thread-safe progress and cooperative cancellation."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import concurrent.futures
|
|
5
|
+
import contextvars
|
|
6
|
+
import logging
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from typing import Final
|
|
9
|
+
|
|
10
|
+
from async_task_api_worker._runtime.progress import ProgressReporter
|
|
11
|
+
from async_task_api_worker.protocol.json_types import ProgressPayload
|
|
12
|
+
|
|
13
|
+
_LOGGER: Final[logging.Logger] = logging.getLogger("async_task_api_worker")
|
|
14
|
+
# A progress publish that takes longer than this is abandoned: the thread must not hang on telemetry.
|
|
15
|
+
PROGRESS_PUBLISH_TIMEOUT_S: Final[float] = 5.0
|
|
16
|
+
|
|
17
|
+
type ThreadSafeProgress = Callable[[float, ProgressPayload | None], None]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class TaskCancelledError(Exception):
|
|
21
|
+
"""Raised by ``SyncTaskContext.raise_if_cancelled``. The engine handles it: the task already concluded."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def make_threadsafe_progress(
|
|
25
|
+
loop: asyncio.AbstractEventLoop,
|
|
26
|
+
reporter: ProgressReporter,
|
|
27
|
+
*,
|
|
28
|
+
timeout_s: float = PROGRESS_PUBLISH_TIMEOUT_S,
|
|
29
|
+
) -> ThreadSafeProgress:
|
|
30
|
+
"""Progress callable for a worker thread: runs on the worker loop, never creates one, never raises."""
|
|
31
|
+
|
|
32
|
+
def report(fraction: float, payload: ProgressPayload | None = None) -> None:
|
|
33
|
+
coroutine = reporter.report(fraction, payload)
|
|
34
|
+
try:
|
|
35
|
+
future = asyncio.run_coroutine_threadsafe(coroutine, loop)
|
|
36
|
+
except RuntimeError:
|
|
37
|
+
coroutine.close()
|
|
38
|
+
_LOGGER.warning("Progress dropped: the event loop is closed.")
|
|
39
|
+
return
|
|
40
|
+
try:
|
|
41
|
+
future.result(timeout=timeout_s)
|
|
42
|
+
except concurrent.futures.TimeoutError:
|
|
43
|
+
future.cancel()
|
|
44
|
+
_LOGGER.warning("Progress dropped: the publish took more than %s s.", timeout_s)
|
|
45
|
+
except Exception:
|
|
46
|
+
_LOGGER.exception("Progress dropped: the publish failed.")
|
|
47
|
+
|
|
48
|
+
return report
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class SyncBridge:
|
|
52
|
+
"""Dedicated thread pool for sync handlers, sized like the concurrency (never the default executor)."""
|
|
53
|
+
|
|
54
|
+
def __init__(self, max_workers: int) -> None:
|
|
55
|
+
self._executor = concurrent.futures.ThreadPoolExecutor(
|
|
56
|
+
max_workers=max_workers,
|
|
57
|
+
thread_name_prefix="async-task-api-worker",
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
def submit[T](self, loop: asyncio.AbstractEventLoop, call: Callable[[], T]) -> asyncio.Future[T]:
|
|
61
|
+
# ``run_in_executor`` does not carry the context over: the thread runs in a copy of the caller's, so that the
|
|
62
|
+
# task bound for the logs (and anything else a handler's libraries put there) is visible in it.
|
|
63
|
+
context = contextvars.copy_context()
|
|
64
|
+
return loop.run_in_executor(self._executor, context.run, call)
|
|
65
|
+
|
|
66
|
+
def shutdown(self) -> None:
|
|
67
|
+
"""Do not wait for threads that are stuck in a handler: the pod restart reclaims them."""
|
|
68
|
+
self._executor.shutdown(wait=False, cancel_futures=True)
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""The two transport protocols the engine runs on. The broker adapter and the in-memory broker implement them."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from typing import Final, Protocol
|
|
5
|
+
|
|
6
|
+
# Closing the connection (or discarding a half-built one) against a broker that is restarting or half-open can wait on
|
|
7
|
+
# the AMQP close handshake: give up after this. The process exit closes the socket and the broker requeues what is
|
|
8
|
+
# unacked.
|
|
9
|
+
CLOSE_TIMEOUT_S: Final[float] = 5.0
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Delivery(Protocol):
|
|
13
|
+
"""One message received from the input queue, to be acked or nacked exactly once."""
|
|
14
|
+
|
|
15
|
+
@property
|
|
16
|
+
def body(self) -> bytes: ...
|
|
17
|
+
|
|
18
|
+
@property
|
|
19
|
+
def delivery_count(self) -> int | None:
|
|
20
|
+
"""How many times the broker already handed this message out (quorum queues), ``None`` when unknown."""
|
|
21
|
+
...
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
def redelivered(self) -> bool: ...
|
|
25
|
+
|
|
26
|
+
async def ack(self) -> None: ...
|
|
27
|
+
|
|
28
|
+
async def nack(self, *, requeue: bool) -> None: ...
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class Publisher(Protocol):
|
|
32
|
+
"""Publishes on the output queue. Persistent and publisher-confirmed: it raises when the publish failed."""
|
|
33
|
+
|
|
34
|
+
async def publish(self, body: bytes) -> None: ...
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
type OnDelivery = Callable[[Delivery], None]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class TransportUnavailableError(Exception):
|
|
41
|
+
"""The broker cannot be reached (yet): starting the transport may be retried."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class Transport(Protocol):
|
|
45
|
+
"""A broker connection: declares the queues, delivers messages and publishes results."""
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def publisher(self) -> Publisher: ...
|
|
49
|
+
|
|
50
|
+
async def start(self, on_delivery: OnDelivery) -> None:
|
|
51
|
+
"""Declare the queues, connect and start consuming. Raises :class:`TransportUnavailableError`."""
|
|
52
|
+
...
|
|
53
|
+
|
|
54
|
+
async def ping(self, timeout_s: float) -> bool:
|
|
55
|
+
"""Whether the broker connection is up right now (``False`` before ``start`` and after ``close``)."""
|
|
56
|
+
...
|
|
57
|
+
|
|
58
|
+
async def stop_consuming(self) -> None:
|
|
59
|
+
"""Stop receiving new deliveries (``basic.cancel``). Acks and publishes keep working."""
|
|
60
|
+
...
|
|
61
|
+
|
|
62
|
+
async def close(self) -> None: ...
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Installed distribution version."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
_DISTRIBUTION_NAME = "async-task-api-worker"
|
|
6
|
+
_UNKNOWN_VERSION = "0.0.0+unknown"
|
|
7
|
+
|
|
8
|
+
try:
|
|
9
|
+
__version__: str = version(_DISTRIBUTION_NAME)
|
|
10
|
+
except PackageNotFoundError: # pragma: no cover - only when run from a bare source tree
|
|
11
|
+
__version__ = _UNKNOWN_VERSION
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Command line: ``async-task-api-worker run <module:attr>``."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import importlib
|
|
5
|
+
import inspect
|
|
6
|
+
import sys
|
|
7
|
+
from collections.abc import Sequence
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Final
|
|
10
|
+
|
|
11
|
+
from async_task_api_worker._runtime.lifecycle import EXIT_CONFIGURATION, EXIT_FAILURE, EXIT_OK, run_blocking
|
|
12
|
+
from async_task_api_worker.errors import WorkerConfigurationError, WorkerDefinitionError
|
|
13
|
+
from async_task_api_worker.worker import Worker
|
|
14
|
+
|
|
15
|
+
__all__ = ["EXIT_CONFIGURATION", "EXIT_FAILURE", "EXIT_OK", "load_worker", "main"]
|
|
16
|
+
|
|
17
|
+
_PROG: Final[str] = "async-task-api-worker"
|
|
18
|
+
_COMMAND_RUN: Final[str] = "run"
|
|
19
|
+
_TARGET_SEPARATOR: Final[str] = ":"
|
|
20
|
+
_DEFINITION_ERRORS: Final[tuple[type[Exception], ...]] = (WorkerDefinitionError, WorkerConfigurationError)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class TargetError(Exception):
|
|
24
|
+
"""The ``module:attr`` target cannot be turned into a worker."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _working_directory_first() -> None:
|
|
28
|
+
"""Like uvicorn: modules next to where the command is run can be imported."""
|
|
29
|
+
working_directory = str(Path.cwd())
|
|
30
|
+
if working_directory not in sys.path:
|
|
31
|
+
sys.path.insert(0, working_directory)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def load_worker(target: str) -> Worker:
|
|
35
|
+
"""Import ``module:attr``: ``attr`` is a ``Worker`` or a zero-argument factory function returning one."""
|
|
36
|
+
module_name, separator, attribute = target.partition(_TARGET_SEPARATOR)
|
|
37
|
+
if not separator or not module_name or not attribute:
|
|
38
|
+
msg = f"Invalid target {target!r}: expected module:attr"
|
|
39
|
+
raise TargetError(msg)
|
|
40
|
+
_working_directory_first()
|
|
41
|
+
try:
|
|
42
|
+
module = importlib.import_module(module_name)
|
|
43
|
+
except ImportError as error:
|
|
44
|
+
msg = f"Cannot import module {module_name!r}: {error}"
|
|
45
|
+
raise TargetError(msg) from error
|
|
46
|
+
except _DEFINITION_ERRORS as error:
|
|
47
|
+
msg = f"Invalid worker definition in module {module_name!r}: {error}"
|
|
48
|
+
raise TargetError(msg) from error
|
|
49
|
+
try:
|
|
50
|
+
candidate = getattr(module, attribute)
|
|
51
|
+
except AttributeError as error:
|
|
52
|
+
msg = f"Module {module_name!r} has no attribute {attribute!r}"
|
|
53
|
+
raise TargetError(msg) from error
|
|
54
|
+
if inspect.isfunction(candidate):
|
|
55
|
+
try:
|
|
56
|
+
candidate = candidate()
|
|
57
|
+
except _DEFINITION_ERRORS as error:
|
|
58
|
+
msg = f"Invalid worker definition in {target!r}: {error}"
|
|
59
|
+
raise TargetError(msg) from error
|
|
60
|
+
except TypeError as error:
|
|
61
|
+
msg = f"{target!r} cannot be called without arguments: {error}"
|
|
62
|
+
raise TargetError(msg) from error
|
|
63
|
+
if not isinstance(candidate, Worker):
|
|
64
|
+
msg = f"{target!r} is neither a Worker nor a zero-argument factory function returning one"
|
|
65
|
+
raise TargetError(msg)
|
|
66
|
+
return candidate
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _parser() -> argparse.ArgumentParser:
|
|
70
|
+
parser = argparse.ArgumentParser(prog=_PROG, description="Tools for AsyncTaskAPI workers.")
|
|
71
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
72
|
+
run = commands.add_parser(_COMMAND_RUN, help="run a worker until SIGTERM or SIGINT")
|
|
73
|
+
run.add_argument("target", help="module:attr, a Worker or a zero-argument factory returning one")
|
|
74
|
+
return parser
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
78
|
+
"""Entry point. Exit codes: 0 ok, 1 failure, 2 usage or configuration error."""
|
|
79
|
+
try:
|
|
80
|
+
arguments = _parser().parse_args(argv)
|
|
81
|
+
except SystemExit as error:
|
|
82
|
+
return error.code if isinstance(error.code, int) else EXIT_CONFIGURATION
|
|
83
|
+
try:
|
|
84
|
+
worker = load_worker(arguments.target)
|
|
85
|
+
except TargetError as error:
|
|
86
|
+
sys.stderr.write(f"{_PROG}: {error}\n")
|
|
87
|
+
return EXIT_CONFIGURATION
|
|
88
|
+
return run_blocking(worker)
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""What a handler receives next to its input: identity, deadline, logger, inputs and progress reporting."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import threading
|
|
5
|
+
import time
|
|
6
|
+
from collections.abc import Awaitable, Callable, Mapping
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
|
|
9
|
+
from async_task_api_worker._runtime.sync_bridge import TaskCancelledError, ThreadSafeProgress
|
|
10
|
+
from async_task_api_worker.protocol.envelope import InputRef
|
|
11
|
+
from async_task_api_worker.protocol.json_types import ProgressPayload
|
|
12
|
+
|
|
13
|
+
type _ReportProgress = Callable[[float, ProgressPayload | None], Awaitable[None]]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True, slots=True)
|
|
17
|
+
class TaskContext:
|
|
18
|
+
"""Context of an ``async def`` handler.
|
|
19
|
+
|
|
20
|
+
``progress`` publishes the **latest state** of the task, not an event stream: updates are throttled (at most
|
|
21
|
+
one per ``WORKER_PROGRESS_MIN_INTERVAL_S``), a newer update replaces a held one, ``fraction`` must be in
|
|
22
|
+
``[0, 1)`` and never goes back. The terminal success or failure message is the 100%.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
task_id: str
|
|
26
|
+
client_id: str | None
|
|
27
|
+
attempt: int
|
|
28
|
+
deadline: float
|
|
29
|
+
logger: logging.LoggerAdapter[logging.Logger]
|
|
30
|
+
inputs: Mapping[str, InputRef]
|
|
31
|
+
_report: _ReportProgress = field(repr=False, compare=False)
|
|
32
|
+
|
|
33
|
+
def remaining(self) -> float:
|
|
34
|
+
"""Seconds left before ``deadline``, never negative."""
|
|
35
|
+
return max(0.0, self.deadline - time.monotonic())
|
|
36
|
+
|
|
37
|
+
async def progress(self, fraction: float, payload: ProgressPayload | None = None) -> None:
|
|
38
|
+
"""Report progress. Never raises: an invalid or failed update is dropped and counted."""
|
|
39
|
+
await self._report(fraction, payload)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True, slots=True)
|
|
43
|
+
class SyncTaskContext:
|
|
44
|
+
"""Context of a ``def`` handler, which runs in a worker thread. Same fields as :class:`TaskContext`."""
|
|
45
|
+
|
|
46
|
+
task_id: str
|
|
47
|
+
client_id: str | None
|
|
48
|
+
attempt: int
|
|
49
|
+
deadline: float
|
|
50
|
+
logger: logging.LoggerAdapter[logging.Logger]
|
|
51
|
+
inputs: Mapping[str, InputRef]
|
|
52
|
+
_report: ThreadSafeProgress = field(repr=False, compare=False)
|
|
53
|
+
_cancel_event: threading.Event = field(repr=False, compare=False)
|
|
54
|
+
|
|
55
|
+
def remaining(self) -> float:
|
|
56
|
+
"""Seconds left before ``deadline``, never negative."""
|
|
57
|
+
return max(0.0, self.deadline - time.monotonic())
|
|
58
|
+
|
|
59
|
+
def progress(self, fraction: float, payload: ProgressPayload | None = None) -> None:
|
|
60
|
+
"""Report progress from the thread, with the same rules as :meth:`TaskContext.progress`."""
|
|
61
|
+
self._report(fraction, payload)
|
|
62
|
+
|
|
63
|
+
def cancelled(self) -> bool:
|
|
64
|
+
"""True once the task timed out or the worker is shutting down: long loops should stop."""
|
|
65
|
+
return self._cancel_event.is_set()
|
|
66
|
+
|
|
67
|
+
def raise_if_cancelled(self) -> None:
|
|
68
|
+
"""Raise the SDK cancellation (handled by the runtime) when :meth:`cancelled` is true."""
|
|
69
|
+
if self.cancelled():
|
|
70
|
+
raise TaskCancelledError
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Error types and the stable error codes the SDK itself produces."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from collections.abc import Mapping
|
|
5
|
+
from enum import StrEnum
|
|
6
|
+
from types import MappingProxyType
|
|
7
|
+
from typing import Final
|
|
8
|
+
|
|
9
|
+
_CODE_PATTERN: Final[re.Pattern[str]] = re.compile(r"^[A-Z][A-Z0-9_]{2,63}$")
|
|
10
|
+
_MIN_MESSAGE_LENGTH: Final[int] = 1
|
|
11
|
+
MAX_MESSAGE_LENGTH: Final[int] = 500
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ErrorCode(StrEnum):
|
|
15
|
+
"""Codes produced by the SDK itself. They are stable and documented in the protocol spec."""
|
|
16
|
+
|
|
17
|
+
INPUT_INVALID = "INPUT_INVALID"
|
|
18
|
+
OUTPUT_INVALID = "OUTPUT_INVALID"
|
|
19
|
+
TASK_TIMEOUT = "TASK_TIMEOUT"
|
|
20
|
+
INTERNAL_ERROR = "INTERNAL_ERROR"
|
|
21
|
+
UPSTREAM_UNAVAILABLE = "UPSTREAM_UNAVAILABLE"
|
|
22
|
+
# Same value as ``CODE_ABANDON`` in ``listener/services/rebut_listener.py``.
|
|
23
|
+
TASK_ABANDONED_AFTER_REDELIVERIES = "TASK_ABANDONED_AFTER_REDELIVERIES"
|
|
24
|
+
# Raised by the ``[s3]`` and ``[llm]`` extras.
|
|
25
|
+
DOCUMENT_NOT_FOUND = "DOCUMENT_NOT_FOUND"
|
|
26
|
+
FILE_TOO_LARGE = "FILE_TOO_LARGE"
|
|
27
|
+
LLM_OUTPUT_INVALID = "LLM_OUTPUT_INVALID"
|
|
28
|
+
LLM_OUTPUT_TRUNCATED = "LLM_OUTPUT_TRUNCATED"
|
|
29
|
+
LLM_OUTPUT_FILTERED = "LLM_OUTPUT_FILTERED"
|
|
30
|
+
LLM_REQUEST_REJECTED = "LLM_REQUEST_REJECTED"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# Fixed client-facing text for the codes the SDK emits itself (never derived from an exception).
|
|
34
|
+
DEFAULT_PUBLIC_MESSAGES: Final[Mapping[ErrorCode, str]] = MappingProxyType(
|
|
35
|
+
{
|
|
36
|
+
ErrorCode.INPUT_INVALID: "The task input does not match the service schema.",
|
|
37
|
+
ErrorCode.OUTPUT_INVALID: "The task result does not match the service schema.",
|
|
38
|
+
ErrorCode.TASK_TIMEOUT: "The task exceeded its time limit.",
|
|
39
|
+
ErrorCode.INTERNAL_ERROR: "The worker failed unexpectedly while processing the task.",
|
|
40
|
+
ErrorCode.UPSTREAM_UNAVAILABLE: "A service the task depends on is unavailable.",
|
|
41
|
+
ErrorCode.TASK_ABANDONED_AFTER_REDELIVERIES: "The task was abandoned after several delivery attempts.",
|
|
42
|
+
ErrorCode.DOCUMENT_NOT_FOUND: "The document was not found.",
|
|
43
|
+
ErrorCode.FILE_TOO_LARGE: "The file exceeds the maximum accepted size.",
|
|
44
|
+
ErrorCode.LLM_OUTPUT_INVALID: "The language model returned an unusable answer.",
|
|
45
|
+
ErrorCode.LLM_OUTPUT_TRUNCATED: "The language model answer was truncated.",
|
|
46
|
+
ErrorCode.LLM_OUTPUT_FILTERED: "The language model answer was blocked by a content filter.",
|
|
47
|
+
ErrorCode.LLM_REQUEST_REJECTED: "The language model rejected the request.",
|
|
48
|
+
},
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
# Owned by the broker side (the rebut listener concludes tasks that expired in the queue with it): the SDK never
|
|
52
|
+
# emits it, and a handler must not claim it for a task that did run.
|
|
53
|
+
TASK_EXPIRED_IN_QUEUE: Final[str] = "TASK_EXPIRED_IN_QUEUE"
|
|
54
|
+
|
|
55
|
+
# Codes only the runtime or the broker side may emit: a handler cannot raise a ``WorkerError`` carrying one of them.
|
|
56
|
+
RESERVED_CODES: Final[frozenset[str]] = frozenset(
|
|
57
|
+
{
|
|
58
|
+
ErrorCode.INPUT_INVALID,
|
|
59
|
+
ErrorCode.OUTPUT_INVALID,
|
|
60
|
+
ErrorCode.TASK_TIMEOUT,
|
|
61
|
+
ErrorCode.INTERNAL_ERROR,
|
|
62
|
+
ErrorCode.TASK_ABANDONED_AFTER_REDELIVERIES,
|
|
63
|
+
TASK_EXPIRED_IN_QUEUE,
|
|
64
|
+
},
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def validate_public_message(message: str) -> None:
|
|
69
|
+
"""Raise ``ValueError`` unless ``message`` is safe to show to a client: one non-empty line, bounded length."""
|
|
70
|
+
if not _MIN_MESSAGE_LENGTH <= len(message) <= MAX_MESSAGE_LENGTH or "\n" in message or "\r" in message:
|
|
71
|
+
msg = f"Error message must be a single line of {_MIN_MESSAGE_LENGTH}..{MAX_MESSAGE_LENGTH} characters"
|
|
72
|
+
raise ValueError(msg)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class WorkerError(Exception):
|
|
76
|
+
"""A failure whose code and message are safe to show to the client.
|
|
77
|
+
|
|
78
|
+
Only these two values ever reach the wire: never build the message from ``str(cause)``.
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
def __init__(self, code: str, message: str) -> None:
|
|
82
|
+
if not _CODE_PATTERN.fullmatch(code):
|
|
83
|
+
msg = f"Invalid error code {code!r}: expected {_CODE_PATTERN.pattern}"
|
|
84
|
+
raise ValueError(msg)
|
|
85
|
+
if code in RESERVED_CODES:
|
|
86
|
+
msg = f"Error code {code!r} is reserved for the SDK runtime"
|
|
87
|
+
raise ValueError(msg)
|
|
88
|
+
validate_public_message(message)
|
|
89
|
+
super().__init__(code, message)
|
|
90
|
+
self.code: str = code
|
|
91
|
+
self.public_message: str = message
|
|
92
|
+
|
|
93
|
+
@property
|
|
94
|
+
def wire_text(self) -> str:
|
|
95
|
+
"""The ``"<CODE>: <message>"`` text form, for clients that read the failure as a string."""
|
|
96
|
+
return f"{self.code}: {self.public_message}"
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class FatalError(WorkerError):
|
|
100
|
+
"""Not worth retrying: the failure is published and the delivery acked."""
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class RetryableError(WorkerError):
|
|
104
|
+
"""Transient: backoff and requeue until the broker's last delivery attempt."""
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class ProbeError(Exception):
|
|
108
|
+
"""Raised by a readiness probe to signal "not ready", optionally with a safe detail."""
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class WorkerConfigurationError(Exception):
|
|
112
|
+
"""Invalid settings or budget: the worker refuses to start."""
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class WorkerDefinitionError(Exception):
|
|
116
|
+
"""Invalid decorator usage, raised at import time."""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Probes, metrics and logs of a running worker."""
|