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,251 @@
|
|
|
1
|
+
"""Run a worker's task in memory, through the real engine.
|
|
2
|
+
|
|
3
|
+
``WorkerHarness`` feeds the engine the exact envelope the API publishes and hands back what the worker published and
|
|
4
|
+
what it did to the delivery, with no broker and no Docker. The engine is the production one (decoding, timeouts,
|
|
5
|
+
retries, error mapping, ack discipline): only the transport is replaced.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import weakref
|
|
10
|
+
from collections.abc import Mapping
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from enum import StrEnum
|
|
13
|
+
from itertools import count
|
|
14
|
+
from types import TracebackType
|
|
15
|
+
from typing import Final, Literal, Self
|
|
16
|
+
|
|
17
|
+
from pydantic_settings import BaseSettings, PydanticBaseSettingsSource
|
|
18
|
+
|
|
19
|
+
from async_task_api_worker._runtime.engine import Engine
|
|
20
|
+
from async_task_api_worker._runtime.lifecycle import check_time_budget
|
|
21
|
+
from async_task_api_worker.errors import WorkerConfigurationError
|
|
22
|
+
from async_task_api_worker.protocol.envelope import InputRef, decode_envelope
|
|
23
|
+
from async_task_api_worker.protocol.json_types import JsonObject, JsonValue
|
|
24
|
+
from async_task_api_worker.settings import WorkerSettings
|
|
25
|
+
from async_task_api_worker.testing._memory_broker import InMemoryBroker, MemoryDelivery
|
|
26
|
+
from async_task_api_worker.worker import Worker
|
|
27
|
+
|
|
28
|
+
_KEY_TASK_ID: Final[str] = "task_id"
|
|
29
|
+
_KEY_DATA: Final[str] = "data"
|
|
30
|
+
_MESSAGE_TYPE: Final[str] = "message_type"
|
|
31
|
+
_TERMINAL_TYPES: Final[frozenset[str]] = frozenset({"success", "failure"})
|
|
32
|
+
_TYPE_SUCCESS: Final[str] = "success"
|
|
33
|
+
_TYPE_FAILURE: Final[str] = "failure"
|
|
34
|
+
_TEST_BROKER_URL: Final[str] = "amqp://guest:guest@localhost:5672/"
|
|
35
|
+
# A retry must not make a test wait for the production backoff.
|
|
36
|
+
_NEGLIGIBLE_BACKOFF_S: Final[float] = 0.001
|
|
37
|
+
type MessageName = Literal["started", "progress", "success", "failure"]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class Outcome(StrEnum):
|
|
41
|
+
"""What the worker did to the delivery."""
|
|
42
|
+
|
|
43
|
+
ACKED = "acked"
|
|
44
|
+
NACKED_REQUEUE = "nacked_requeue"
|
|
45
|
+
REJECTED = "rejected"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True, slots=True)
|
|
49
|
+
class TaskRun:
|
|
50
|
+
"""What one submitted message produced."""
|
|
51
|
+
|
|
52
|
+
task_id: str | None
|
|
53
|
+
messages: tuple[JsonObject, ...]
|
|
54
|
+
raw: tuple[bytes, ...]
|
|
55
|
+
outcome: Outcome
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def message_types(self) -> tuple[str, ...]:
|
|
59
|
+
"""The ``message_type`` of each published message, in order."""
|
|
60
|
+
return tuple(str(_data(message).get(_MESSAGE_TYPE)) for message in self.messages)
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def terminal(self) -> JsonObject | None:
|
|
64
|
+
"""The ``data`` of the success or failure message, ``None`` when there is none."""
|
|
65
|
+
for message in reversed(self.messages):
|
|
66
|
+
if _data(message).get(_MESSAGE_TYPE) in _TERMINAL_TYPES:
|
|
67
|
+
return _data(message)
|
|
68
|
+
return None
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def response(self) -> JsonObject | None:
|
|
72
|
+
"""The result of a successful task."""
|
|
73
|
+
terminal = self.terminal
|
|
74
|
+
if terminal is None or terminal.get(_MESSAGE_TYPE) != _TYPE_SUCCESS:
|
|
75
|
+
return None
|
|
76
|
+
response = terminal.get("response")
|
|
77
|
+
return response if isinstance(response, dict) else None
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def error(self) -> JsonObject | None:
|
|
81
|
+
"""The ``{"code", "message"}`` of a failed task."""
|
|
82
|
+
terminal = self.terminal
|
|
83
|
+
if terminal is None or terminal.get(_MESSAGE_TYPE) != _TYPE_FAILURE:
|
|
84
|
+
return None
|
|
85
|
+
error = terminal.get("error_message")
|
|
86
|
+
return error if isinstance(error, dict) else None
|
|
87
|
+
|
|
88
|
+
def assert_succeeded(self) -> None:
|
|
89
|
+
"""Raise ``AssertionError`` unless the task started, succeeded (progress may come between) and was acked."""
|
|
90
|
+
types = self.message_types
|
|
91
|
+
if types[:1] != ("started",) or types[-1:] != (_TYPE_SUCCESS,) or self.outcome is not Outcome.ACKED:
|
|
92
|
+
msg = f"Expected started, success and an ack, got {types} and {self.outcome.value}"
|
|
93
|
+
raise AssertionError(msg)
|
|
94
|
+
|
|
95
|
+
def assert_failed(self, *, code: str | None = None) -> None:
|
|
96
|
+
"""Raise ``AssertionError`` unless the task concluded as a failure (with ``code`` when given) and was acked."""
|
|
97
|
+
error = self.error
|
|
98
|
+
if error is None or self.outcome is not Outcome.ACKED:
|
|
99
|
+
msg = f"Expected a failure and an ack, got {self.message_types} and {self.outcome.value}"
|
|
100
|
+
raise AssertionError(msg)
|
|
101
|
+
if code is not None and error.get("code") != code:
|
|
102
|
+
msg = f"Expected the failure code {code!r}, got {error.get('code')!r}"
|
|
103
|
+
raise AssertionError(msg)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _data(message: JsonObject) -> JsonObject:
|
|
107
|
+
data = message.get(_KEY_DATA)
|
|
108
|
+
return data if isinstance(data, dict) else {}
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class _IsolatedSettings(WorkerSettings):
|
|
112
|
+
"""The worker settings from the values given and nothing else: never the environment of the test machine."""
|
|
113
|
+
|
|
114
|
+
@classmethod
|
|
115
|
+
def settings_customise_sources(
|
|
116
|
+
cls,
|
|
117
|
+
settings_cls: type[BaseSettings],
|
|
118
|
+
init_settings: PydanticBaseSettingsSource,
|
|
119
|
+
env_settings: PydanticBaseSettingsSource,
|
|
120
|
+
dotenv_settings: PydanticBaseSettingsSource,
|
|
121
|
+
file_secret_settings: PydanticBaseSettingsSource,
|
|
122
|
+
) -> tuple[PydanticBaseSettingsSource, ...]:
|
|
123
|
+
del settings_cls, env_settings, dotenv_settings, file_secret_settings # only the values given count
|
|
124
|
+
return (init_settings,)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class WorkerHarness:
|
|
128
|
+
"""Runs a :class:`~async_task_api_worker.Worker` in memory, through the real engine.
|
|
129
|
+
|
|
130
|
+
``settings_overrides`` are :class:`~async_task_api_worker.WorkerSettings` field names (``worker_delivery_limit``,
|
|
131
|
+
``worker_task_timeout_s``...). No environment variable is read; the broker settings are placeholders. The defaults
|
|
132
|
+
are the production ones, except that the retry backoffs are negligible (a millisecond), so that a retry does not
|
|
133
|
+
make a test wait. The time budget is checked as at startup.
|
|
134
|
+
|
|
135
|
+
Use it as ``async with WorkerHarness(worker) as harness`` (or call :meth:`aclose`): the engine's thread pool for
|
|
136
|
+
``def`` handlers is released then, and otherwise when the harness is garbage collected.
|
|
137
|
+
"""
|
|
138
|
+
|
|
139
|
+
def __init__(self, worker: Worker, *, settings_overrides: Mapping[str, object] | None = None) -> None:
|
|
140
|
+
values: dict[str, object] = {
|
|
141
|
+
"broker_url": _TEST_BROKER_URL,
|
|
142
|
+
"in_queue_name": f"{worker.service}_queue_in",
|
|
143
|
+
"out_queue_name": f"{worker.service}_queue_out",
|
|
144
|
+
"worker_retry_backoff_base_s": _NEGLIGIBLE_BACKOFF_S,
|
|
145
|
+
"worker_retry_backoff_max_s": _NEGLIGIBLE_BACKOFF_S,
|
|
146
|
+
**(settings_overrides or {}),
|
|
147
|
+
}
|
|
148
|
+
unknown = sorted(set(values) - set(WorkerSettings.model_fields))
|
|
149
|
+
if unknown:
|
|
150
|
+
# The settings ignore unknown fields, which would hide a typo in a test.
|
|
151
|
+
msg = f"Unknown settings in settings_overrides: {', '.join(unknown)}"
|
|
152
|
+
raise WorkerConfigurationError(msg)
|
|
153
|
+
self.settings: WorkerSettings = _IsolatedSettings.model_validate(values)
|
|
154
|
+
definition = worker._require_definition()
|
|
155
|
+
timeout_s = self.settings.worker_task_timeout_s or definition.timeout_s
|
|
156
|
+
check_time_budget(self.settings, timeout_s)
|
|
157
|
+
self._broker = InMemoryBroker()
|
|
158
|
+
self._engine = Engine(
|
|
159
|
+
definition=definition,
|
|
160
|
+
service=worker.service,
|
|
161
|
+
settings=self.settings,
|
|
162
|
+
publisher=self._broker,
|
|
163
|
+
)
|
|
164
|
+
self._finalizer = weakref.finalize(self, self._engine.close)
|
|
165
|
+
self._task_ids = count(1)
|
|
166
|
+
|
|
167
|
+
async def __aenter__(self) -> Self:
|
|
168
|
+
return self
|
|
169
|
+
|
|
170
|
+
async def __aexit__(
|
|
171
|
+
self,
|
|
172
|
+
exc_type: type[BaseException] | None,
|
|
173
|
+
exc: BaseException | None,
|
|
174
|
+
traceback: TracebackType | None,
|
|
175
|
+
) -> None:
|
|
176
|
+
await self.aclose()
|
|
177
|
+
|
|
178
|
+
async def aclose(self) -> None:
|
|
179
|
+
"""Release the engine's resources. Safe to call twice."""
|
|
180
|
+
self._finalizer()
|
|
181
|
+
|
|
182
|
+
def fail_next_publish(self, message_type: MessageName | None = None) -> None:
|
|
183
|
+
"""Make the next publish fail, as a broker outage would: of ``message_type``, or of any type."""
|
|
184
|
+
|
|
185
|
+
def fault(message: JsonObject) -> Exception | None:
|
|
186
|
+
published = _data(message).get(_MESSAGE_TYPE)
|
|
187
|
+
if message_type is not None and published != message_type:
|
|
188
|
+
return None
|
|
189
|
+
self._broker.publish_fault = None
|
|
190
|
+
return ConnectionError("The in-memory broker was told to fail this publish")
|
|
191
|
+
|
|
192
|
+
self._broker.publish_fault = fault
|
|
193
|
+
|
|
194
|
+
async def submit(
|
|
195
|
+
self,
|
|
196
|
+
body: Mapping[str, JsonValue],
|
|
197
|
+
*,
|
|
198
|
+
task_id: str | None = None,
|
|
199
|
+
client_id: str | None = None,
|
|
200
|
+
input_refs: Mapping[str, InputRef] | None = None,
|
|
201
|
+
delivery_count: int | None = None,
|
|
202
|
+
redelivered: bool = False,
|
|
203
|
+
) -> TaskRun:
|
|
204
|
+
"""Submit a task as the API would, and wait for the worker to settle the delivery.
|
|
205
|
+
|
|
206
|
+
``delivery_count`` is the broker's ``x-delivery-count``: how many times the message was already handed out
|
|
207
|
+
(``None`` for a first delivery, and for a queue that has no counter).
|
|
208
|
+
"""
|
|
209
|
+
identifier = task_id if task_id is not None else f"task-{next(self._task_ids):04d}"
|
|
210
|
+
data: dict[str, object] = {_MESSAGE_TYPE: "submission", "body": dict(body)}
|
|
211
|
+
context: dict[str, object] = {}
|
|
212
|
+
if client_id is not None:
|
|
213
|
+
context["client_id"] = client_id
|
|
214
|
+
if input_refs:
|
|
215
|
+
context["input_refs"] = {name: ref.model_dump(mode="json") for name, ref in input_refs.items()}
|
|
216
|
+
if context:
|
|
217
|
+
data["context"] = context
|
|
218
|
+
raw = json.dumps({_KEY_TASK_ID: identifier, _KEY_DATA: data}).encode()
|
|
219
|
+
return await self.submit_raw(raw, delivery_count=delivery_count, redelivered=redelivered)
|
|
220
|
+
|
|
221
|
+
async def submit_raw(
|
|
222
|
+
self,
|
|
223
|
+
raw: bytes,
|
|
224
|
+
*,
|
|
225
|
+
delivery_count: int | None = None,
|
|
226
|
+
redelivered: bool = False,
|
|
227
|
+
) -> TaskRun:
|
|
228
|
+
"""Deliver ``raw`` bytes as they are (a poison message, say), and wait for the delivery to be settled.
|
|
229
|
+
|
|
230
|
+
``TaskRun.task_id`` is the one in the envelope when the bytes carry one, otherwise ``None``.
|
|
231
|
+
"""
|
|
232
|
+
already_published = len(self._broker.published)
|
|
233
|
+
delivery = self._broker.delivery(raw, delivery_count=delivery_count, redelivered=redelivered)
|
|
234
|
+
await self._engine.process_delivery(delivery)
|
|
235
|
+
published = tuple(self._broker.published[already_published:])
|
|
236
|
+
return TaskRun(
|
|
237
|
+
task_id=decode_envelope(raw).task_id,
|
|
238
|
+
messages=tuple(json.loads(body) for body in published),
|
|
239
|
+
raw=published,
|
|
240
|
+
outcome=_outcome_of(delivery),
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _outcome_of(delivery: MemoryDelivery) -> Outcome:
|
|
245
|
+
"""What the worker did to the delivery. A delivery must be settled exactly once: anything else is an engine bug."""
|
|
246
|
+
if delivery.settlements != 1:
|
|
247
|
+
msg = f"The delivery was settled {delivery.settlements} times (acks: {delivery.ack_count}), expected once"
|
|
248
|
+
raise AssertionError(msg)
|
|
249
|
+
if delivery.ack_count:
|
|
250
|
+
return Outcome.ACKED
|
|
251
|
+
return Outcome.NACKED_REQUEUE if delivery.nack_requeue_values[0] else Outcome.REJECTED
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""An in-memory broker implementing the runtime transport, with delivery counts and fault injection."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
from collections.abc import Awaitable, Callable
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from enum import StrEnum
|
|
8
|
+
|
|
9
|
+
from async_task_api_worker._runtime.transport import OnDelivery, Publisher, TransportUnavailableError
|
|
10
|
+
from async_task_api_worker.protocol.json_types import JsonObject
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class EventKind(StrEnum):
|
|
14
|
+
PUBLISH = "publish"
|
|
15
|
+
ACK = "ack"
|
|
16
|
+
NACK = "nack"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True, slots=True)
|
|
20
|
+
class BrokerEvent:
|
|
21
|
+
"""One thing that happened on the broker, in order. ``body`` is set for a publish, ``requeue`` for a nack."""
|
|
22
|
+
|
|
23
|
+
kind: EventKind
|
|
24
|
+
body: bytes | None = None
|
|
25
|
+
requeue: bool | None = None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
type PublishFault = Callable[[JsonObject], Exception | None]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class MemoryDelivery:
|
|
32
|
+
"""A delivery that records its ack and nack on the broker's ordered event log."""
|
|
33
|
+
|
|
34
|
+
def __init__(self, broker: "InMemoryBroker", body: bytes, *, delivery_count: int | None, redelivered: bool) -> None:
|
|
35
|
+
self._broker = broker
|
|
36
|
+
self._body = body
|
|
37
|
+
self._delivery_count = delivery_count
|
|
38
|
+
self._redelivered = redelivered
|
|
39
|
+
self.ack_count = 0
|
|
40
|
+
self.nack_requeue_values: list[bool] = []
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def body(self) -> bytes:
|
|
44
|
+
return self._body
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def delivery_count(self) -> int | None:
|
|
48
|
+
return self._delivery_count
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def redelivered(self) -> bool:
|
|
52
|
+
return self._redelivered
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def nack_count(self) -> int:
|
|
56
|
+
return len(self.nack_requeue_values)
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def settlements(self) -> int:
|
|
60
|
+
"""Acks plus nacks: exactly one for a delivery the engine has finished with."""
|
|
61
|
+
return self.ack_count + self.nack_count
|
|
62
|
+
|
|
63
|
+
async def ack(self) -> None:
|
|
64
|
+
self.ack_count += 1
|
|
65
|
+
self._broker.events.append(BrokerEvent(EventKind.ACK))
|
|
66
|
+
if self._broker.ack_error is not None:
|
|
67
|
+
raise self._broker.ack_error
|
|
68
|
+
|
|
69
|
+
async def nack(self, *, requeue: bool) -> None:
|
|
70
|
+
self.nack_requeue_values.append(requeue)
|
|
71
|
+
self._broker.events.append(BrokerEvent(EventKind.NACK, requeue=requeue))
|
|
72
|
+
if self._broker.nack_hook is not None:
|
|
73
|
+
await self._broker.nack_hook()
|
|
74
|
+
if self._broker.nack_error is not None:
|
|
75
|
+
raise self._broker.nack_error
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class InMemoryBroker:
|
|
79
|
+
"""Publisher and delivery factory. ``events`` is the ordered log of publishes, acks and nacks."""
|
|
80
|
+
|
|
81
|
+
def __init__(self) -> None:
|
|
82
|
+
self.events: list[BrokerEvent] = []
|
|
83
|
+
self.publish_fault: PublishFault | None = None
|
|
84
|
+
self.ack_error: Exception | None = None
|
|
85
|
+
self.nack_error: Exception | None = None
|
|
86
|
+
self.nack_hook: Callable[[], Awaitable[None]] | None = None
|
|
87
|
+
|
|
88
|
+
def delivery(self, body: bytes, *, delivery_count: int | None = None, redelivered: bool = False) -> MemoryDelivery:
|
|
89
|
+
return MemoryDelivery(self, body, delivery_count=delivery_count, redelivered=redelivered)
|
|
90
|
+
|
|
91
|
+
async def publish(self, body: bytes) -> None:
|
|
92
|
+
if self.publish_fault is not None:
|
|
93
|
+
error = self.publish_fault(json.loads(body))
|
|
94
|
+
if error is not None:
|
|
95
|
+
raise error
|
|
96
|
+
self.events.append(BrokerEvent(EventKind.PUBLISH, body=body))
|
|
97
|
+
|
|
98
|
+
@property
|
|
99
|
+
def published(self) -> list[bytes]:
|
|
100
|
+
return [event.body for event in self.events if event.kind is EventKind.PUBLISH and event.body is not None]
|
|
101
|
+
|
|
102
|
+
def published_messages(self) -> list[JsonObject]:
|
|
103
|
+
"""Published messages, decoded."""
|
|
104
|
+
return [json.loads(body) for body in self.published]
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class MemoryTransport:
|
|
108
|
+
"""A transport on an :class:`InMemoryBroker`: ``deliver`` feeds the consumer like the real broker would."""
|
|
109
|
+
|
|
110
|
+
def __init__(self, broker: InMemoryBroker | None = None) -> None:
|
|
111
|
+
self.broker = broker or InMemoryBroker()
|
|
112
|
+
self.unavailable_attempts = 0
|
|
113
|
+
self.start_attempts = 0
|
|
114
|
+
self.consuming = False
|
|
115
|
+
self.started = False
|
|
116
|
+
self.closed = False
|
|
117
|
+
self.calls: list[str] = []
|
|
118
|
+
self._on_delivery: OnDelivery | None = None
|
|
119
|
+
|
|
120
|
+
@property
|
|
121
|
+
def publisher(self) -> Publisher:
|
|
122
|
+
return self.broker
|
|
123
|
+
|
|
124
|
+
async def start(self, on_delivery: OnDelivery) -> None:
|
|
125
|
+
self.start_attempts += 1
|
|
126
|
+
if self.unavailable_attempts > 0:
|
|
127
|
+
self.unavailable_attempts -= 1
|
|
128
|
+
msg = "The broker is unreachable"
|
|
129
|
+
raise TransportUnavailableError(msg)
|
|
130
|
+
self._on_delivery = on_delivery
|
|
131
|
+
self.consuming = True
|
|
132
|
+
self.started = True
|
|
133
|
+
self.calls.append("start")
|
|
134
|
+
|
|
135
|
+
async def ping(self, timeout_s: float) -> bool:
|
|
136
|
+
del timeout_s
|
|
137
|
+
return self.started and not self.closed
|
|
138
|
+
|
|
139
|
+
async def stop_consuming(self) -> None:
|
|
140
|
+
self.consuming = False
|
|
141
|
+
# Like the real broker, the cancel is a round trip: other tasks run meanwhile.
|
|
142
|
+
await asyncio.sleep(0)
|
|
143
|
+
self.calls.append("stop_consuming")
|
|
144
|
+
|
|
145
|
+
async def close(self) -> None:
|
|
146
|
+
self.closed = True
|
|
147
|
+
self.calls.append("close")
|
|
148
|
+
|
|
149
|
+
def deliver(
|
|
150
|
+
self,
|
|
151
|
+
body: bytes,
|
|
152
|
+
*,
|
|
153
|
+
delivery_count: int | None = None,
|
|
154
|
+
redelivered: bool = False,
|
|
155
|
+
) -> MemoryDelivery:
|
|
156
|
+
"""Hand a new delivery to the consumer. Raises when the transport is not consuming."""
|
|
157
|
+
if not self.consuming or self._on_delivery is None:
|
|
158
|
+
msg = "The transport is not consuming"
|
|
159
|
+
raise RuntimeError(msg)
|
|
160
|
+
delivery = self.broker.delivery(body, delivery_count=delivery_count, redelivered=redelivered)
|
|
161
|
+
self._on_delivery(delivery)
|
|
162
|
+
return delivery
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
"""``Worker``: declares one task and its models. The runtime that consumes the queue is built from it."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import inspect
|
|
5
|
+
import math
|
|
6
|
+
import re
|
|
7
|
+
import time
|
|
8
|
+
import typing
|
|
9
|
+
from collections.abc import Awaitable, Callable
|
|
10
|
+
from typing import Final, NoReturn
|
|
11
|
+
|
|
12
|
+
from pydantic import BaseModel
|
|
13
|
+
|
|
14
|
+
from async_task_api_worker._runtime.engine import AsyncHandler, Engine, SyncHandler, TaskDefinition
|
|
15
|
+
from async_task_api_worker._runtime.lifecycle import Lifecycle, run_blocking
|
|
16
|
+
from async_task_api_worker._runtime.observer import RuntimeObserver
|
|
17
|
+
from async_task_api_worker._runtime.progress import Clock, Sleep
|
|
18
|
+
from async_task_api_worker._runtime.transport import Publisher, Transport
|
|
19
|
+
from async_task_api_worker.context import SyncTaskContext, TaskContext
|
|
20
|
+
from async_task_api_worker.errors import WorkerDefinitionError
|
|
21
|
+
from async_task_api_worker.observability.health import Component
|
|
22
|
+
from async_task_api_worker.protocol.messages import MessageEncoder
|
|
23
|
+
from async_task_api_worker.settings import WorkerSettings
|
|
24
|
+
|
|
25
|
+
_SERVICE_PATTERN: Final[re.Pattern[str]] = re.compile(r"^[a-z][a-z0-9-]*$")
|
|
26
|
+
_PROBE_PATTERN: Final[re.Pattern[str]] = re.compile(r"^[a-z][a-z0-9_]*$")
|
|
27
|
+
_RESERVED_PROBE_NAMES: Final[frozenset[str]] = frozenset(component.value for component in Component)
|
|
28
|
+
_HANDLER_PARAMETERS: Final[int] = 2
|
|
29
|
+
_RETURN_KEY: Final[str] = "return"
|
|
30
|
+
_JSON_SCHEMA_TYPE_KEY: Final[str] = "type"
|
|
31
|
+
_JSON_SCHEMA_OBJECT: Final[str] = "object"
|
|
32
|
+
_POSITIONAL_KINDS: Final[frozenset[int]] = frozenset(
|
|
33
|
+
{inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD},
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
type Hook = Callable[[], Awaitable[None]]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _model_annotation(annotation: object, role: str) -> type[BaseModel]:
|
|
40
|
+
if not (isinstance(annotation, type) and issubclass(annotation, BaseModel)):
|
|
41
|
+
msg = f"The handler {role} must be annotated with a pydantic BaseModel subclass, got {annotation!r}"
|
|
42
|
+
raise WorkerDefinitionError(msg)
|
|
43
|
+
return annotation
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _build_definition(handler: Callable[..., object], timeout_s: float) -> TaskDefinition:
|
|
47
|
+
"""Read the input and output models from the handler's annotations."""
|
|
48
|
+
parameters = list(inspect.signature(handler).parameters.values())
|
|
49
|
+
if len(parameters) != _HANDLER_PARAMETERS or any(p.kind not in _POSITIONAL_KINDS for p in parameters):
|
|
50
|
+
msg = "A task handler takes exactly two positional parameters: the input model and the task context"
|
|
51
|
+
raise WorkerDefinitionError(msg)
|
|
52
|
+
try:
|
|
53
|
+
hints = typing.get_type_hints(handler)
|
|
54
|
+
except Exception as error:
|
|
55
|
+
msg = f"The handler annotations cannot be resolved: {error}"
|
|
56
|
+
raise WorkerDefinitionError(msg) from error
|
|
57
|
+
input_name, context_name = (parameter.name for parameter in parameters)
|
|
58
|
+
for name in (input_name, context_name, _RETURN_KEY):
|
|
59
|
+
if name not in hints:
|
|
60
|
+
msg = f"The handler is missing the annotation of {name!r}"
|
|
61
|
+
raise WorkerDefinitionError(msg)
|
|
62
|
+
|
|
63
|
+
is_async = inspect.iscoroutinefunction(handler)
|
|
64
|
+
expected_context = TaskContext if is_async else SyncTaskContext
|
|
65
|
+
if hints[context_name] is not expected_context:
|
|
66
|
+
kind = "async def" if is_async else "def"
|
|
67
|
+
msg = (
|
|
68
|
+
f"A {kind} handler takes exactly a {expected_context.__name__} as second parameter "
|
|
69
|
+
f"(an exact annotation, no subclass or wrapper), got {hints[context_name]!r}"
|
|
70
|
+
)
|
|
71
|
+
raise WorkerDefinitionError(msg)
|
|
72
|
+
|
|
73
|
+
input_model = _model_annotation(hints[input_name], "input")
|
|
74
|
+
output_model = _model_annotation(hints[_RETURN_KEY], "return value")
|
|
75
|
+
try:
|
|
76
|
+
output_type = output_model.model_json_schema().get(_JSON_SCHEMA_TYPE_KEY)
|
|
77
|
+
except Exception as error:
|
|
78
|
+
msg = f"The JSON schema of {output_model.__name__} cannot be generated: {error}"
|
|
79
|
+
raise WorkerDefinitionError(msg) from error
|
|
80
|
+
if output_type != _JSON_SCHEMA_OBJECT:
|
|
81
|
+
msg = f"The output model {output_model.__name__} must have a JSON object schema, got {output_type!r}"
|
|
82
|
+
raise WorkerDefinitionError(msg)
|
|
83
|
+
return TaskDefinition(
|
|
84
|
+
handler=typing.cast("AsyncHandler | SyncHandler", handler),
|
|
85
|
+
is_async=is_async,
|
|
86
|
+
input_model=input_model,
|
|
87
|
+
output_model=output_model,
|
|
88
|
+
timeout_s=timeout_s,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _require_coroutine_function(function: Callable[..., object], role: str) -> None:
|
|
93
|
+
if not inspect.iscoroutinefunction(function):
|
|
94
|
+
msg = f"The {role} must be an async def function, got {function!r}"
|
|
95
|
+
raise WorkerDefinitionError(msg)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class Worker:
|
|
99
|
+
"""One service, one task.
|
|
100
|
+
|
|
101
|
+
``settings=None`` reads :class:`WorkerSettings` from the environment the first time it is needed, so that
|
|
102
|
+
importing the module that declares the worker never fails on a missing variable.
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
def __init__(self, service: str, *, settings: WorkerSettings | None = None) -> None:
|
|
106
|
+
if not _SERVICE_PATTERN.fullmatch(service):
|
|
107
|
+
msg = f"Invalid service name {service!r}: expected {_SERVICE_PATTERN.pattern}"
|
|
108
|
+
raise WorkerDefinitionError(msg)
|
|
109
|
+
self._service = service
|
|
110
|
+
self._settings = settings
|
|
111
|
+
self._definition: TaskDefinition | None = None
|
|
112
|
+
self._probes: dict[str, Hook] = {}
|
|
113
|
+
self._startup_hooks: list[Hook] = []
|
|
114
|
+
self._shutdown_hooks: list[Hook] = []
|
|
115
|
+
|
|
116
|
+
@property
|
|
117
|
+
def service(self) -> str:
|
|
118
|
+
return self._service
|
|
119
|
+
|
|
120
|
+
@property
|
|
121
|
+
def settings(self) -> WorkerSettings:
|
|
122
|
+
if self._settings is None:
|
|
123
|
+
self._settings = WorkerSettings()
|
|
124
|
+
return self._settings
|
|
125
|
+
|
|
126
|
+
def task[F: Callable[..., object]](self, *, timeout: float) -> Callable[[F], F]:
|
|
127
|
+
"""Declare the task handler: ``async def`` runs on the loop, ``def`` in the SDK thread pool.
|
|
128
|
+
|
|
129
|
+
The second parameter must be annotated exactly ``TaskContext`` (``async def``) or ``SyncTaskContext``
|
|
130
|
+
(``def``): the check is an identity one, a subclass or a wrapper such as ``Annotated`` is rejected.
|
|
131
|
+
|
|
132
|
+
The input and output models are read from the annotations (``def handle(inp: In, ctx: TaskContext) -> Out``).
|
|
133
|
+
``timeout`` is in seconds; ``WORKER_TASK_TIMEOUT_S`` overrides it. The function is returned unchanged.
|
|
134
|
+
"""
|
|
135
|
+
if not math.isfinite(timeout) or timeout <= 0:
|
|
136
|
+
msg = f"timeout must be a positive number of seconds, got {timeout!r}"
|
|
137
|
+
raise WorkerDefinitionError(msg)
|
|
138
|
+
|
|
139
|
+
def decorator(handler: F) -> F:
|
|
140
|
+
if self._definition is not None:
|
|
141
|
+
msg = "A worker declares exactly one task"
|
|
142
|
+
raise WorkerDefinitionError(msg)
|
|
143
|
+
self._definition = _build_definition(handler, timeout)
|
|
144
|
+
return handler
|
|
145
|
+
|
|
146
|
+
return decorator
|
|
147
|
+
|
|
148
|
+
def readiness_probe(self, name: str) -> Callable[[Hook], Hook]:
|
|
149
|
+
"""Register a dependency probe: it raises to signal "not ready"."""
|
|
150
|
+
if not _PROBE_PATTERN.fullmatch(name) or name in _RESERVED_PROBE_NAMES:
|
|
151
|
+
reserved = ", ".join(sorted(_RESERVED_PROBE_NAMES))
|
|
152
|
+
msg = f"Invalid probe name {name!r}: expected {_PROBE_PATTERN.pattern}, and not one of: {reserved}"
|
|
153
|
+
raise WorkerDefinitionError(msg)
|
|
154
|
+
|
|
155
|
+
def decorator(probe: Hook) -> Hook:
|
|
156
|
+
_require_coroutine_function(probe, f"probe {name!r}")
|
|
157
|
+
if name in self._probes:
|
|
158
|
+
msg = f"Probe {name!r} is already registered"
|
|
159
|
+
raise WorkerDefinitionError(msg)
|
|
160
|
+
self._probes[name] = probe
|
|
161
|
+
return probe
|
|
162
|
+
|
|
163
|
+
return decorator
|
|
164
|
+
|
|
165
|
+
def on_startup(self, hook: Hook) -> Hook:
|
|
166
|
+
"""Run before consuming, in registration order."""
|
|
167
|
+
_require_coroutine_function(hook, "startup hook")
|
|
168
|
+
self._startup_hooks.append(hook)
|
|
169
|
+
return hook
|
|
170
|
+
|
|
171
|
+
def on_shutdown(self, hook: Hook) -> Hook:
|
|
172
|
+
"""Run after the drain, in reverse registration order."""
|
|
173
|
+
_require_coroutine_function(hook, "shutdown hook")
|
|
174
|
+
self._shutdown_hooks.append(hook)
|
|
175
|
+
return hook
|
|
176
|
+
|
|
177
|
+
@property
|
|
178
|
+
def input_model(self) -> type[BaseModel]:
|
|
179
|
+
return self._require_definition().input_model
|
|
180
|
+
|
|
181
|
+
@property
|
|
182
|
+
def output_model(self) -> type[BaseModel]:
|
|
183
|
+
return self._require_definition().output_model
|
|
184
|
+
|
|
185
|
+
def run(self) -> NoReturn:
|
|
186
|
+
"""Serve until SIGTERM or SIGINT, then exit: code 0 after a clean drain, 2 on a configuration error.
|
|
187
|
+
|
|
188
|
+
With ``WORKER_MODE=job`` it also exits 0 once ``WORKER_JOB_MAX_TASKS`` deliveries are settled, or when none
|
|
189
|
+
arrives within ``WORKER_JOB_IDLE_TIMEOUT_S``.
|
|
190
|
+
"""
|
|
191
|
+
raise SystemExit(run_blocking(self))
|
|
192
|
+
|
|
193
|
+
async def serve(self) -> None:
|
|
194
|
+
"""Serve until SIGTERM or SIGINT (or, in job mode, until the job is done), for embedding: returns after a drain.
|
|
195
|
+
|
|
196
|
+
Reads and validates the settings first, so a bad configuration fails before the first delivery. The probe and
|
|
197
|
+
metrics server runs on ``HEALTH_CHECK_HOST:HEALTH_CHECK_PORT`` for as long as it serves, embedded or not.
|
|
198
|
+
"""
|
|
199
|
+
await self._serve_report()
|
|
200
|
+
|
|
201
|
+
async def _serve_report(
|
|
202
|
+
self,
|
|
203
|
+
transport: Transport | None = None,
|
|
204
|
+
*,
|
|
205
|
+
serve_probes: bool | None = None,
|
|
206
|
+
) -> tuple[str, ...]:
|
|
207
|
+
"""Serve like :meth:`serve`, and return the task ids of sync handlers still running after the drain.
|
|
208
|
+
|
|
209
|
+
``transport`` is for the tests: the broker transport is built from the settings by default, and the probe
|
|
210
|
+
server then runs (``serve_probes`` overrides it, so that a test never binds the probe port).
|
|
211
|
+
"""
|
|
212
|
+
settings = self.settings
|
|
213
|
+
if serve_probes is None:
|
|
214
|
+
serve_probes = transport is None
|
|
215
|
+
if transport is None:
|
|
216
|
+
from async_task_api_worker._runtime.amqp_transport import AmqpTransport # noqa: PLC0415
|
|
217
|
+
|
|
218
|
+
transport = AmqpTransport(
|
|
219
|
+
url=settings.broker_url.get_secret_value(),
|
|
220
|
+
in_queue=settings.in_queue_name,
|
|
221
|
+
out_queue=settings.out_queue_name,
|
|
222
|
+
prefetch=settings.prefetch,
|
|
223
|
+
reconnect_interval_s=settings.worker_broker_reconnect_interval_s,
|
|
224
|
+
)
|
|
225
|
+
lifecycle = Lifecycle(self, transport, serve_probes=serve_probes)
|
|
226
|
+
await lifecycle.run()
|
|
227
|
+
return lifecycle.overrunning
|
|
228
|
+
|
|
229
|
+
def _require_definition(self) -> TaskDefinition:
|
|
230
|
+
if self._definition is None:
|
|
231
|
+
msg = "No task declared: decorate the handler with @worker.task(timeout=...)"
|
|
232
|
+
raise WorkerDefinitionError(msg)
|
|
233
|
+
return self._definition
|
|
234
|
+
|
|
235
|
+
def _create_engine(
|
|
236
|
+
self,
|
|
237
|
+
publisher: Publisher,
|
|
238
|
+
*,
|
|
239
|
+
encoder: MessageEncoder | None = None,
|
|
240
|
+
observer: RuntimeObserver | None = None,
|
|
241
|
+
clock: Clock | None = None,
|
|
242
|
+
sleep: Sleep | None = None,
|
|
243
|
+
) -> Engine:
|
|
244
|
+
"""Build the engine for this worker. Internal: used by the runtime and the conformance kit."""
|
|
245
|
+
return Engine(
|
|
246
|
+
definition=self._require_definition(),
|
|
247
|
+
service=self._service,
|
|
248
|
+
settings=self.settings,
|
|
249
|
+
publisher=publisher,
|
|
250
|
+
encoder=encoder,
|
|
251
|
+
observer=observer,
|
|
252
|
+
clock=clock or time.monotonic,
|
|
253
|
+
sleep=sleep or asyncio.sleep,
|
|
254
|
+
)
|