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.
Files changed (40) hide show
  1. async_task_api_worker/__init__.py +42 -0
  2. async_task_api_worker/__main__.py +5 -0
  3. async_task_api_worker/_runtime/__init__.py +1 -0
  4. async_task_api_worker/_runtime/amqp_transport.py +251 -0
  5. async_task_api_worker/_runtime/declare.py +40 -0
  6. async_task_api_worker/_runtime/engine.py +543 -0
  7. async_task_api_worker/_runtime/inflight.py +38 -0
  8. async_task_api_worker/_runtime/lifecycle.py +463 -0
  9. async_task_api_worker/_runtime/log_context.py +20 -0
  10. async_task_api_worker/_runtime/observer.py +78 -0
  11. async_task_api_worker/_runtime/progress.py +143 -0
  12. async_task_api_worker/_runtime/retry.py +29 -0
  13. async_task_api_worker/_runtime/sync_bridge.py +68 -0
  14. async_task_api_worker/_runtime/transport.py +62 -0
  15. async_task_api_worker/_version.py +11 -0
  16. async_task_api_worker/cli.py +88 -0
  17. async_task_api_worker/context.py +70 -0
  18. async_task_api_worker/errors.py +116 -0
  19. async_task_api_worker/observability/__init__.py +1 -0
  20. async_task_api_worker/observability/health.py +299 -0
  21. async_task_api_worker/observability/logging.py +89 -0
  22. async_task_api_worker/observability/loop_lag.py +56 -0
  23. async_task_api_worker/observability/metrics.py +188 -0
  24. async_task_api_worker/protocol/__init__.py +41 -0
  25. async_task_api_worker/protocol/envelope.py +103 -0
  26. async_task_api_worker/protocol/json_types.py +7 -0
  27. async_task_api_worker/protocol/messages.py +121 -0
  28. async_task_api_worker/protocol/version.py +11 -0
  29. async_task_api_worker/py.typed +0 -0
  30. async_task_api_worker/settings.py +127 -0
  31. async_task_api_worker/testing/__init__.py +27 -0
  32. async_task_api_worker/testing/_context.py +93 -0
  33. async_task_api_worker/testing/_harness.py +251 -0
  34. async_task_api_worker/testing/_memory_broker.py +162 -0
  35. async_task_api_worker/worker.py +254 -0
  36. async_task_api_worker-0.1.0rc2.dist-info/METADATA +269 -0
  37. async_task_api_worker-0.1.0rc2.dist-info/RECORD +40 -0
  38. async_task_api_worker-0.1.0rc2.dist-info/WHEEL +4 -0
  39. async_task_api_worker-0.1.0rc2.dist-info/entry_points.txt +2 -0
  40. 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
+ )