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,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 @@
|
|
|
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)
|