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,42 @@
1
+ """SDK to write workers for the AsyncTaskAPI platform."""
2
+
3
+ from async_task_api_worker._version import __version__
4
+ from async_task_api_worker.context import SyncTaskContext, TaskContext
5
+ from async_task_api_worker.errors import (
6
+ RESERVED_CODES,
7
+ ErrorCode,
8
+ FatalError,
9
+ ProbeError,
10
+ RetryableError,
11
+ WorkerConfigurationError,
12
+ WorkerDefinitionError,
13
+ WorkerError,
14
+ )
15
+ from async_task_api_worker.protocol.envelope import InputRef # reached by handlers through ``ctx.inputs``
16
+ from async_task_api_worker.protocol.json_types import JsonObject, JsonValue, ProgressPayload
17
+ from async_task_api_worker.protocol.version import PROTOCOL_VERSION
18
+ from async_task_api_worker.settings import LogLevel, WorkerMode, WorkerSettings
19
+ from async_task_api_worker.worker import Worker
20
+
21
+ __all__ = [
22
+ "PROTOCOL_VERSION",
23
+ "RESERVED_CODES",
24
+ "ErrorCode",
25
+ "FatalError",
26
+ "InputRef",
27
+ "JsonObject",
28
+ "JsonValue",
29
+ "LogLevel",
30
+ "ProbeError",
31
+ "ProgressPayload",
32
+ "RetryableError",
33
+ "SyncTaskContext",
34
+ "TaskContext",
35
+ "Worker",
36
+ "WorkerConfigurationError",
37
+ "WorkerDefinitionError",
38
+ "WorkerError",
39
+ "WorkerMode",
40
+ "WorkerSettings",
41
+ "__version__",
42
+ ]
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from async_task_api_worker.cli import main
4
+
5
+ sys.exit(main())
@@ -0,0 +1 @@
1
+ """Private runtime: nothing imported from here is public API."""
@@ -0,0 +1,251 @@
1
+ """The broker transport, on aio-pika. This and ``declare.py`` are the only modules that import it.
2
+
3
+ One robust connection (it reconnects by itself and restores what it opened), with two channels:
4
+
5
+ - the consume channel: a prefetch equal to the concurrency and manual acknowledgements, the engine acking only after
6
+ the terminal publish. The consumer is cancelled by this module, with the tag it owns, so that the order of the cancel
7
+ and of the last deliveries is ours: every delivery sent before the cancel is handed to the engine, and a cancelled
8
+ consumer is never restored after a reconnect;
9
+ - the publish channel: publisher confirms on, and ``mandatory`` publishes that raise when nothing is routed.
10
+
11
+ Queues are declared by :mod:`declare` (durable, no argument: the broker decides the type and the policies) and only
12
+ attached here with a passive declare.
13
+
14
+ Published messages are persistent, with a JSON content type and no other property of our own: no correlation id, no
15
+ headers. aio-pika adds a random ``message_id``, which means nothing to anyone; the listener reads the body only (a task
16
+ is identified by its ``task_id`` inside it).
17
+ """
18
+
19
+ import asyncio
20
+ import contextlib
21
+ import logging
22
+ from collections.abc import Awaitable, Callable
23
+ from typing import Final
24
+
25
+ from aio_pika import DeliveryMode, Message
26
+ from aio_pika.abc import (
27
+ AbstractChannel,
28
+ AbstractIncomingMessage,
29
+ AbstractQueue,
30
+ AbstractRobustConnection,
31
+ ConsumerTag,
32
+ )
33
+ from aio_pika.connection import make_url
34
+ from aio_pika.exceptions import AMQPConnectionError, ChannelInvalidStateError, ProbableAuthenticationError
35
+ from aio_pika.robust_connection import RobustConnection
36
+
37
+ from async_task_api_worker._runtime.declare import authentication_failed, declare_queues
38
+ from async_task_api_worker._runtime.transport import CLOSE_TIMEOUT_S, OnDelivery, Publisher, TransportUnavailableError
39
+
40
+ # Quorum queues count a delivery that was returned after a failure (channel closed, consumer gone) in
41
+ # ``x-delivery-count``, and every delivery of a message handed out again, including a voluntary
42
+ # ``nack(requeue=True)``, in ``x-acquired-count``. Only the failures can reach the broker's delivery limit.
43
+ _DELIVERY_COUNT_HEADERS: Final[tuple[str, ...]] = ("x-delivery-count", "x-acquired-count")
44
+ # How long a publish waits for the broker's confirmation before it counts as failed.
45
+ _PUBLISH_CONFIRM_TIMEOUT_S: Final[float] = 30.0
46
+ # How long ``basic.cancel`` may take. During an outage the robust channel waits for the reconnect before it sends it.
47
+ CANCEL_TIMEOUT_S: Final[float] = 5.0
48
+ _CONTENT_TYPE: Final[str] = "application/json"
49
+ # The share of the close timeout the channels may take; the connection gets the rest.
50
+ _CHANNELS_SHARE: Final[float] = 0.5
51
+ _LOGGER: Final[logging.Logger] = logging.getLogger("async_task_api_worker")
52
+ _UNREACHABLE: Final[tuple[type[Exception], ...]] = (OSError, AMQPConnectionError)
53
+
54
+ type NewConnection = Callable[[str, float], AbstractRobustConnection]
55
+
56
+
57
+ def _robust_connection(url: str, reconnect_interval_s: float) -> AbstractRobustConnection:
58
+ """A robust connection, not connected yet: the caller owns it from here and can close it whatever happens."""
59
+ return RobustConnection(make_url(url), reconnect_interval=reconnect_interval_s)
60
+
61
+
62
+ class _AmqpDelivery:
63
+ def __init__(self, message: AbstractIncomingMessage) -> None:
64
+ self._message = message
65
+
66
+ @property
67
+ def body(self) -> bytes:
68
+ return self._message.body
69
+
70
+ @property
71
+ def delivery_count(self) -> int | None:
72
+ """How many times the broker already handed this message out: the largest of the two broker counters."""
73
+ headers = self._message.headers or {}
74
+ counts = [value for name in _DELIVERY_COUNT_HEADERS if isinstance(value := headers.get(name), int)]
75
+ return max(counts) if counts else None
76
+
77
+ @property
78
+ def redelivered(self) -> bool:
79
+ return bool(self._message.redelivered)
80
+
81
+ async def ack(self) -> None:
82
+ await self._message.ack()
83
+
84
+ async def nack(self, *, requeue: bool) -> None:
85
+ # Never ``reject``: the broker counts it as a failure, like a crash.
86
+ await self._message.nack(requeue=requeue)
87
+
88
+
89
+ class _AmqpPublisher:
90
+ def __init__(self, transport: "AmqpTransport") -> None:
91
+ self._transport = transport
92
+
93
+ async def publish(self, body: bytes) -> None:
94
+ await self._transport.publish(body)
95
+
96
+
97
+ class AmqpTransport:
98
+ """RabbitMQ through aio-pika: one consume channel (prefetch = concurrency), one publish channel with confirms."""
99
+
100
+ def __init__(
101
+ self,
102
+ *,
103
+ url: str,
104
+ in_queue: str,
105
+ out_queue: str,
106
+ prefetch: int,
107
+ reconnect_interval_s: float,
108
+ new_connection: NewConnection = _robust_connection,
109
+ cancel_timeout_s: float = CANCEL_TIMEOUT_S,
110
+ close_timeout_s: float = CLOSE_TIMEOUT_S,
111
+ declare: Callable[[str, tuple[str, ...]], Awaitable[None]] = declare_queues,
112
+ ) -> None:
113
+ self._new_connection = new_connection
114
+ self._cancel_timeout_s = cancel_timeout_s
115
+ self._close_timeout_s = close_timeout_s
116
+ self._declare = declare
117
+ self._url = url
118
+ self._in_queue = in_queue
119
+ self._out_queue = out_queue
120
+ self._prefetch = prefetch
121
+ self._reconnect_interval_s = reconnect_interval_s
122
+ self._on_delivery: OnDelivery | None = None
123
+ self._connection: AbstractRobustConnection | None = None
124
+ self._consume_channel: AbstractChannel | None = None
125
+ self._publish_channel: AbstractChannel | None = None
126
+ self._queue: AbstractQueue | None = None
127
+ self._consumer_tag: ConsumerTag | None = None
128
+ self._stopped = False
129
+ self._publisher = _AmqpPublisher(self)
130
+
131
+ @property
132
+ def publisher(self) -> Publisher:
133
+ return self._publisher
134
+
135
+ async def start(self, on_delivery: OnDelivery) -> None:
136
+ await self._declare(self._url, (self._in_queue, self._out_queue))
137
+ self._on_delivery = on_delivery
138
+ self._stopped = False
139
+ connection = self._new_connection(self._url, self._reconnect_interval_s)
140
+ try:
141
+ await connection.connect()
142
+ consume_channel = await connection.channel()
143
+ await consume_channel.set_qos(prefetch_count=self._prefetch)
144
+ publish_channel = await connection.channel(publisher_confirms=True, on_return_raises=True)
145
+ # Passive: the queue was declared above, and a passive declare sends no argument.
146
+ queue = await consume_channel.declare_queue(self._in_queue, passive=True)
147
+ self._consumer_tag = await queue.consume(self._on_message)
148
+ except ProbableAuthenticationError as error:
149
+ await self._discard(connection)
150
+ raise authentication_failed() from error
151
+ except _UNREACHABLE as error:
152
+ await self._discard(connection)
153
+ msg = "The broker is unreachable"
154
+ raise TransportUnavailableError(msg) from error
155
+ except BaseException:
156
+ # Also a cancellation (the job connect timeout): the half-built robust connection would keep reconnecting.
157
+ await self._discard(connection)
158
+ raise
159
+ self._connection = connection
160
+ self._consume_channel = consume_channel
161
+ self._publish_channel = publish_channel
162
+ self._queue = queue
163
+
164
+ async def _discard(self, connection: AbstractRobustConnection) -> None:
165
+ with contextlib.suppress(Exception):
166
+ async with asyncio.timeout(self._close_timeout_s):
167
+ await connection.close()
168
+
169
+ async def _on_message(self, message: AbstractIncomingMessage) -> None:
170
+ assert self._on_delivery is not None
171
+ if self._stopped:
172
+ # A delivery that still arrives after the stop (the consumer comes back after a reconnect, say): the engine
173
+ # is draining and must not take it. Give it back at once.
174
+ with contextlib.suppress(Exception):
175
+ await message.nack(requeue=True)
176
+ return
177
+ self._on_delivery(_AmqpDelivery(message))
178
+
179
+ async def stop_consuming(self) -> None:
180
+ """``basic.cancel``: once it returns, no new delivery comes, and every one sent before it was handed over.
181
+
182
+ Always sent, even while the connection is down: the robust queue forgets the consumer before its first await,
183
+ so that it is not restored after the reconnect, and the cancel itself then waits for the channel, hence the
184
+ timeout. Whatever still arrives after the stop is given back (see ``_on_message``).
185
+ """
186
+ queue, tag = self._queue, self._consumer_tag
187
+ self._consumer_tag = None
188
+ try:
189
+ if queue is not None and tag is not None:
190
+ with contextlib.suppress(ChannelInvalidStateError, TimeoutError):
191
+ async with asyncio.timeout(self._cancel_timeout_s):
192
+ await queue.cancel(tag)
193
+ finally:
194
+ # After the cancel, not before: what was sent before it still reaches the engine.
195
+ self._stopped = True
196
+
197
+ async def ping(self, timeout_s: float) -> bool:
198
+ del timeout_s # the robust connection tracks its own state: no round trip is needed
199
+ connection = self._connection
200
+ return connection is not None and not connection.is_closed and connection.connected.is_set()
201
+
202
+ async def close(self) -> None:
203
+ """Close the channels, wait for the broker to confirm it, then close the connection.
204
+
205
+ Closing the connection alone writes ``Connection.Close`` and drops the socket without waiting. The broker's
206
+ reader then shuts the channel process down with an exit signal that takes priority over a ``basic.ack`` still in
207
+ its mailbox, and the quorum queue gives the delivery that was just concluded back: it is processed twice. A
208
+ ``Channel.Close`` is an RPC answered after what the channel already received, the ack included.
209
+
210
+ Each step is bounded (the channels get half of ``close_timeout_s``, the connection the rest) and a failure of
211
+ one does not prevent the next: unacked deliveries are then requeued by the broker, the correct fallback.
212
+ """
213
+ connection, self._connection = self._connection, None
214
+ channels = [channel for channel in (self._consume_channel, self._publish_channel) if channel is not None]
215
+ self._consume_channel = None
216
+ self._publish_channel = None
217
+ self._queue = None
218
+ if connection is None:
219
+ return
220
+ channels_timeout_s = self._close_timeout_s * _CHANNELS_SHARE
221
+ try:
222
+ async with asyncio.timeout(channels_timeout_s):
223
+ await asyncio.gather(*(self._close_channel(channel) for channel in channels))
224
+ except TimeoutError:
225
+ _LOGGER.warning(
226
+ "Closing the channels took more than %s s: closing the connection anyway.",
227
+ channels_timeout_s,
228
+ )
229
+ async with asyncio.timeout(self._close_timeout_s - channels_timeout_s):
230
+ await connection.close()
231
+
232
+ @staticmethod
233
+ async def _close_channel(channel: AbstractChannel) -> None:
234
+ try:
235
+ await channel.close()
236
+ except Exception:
237
+ _LOGGER.warning("Closing a channel failed: closing the connection anyway.", exc_info=True)
238
+
239
+ async def publish(self, body: bytes) -> None:
240
+ """Persistent, publisher-confirmed, mandatory publish on the output queue (default exchange)."""
241
+ channel = self._publish_channel
242
+ if channel is None:
243
+ msg = "The transport is not started"
244
+ raise TransportUnavailableError(msg)
245
+ message = Message(body, delivery_mode=DeliveryMode.PERSISTENT, content_type=_CONTENT_TYPE)
246
+ await channel.default_exchange.publish(
247
+ message,
248
+ routing_key=self._out_queue,
249
+ mandatory=True,
250
+ timeout=_PUBLISH_CONFIRM_TIMEOUT_S,
251
+ )
@@ -0,0 +1,40 @@
1
+ """Queue declaration, done by the SDK itself and never with arguments.
2
+
3
+ The broker decides the queue type (the virtual host default) and the policies add the delivery limit and the dead
4
+ letter exchange. Declaring with an explicit ``x-queue-type`` (``classic``, say) would fail with
5
+ ``PRECONDITION_FAILED`` against an existing quorum queue. Consumers and publishers are then attached with a passive
6
+ declare only.
7
+ """
8
+
9
+ from collections.abc import Awaitable, Callable
10
+
11
+ import aio_pika
12
+ from aio_pika.abc import AbstractConnection
13
+
14
+ from async_task_api_worker._runtime.transport import TransportUnavailableError
15
+ from async_task_api_worker.errors import WorkerConfigurationError
16
+
17
+ _UNREACHABLE: tuple[type[Exception], ...] = (OSError, aio_pika.exceptions.AMQPConnectionError)
18
+
19
+
20
+ def authentication_failed() -> WorkerConfigurationError:
21
+ """The broker refused the credentials: retrying would loop forever, it is a configuration error."""
22
+ return WorkerConfigurationError("Broker authentication failed: check the credentials in BROKER_URL")
23
+
24
+
25
+ type Connect = Callable[[str], Awaitable[AbstractConnection]]
26
+
27
+
28
+ async def declare_queues(url: str, queue_names: tuple[str, ...], *, connect: Connect = aio_pika.connect) -> None:
29
+ """Declare each queue durable, with no arguments. Idempotent when the queue already exists."""
30
+ try:
31
+ connection = await connect(url)
32
+ except aio_pika.exceptions.ProbableAuthenticationError as error:
33
+ raise authentication_failed() from error
34
+ except _UNREACHABLE as error:
35
+ msg = "The broker is unreachable"
36
+ raise TransportUnavailableError(msg) from error
37
+ async with connection:
38
+ channel = await connection.channel()
39
+ for name in queue_names:
40
+ await channel.declare_queue(name, durable=True)