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,543 @@
|
|
|
1
|
+
"""The per-delivery state machine: one ack or one nack for every delivery, whatever happens.
|
|
2
|
+
|
|
3
|
+
Transport-agnostic: it runs on a :class:`Delivery` source and a :class:`Publisher`. The outcome of a delivery:
|
|
4
|
+
|
|
5
|
+
- poison envelope: ack, nothing published;
|
|
6
|
+
- ``started`` publish fails: nack (requeue), the handler does not run;
|
|
7
|
+
- the handler concludes (success, ``INPUT_INVALID``, ``OUTPUT_INVALID``, ``TASK_TIMEOUT``, a ``WorkerError``, any
|
|
8
|
+
other exception as ``INTERNAL_ERROR``): the terminal message is published, **then** the delivery is acked;
|
|
9
|
+
- a terminal publish that fails: nack (requeue), never left pending;
|
|
10
|
+
- ``RetryableError`` before the last attempt: backoff, then nack (requeue); on the last attempt it concludes with
|
|
11
|
+
its own code;
|
|
12
|
+
- cancellation (shutdown after the grace period): nack (requeue), then the cancellation is re-raised.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import asyncio
|
|
16
|
+
import hashlib
|
|
17
|
+
import logging
|
|
18
|
+
import random
|
|
19
|
+
import threading
|
|
20
|
+
import time
|
|
21
|
+
from collections.abc import Awaitable, Callable, Mapping
|
|
22
|
+
from contextvars import Token
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from enum import StrEnum
|
|
25
|
+
from functools import partial
|
|
26
|
+
from typing import Final, Literal, cast
|
|
27
|
+
|
|
28
|
+
from pydantic import BaseModel, ValidationError
|
|
29
|
+
|
|
30
|
+
from async_task_api_worker._runtime.inflight import InFlightRegistry, InFlightTask
|
|
31
|
+
from async_task_api_worker._runtime.log_context import TaskLogContext, current_task
|
|
32
|
+
from async_task_api_worker._runtime.observer import MessageType, NoopObserver, RuntimeObserver, TaskStatus
|
|
33
|
+
from async_task_api_worker._runtime.progress import Clock, ProgressDropLog, ProgressReporter, Sleep
|
|
34
|
+
from async_task_api_worker._runtime.retry import Uniform, attempt_of, backoff_seconds
|
|
35
|
+
from async_task_api_worker._runtime.sync_bridge import SyncBridge, make_threadsafe_progress
|
|
36
|
+
from async_task_api_worker._runtime.transport import Delivery, Publisher
|
|
37
|
+
from async_task_api_worker.context import SyncTaskContext, TaskContext
|
|
38
|
+
from async_task_api_worker.errors import MAX_MESSAGE_LENGTH, ErrorCode, RetryableError, WorkerError
|
|
39
|
+
from async_task_api_worker.protocol.envelope import InputRef, PoisonDelivery, TaskEnvelope, decode_envelope
|
|
40
|
+
from async_task_api_worker.protocol.messages import MessageEncoder
|
|
41
|
+
from async_task_api_worker.settings import WorkerSettings
|
|
42
|
+
|
|
43
|
+
_LOGGER: Final[logging.Logger] = logging.getLogger("async_task_api_worker")
|
|
44
|
+
# Enough to correlate a poison delivery with the broker or the API, and nothing of its content.
|
|
45
|
+
_BODY_DIGEST_HEX_LENGTH: Final[int] = 12
|
|
46
|
+
_MAX_REPORTED_ISSUES: Final[int] = 10
|
|
47
|
+
_ELLIPSIS: Final[str] = "..."
|
|
48
|
+
|
|
49
|
+
type AsyncHandler = Callable[[BaseModel, TaskContext], Awaitable[BaseModel]]
|
|
50
|
+
type SyncHandler = Callable[[BaseModel, SyncTaskContext], BaseModel]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True, slots=True)
|
|
54
|
+
class TaskDefinition:
|
|
55
|
+
"""What ``Worker.task`` registers: the handler and the models read from its annotations."""
|
|
56
|
+
|
|
57
|
+
handler: AsyncHandler | SyncHandler
|
|
58
|
+
is_async: bool
|
|
59
|
+
input_model: type[BaseModel]
|
|
60
|
+
output_model: type[BaseModel]
|
|
61
|
+
timeout_s: float
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(frozen=True, slots=True)
|
|
65
|
+
class _Terminal:
|
|
66
|
+
"""A terminal message to publish. ``code`` is ``None`` for a success."""
|
|
67
|
+
|
|
68
|
+
body: bytes
|
|
69
|
+
code: str | None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass(frozen=True, slots=True)
|
|
73
|
+
class _Retry:
|
|
74
|
+
error: RetryableError
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class _DeadlineExceededError(Exception):
|
|
78
|
+
"""The handler did not return before the deadline."""
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass(slots=True)
|
|
82
|
+
class _Run:
|
|
83
|
+
"""Per-delivery state that must survive an exception: what the concurrency slot waits for."""
|
|
84
|
+
|
|
85
|
+
entry: InFlightTask | None = None
|
|
86
|
+
thread_future: asyncio.Future[BaseModel] | None = None
|
|
87
|
+
log_token: Token[TaskLogContext | None] | None = None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass(frozen=True, slots=True)
|
|
91
|
+
class _Attempt:
|
|
92
|
+
"""Everything one handler run needs."""
|
|
93
|
+
|
|
94
|
+
envelope: TaskEnvelope
|
|
95
|
+
number: int
|
|
96
|
+
deadline: float
|
|
97
|
+
reporter: ProgressReporter
|
|
98
|
+
log: logging.LoggerAdapter[logging.Logger]
|
|
99
|
+
run: _Run
|
|
100
|
+
counted: bool = True
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def task_id(self) -> str:
|
|
104
|
+
return self.envelope.task_id
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class DeliveryOutcome(StrEnum):
|
|
108
|
+
"""What the engine decided about a delivery: the ``outcome`` of its decision log line."""
|
|
109
|
+
|
|
110
|
+
SUCCESS = "success"
|
|
111
|
+
FAILURE = "failure"
|
|
112
|
+
POISON = "poison"
|
|
113
|
+
RETRY = "retry"
|
|
114
|
+
PUBLISH_FAILED = "publish_failed"
|
|
115
|
+
SHUTDOWN_REQUEUED = "shutdown_requeued"
|
|
116
|
+
CANCELLED = "cancelled"
|
|
117
|
+
ENGINE_ERROR = "engine_error"
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class SettleAction(StrEnum):
|
|
121
|
+
"""What was done to the broker delivery: the ``action`` of its decision log line."""
|
|
122
|
+
|
|
123
|
+
ACK = "ack"
|
|
124
|
+
CONCLUDE = "conclude"
|
|
125
|
+
NACK_REQUEUE = "nack-requeue"
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class _Settler:
|
|
129
|
+
"""Settles one delivery exactly once, however many code paths reach for it.
|
|
130
|
+
|
|
131
|
+
Every settlement writes one INFO "decision" line with the task id, the attempt over the limit, the outcome, the
|
|
132
|
+
failure code and the action, so that ``grep <task_id>`` tells the whole story of a task. Never a payload.
|
|
133
|
+
"""
|
|
134
|
+
|
|
135
|
+
def __init__(self, delivery: Delivery, observer: RuntimeObserver, service: str, delivery_limit: int) -> None:
|
|
136
|
+
self._delivery = delivery
|
|
137
|
+
self._observer = observer
|
|
138
|
+
self._service = service
|
|
139
|
+
self._delivery_limit = delivery_limit
|
|
140
|
+
self._attempt = attempt_of(delivery)
|
|
141
|
+
self.task_id: str | None = None
|
|
142
|
+
self.settled = False
|
|
143
|
+
|
|
144
|
+
def _log_decision(self, outcome: DeliveryOutcome, action: SettleAction, code: str | None) -> None:
|
|
145
|
+
_LOGGER.info(
|
|
146
|
+
"Delivery settled: outcome=%s action=%s code=%s attempt=%d/%d",
|
|
147
|
+
outcome,
|
|
148
|
+
action,
|
|
149
|
+
code,
|
|
150
|
+
self._attempt,
|
|
151
|
+
self._delivery_limit,
|
|
152
|
+
extra={
|
|
153
|
+
"task_id": self.task_id,
|
|
154
|
+
"service": self._service,
|
|
155
|
+
"attempt": self._attempt,
|
|
156
|
+
"delivery_limit": self._delivery_limit,
|
|
157
|
+
"outcome": outcome.value,
|
|
158
|
+
"action": action.value,
|
|
159
|
+
"code": code,
|
|
160
|
+
},
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
async def ack(self, outcome: DeliveryOutcome, *, code: str | None = None, concluded: bool = False) -> None:
|
|
164
|
+
"""Ack the delivery. ``concluded``: a terminal message was published for it first."""
|
|
165
|
+
self.settled = True
|
|
166
|
+
self._log_decision(outcome, SettleAction.CONCLUDE if concluded else SettleAction.ACK, code)
|
|
167
|
+
try:
|
|
168
|
+
await self._delivery.ack()
|
|
169
|
+
except Exception:
|
|
170
|
+
# The result is published, but the broker will redeliver (typically a closed channel after the
|
|
171
|
+
# consumer timeout): the work is redone. Make it loud.
|
|
172
|
+
self._observer.ack_failed()
|
|
173
|
+
_LOGGER.exception(
|
|
174
|
+
"ACK FAILED for task %s: the result is published but the message will be redelivered.",
|
|
175
|
+
self.task_id,
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
async def nack_requeue(self, outcome: DeliveryOutcome, *, code: str | None = None) -> None:
|
|
179
|
+
self.settled = True
|
|
180
|
+
self._log_decision(outcome, SettleAction.NACK_REQUEUE, code)
|
|
181
|
+
try:
|
|
182
|
+
await self._delivery.nack(requeue=True)
|
|
183
|
+
except Exception:
|
|
184
|
+
# Dead channel: the broker redelivers when the channel closes.
|
|
185
|
+
_LOGGER.exception("NACK FAILED for task %s: the broker redelivers when the channel closes.", self.task_id)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def describe_validation_error(error: ValidationError) -> str:
|
|
189
|
+
"""Public text of an input validation error: locations and messages, never the input values."""
|
|
190
|
+
issues = error.errors(include_url=False, include_context=False, include_input=False)
|
|
191
|
+
parts = [
|
|
192
|
+
f"{'.'.join(str(part) for part in issue['loc']) or '<root>'}: {issue['msg']}"
|
|
193
|
+
for issue in issues[:_MAX_REPORTED_ISSUES]
|
|
194
|
+
]
|
|
195
|
+
text = "Invalid task input: " + "; ".join(parts)
|
|
196
|
+
if len(issues) > _MAX_REPORTED_ISSUES:
|
|
197
|
+
text += f"; and {len(issues) - _MAX_REPORTED_ISSUES} more"
|
|
198
|
+
text = " ".join(text.split())
|
|
199
|
+
if len(text) > MAX_MESSAGE_LENGTH:
|
|
200
|
+
text = text[: MAX_MESSAGE_LENGTH - len(_ELLIPSIS)] + _ELLIPSIS
|
|
201
|
+
return text
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
class Engine:
|
|
205
|
+
"""Processes deliveries for one task definition."""
|
|
206
|
+
|
|
207
|
+
def __init__(
|
|
208
|
+
self,
|
|
209
|
+
*,
|
|
210
|
+
definition: TaskDefinition,
|
|
211
|
+
service: str,
|
|
212
|
+
settings: WorkerSettings,
|
|
213
|
+
publisher: Publisher,
|
|
214
|
+
encoder: MessageEncoder | None = None,
|
|
215
|
+
observer: RuntimeObserver | None = None,
|
|
216
|
+
clock: Clock = time.monotonic,
|
|
217
|
+
sleep: Sleep = asyncio.sleep,
|
|
218
|
+
uniform: Uniform = random.uniform,
|
|
219
|
+
) -> None:
|
|
220
|
+
self._definition = definition
|
|
221
|
+
self._service = service
|
|
222
|
+
self._settings = settings
|
|
223
|
+
self._publisher = publisher
|
|
224
|
+
self._encoder = encoder or MessageEncoder()
|
|
225
|
+
self._observer: RuntimeObserver = observer or NoopObserver()
|
|
226
|
+
self._clock = clock
|
|
227
|
+
self._sleep = sleep
|
|
228
|
+
self._uniform = uniform
|
|
229
|
+
self._timeout_s = (
|
|
230
|
+
settings.worker_task_timeout_s if settings.worker_task_timeout_s is not None else definition.timeout_s
|
|
231
|
+
)
|
|
232
|
+
self._slots = asyncio.Semaphore(settings.worker_concurrency)
|
|
233
|
+
self._inflight = InFlightRegistry()
|
|
234
|
+
self._bridge = SyncBridge(settings.worker_concurrency)
|
|
235
|
+
self._drops = ProgressDropLog(self._observer)
|
|
236
|
+
self._shutdown = asyncio.Event()
|
|
237
|
+
self._tasks: set[asyncio.Task[None]] = set()
|
|
238
|
+
self._warned_uncounted = False
|
|
239
|
+
|
|
240
|
+
@property
|
|
241
|
+
def inflight(self) -> InFlightRegistry:
|
|
242
|
+
return self._inflight
|
|
243
|
+
|
|
244
|
+
def tasks(self) -> tuple[asyncio.Task[None], ...]:
|
|
245
|
+
"""Snapshot of the tasks spawned and not finished yet (safe to iterate while they complete)."""
|
|
246
|
+
return tuple(self._tasks)
|
|
247
|
+
|
|
248
|
+
def request_shutdown(self) -> None:
|
|
249
|
+
"""Interrupt the retry backoffs: their deliveries are requeued at once."""
|
|
250
|
+
self._shutdown.set()
|
|
251
|
+
|
|
252
|
+
def close(self) -> None:
|
|
253
|
+
self._bridge.shutdown()
|
|
254
|
+
|
|
255
|
+
def spawn(self, delivery: Delivery) -> asyncio.Task[None]:
|
|
256
|
+
"""Process ``delivery`` in a task the engine owns, so that a drain can wait for it or cancel it."""
|
|
257
|
+
task = asyncio.get_running_loop().create_task(self.process_delivery(delivery))
|
|
258
|
+
self._tasks.add(task)
|
|
259
|
+
task.add_done_callback(self._tasks.discard)
|
|
260
|
+
return task
|
|
261
|
+
|
|
262
|
+
async def process_delivery(self, delivery: Delivery) -> None:
|
|
263
|
+
"""Run one delivery to its single ack or nack. Re-raises cancellation after requeueing."""
|
|
264
|
+
settler = _Settler(delivery, self._observer, self._service, self._settings.worker_delivery_limit)
|
|
265
|
+
try:
|
|
266
|
+
acquired = await self._acquire_slot()
|
|
267
|
+
except BaseException as error:
|
|
268
|
+
# Cancelled while waiting for a slot: the delivery must be requeued too.
|
|
269
|
+
await self._abort(error, settler)
|
|
270
|
+
raise
|
|
271
|
+
if not acquired:
|
|
272
|
+
# A shutdown was requested while this delivery waited for a slot: it must not run during the grace
|
|
273
|
+
# period, the broker hands it to another worker.
|
|
274
|
+
self._observer.shutdown_requeued()
|
|
275
|
+
await asyncio.shield(settler.nack_requeue(DeliveryOutcome.SHUTDOWN_REQUEUED))
|
|
276
|
+
return
|
|
277
|
+
run = _Run()
|
|
278
|
+
try:
|
|
279
|
+
await self._process(delivery, settler, run)
|
|
280
|
+
except BaseException as error:
|
|
281
|
+
await self._abort(error, settler)
|
|
282
|
+
raise
|
|
283
|
+
finally:
|
|
284
|
+
if run.log_token is not None:
|
|
285
|
+
current_task.reset(run.log_token)
|
|
286
|
+
self._release_slot(run)
|
|
287
|
+
|
|
288
|
+
async def _acquire_slot(self) -> bool:
|
|
289
|
+
"""Wait for a concurrency slot. False, with no slot held, when a shutdown is requested first."""
|
|
290
|
+
if self._shutdown.is_set():
|
|
291
|
+
return False
|
|
292
|
+
if not self._slots.locked():
|
|
293
|
+
await self._slots.acquire()
|
|
294
|
+
return True
|
|
295
|
+
acquire = asyncio.ensure_future(self._slots.acquire())
|
|
296
|
+
stop = asyncio.ensure_future(self._shutdown.wait())
|
|
297
|
+
try:
|
|
298
|
+
await asyncio.wait({acquire, stop}, return_when=asyncio.FIRST_COMPLETED)
|
|
299
|
+
except BaseException:
|
|
300
|
+
await self._give_up(acquire)
|
|
301
|
+
raise
|
|
302
|
+
finally:
|
|
303
|
+
stop.cancel()
|
|
304
|
+
if acquire.done() and not self._shutdown.is_set():
|
|
305
|
+
return True
|
|
306
|
+
await self._give_up(acquire)
|
|
307
|
+
return False
|
|
308
|
+
|
|
309
|
+
async def _give_up(self, acquire: asyncio.Task[Literal[True]]) -> None:
|
|
310
|
+
"""Stop waiting for a slot, and hand back the one that was granted at the very same moment, if any."""
|
|
311
|
+
acquire.cancel()
|
|
312
|
+
await asyncio.wait({acquire})
|
|
313
|
+
if not acquire.cancelled():
|
|
314
|
+
self._slots.release()
|
|
315
|
+
|
|
316
|
+
async def _abort(self, error: BaseException, settler: _Settler) -> None:
|
|
317
|
+
"""Cancellation (shutdown after the grace period) or a bug: the delivery must not stay pending."""
|
|
318
|
+
cancelled = isinstance(error, asyncio.CancelledError)
|
|
319
|
+
if not cancelled:
|
|
320
|
+
_LOGGER.exception("Unexpected error escaped the engine for task %s.", settler.task_id)
|
|
321
|
+
if not settler.settled:
|
|
322
|
+
if cancelled:
|
|
323
|
+
self._observer.shutdown_requeued()
|
|
324
|
+
outcome = DeliveryOutcome.CANCELLED if cancelled else DeliveryOutcome.ENGINE_ERROR
|
|
325
|
+
await asyncio.shield(settler.nack_requeue(outcome))
|
|
326
|
+
|
|
327
|
+
def _release_slot(self, run: _Run) -> None:
|
|
328
|
+
"""Free the concurrency slot. A sync handler that outlived its task keeps it until its thread returns."""
|
|
329
|
+
|
|
330
|
+
def release(_: object = None) -> None:
|
|
331
|
+
if run.entry is not None:
|
|
332
|
+
self._inflight.unregister(run.entry)
|
|
333
|
+
self._slots.release()
|
|
334
|
+
|
|
335
|
+
if run.thread_future is not None and not run.thread_future.done():
|
|
336
|
+
run.thread_future.add_done_callback(release)
|
|
337
|
+
else:
|
|
338
|
+
release()
|
|
339
|
+
|
|
340
|
+
async def _process(self, delivery: Delivery, settler: _Settler, run: _Run) -> None:
|
|
341
|
+
received = time.monotonic()
|
|
342
|
+
decoded = decode_envelope(delivery.body)
|
|
343
|
+
if isinstance(decoded, PoisonDelivery):
|
|
344
|
+
settler.task_id = decoded.task_id
|
|
345
|
+
_LOGGER.error(
|
|
346
|
+
"Poison delivery (%s), task_id=%s, body_bytes=%d, body_sha256=%s",
|
|
347
|
+
decoded.reason,
|
|
348
|
+
decoded.task_id,
|
|
349
|
+
len(delivery.body),
|
|
350
|
+
hashlib.sha256(delivery.body).hexdigest()[:_BODY_DIGEST_HEX_LENGTH],
|
|
351
|
+
)
|
|
352
|
+
self._observer.poison(decoded.reason)
|
|
353
|
+
await settler.ack(DeliveryOutcome.POISON, code=decoded.reason.value)
|
|
354
|
+
return
|
|
355
|
+
|
|
356
|
+
task_id = decoded.task_id
|
|
357
|
+
settler.task_id = task_id
|
|
358
|
+
attempt = attempt_of(delivery)
|
|
359
|
+
if attempt > self._settings.worker_delivery_limit:
|
|
360
|
+
await self._conclude(settler, self._failure(task_id, ErrorCode.TASK_ABANDONED_AFTER_REDELIVERIES), received)
|
|
361
|
+
return
|
|
362
|
+
if not await self._publish(self._encoder.started(task_id), MessageType.STARTED):
|
|
363
|
+
await settler.nack_requeue(DeliveryOutcome.PUBLISH_FAILED)
|
|
364
|
+
return
|
|
365
|
+
|
|
366
|
+
run.log_token = current_task.set(TaskLogContext(task_id, self._service, attempt))
|
|
367
|
+
deadline = received + self._timeout_s
|
|
368
|
+
run.entry = self._inflight.register(task_id, deadline, is_sync=not self._definition.is_async)
|
|
369
|
+
log = logging.LoggerAdapter(_LOGGER, {"task_id": task_id, "service": self._service, "attempt": attempt})
|
|
370
|
+
reporter = ProgressReporter(
|
|
371
|
+
task_id=task_id,
|
|
372
|
+
encoder=self._encoder,
|
|
373
|
+
publish=self._publish_progress,
|
|
374
|
+
min_interval_s=self._settings.worker_progress_min_interval_s,
|
|
375
|
+
drops=self._drops,
|
|
376
|
+
clock=self._clock,
|
|
377
|
+
sleep=self._sleep,
|
|
378
|
+
)
|
|
379
|
+
try:
|
|
380
|
+
# A first delivery legitimately carries no counter. A redelivery without one (a classic queue) cannot be
|
|
381
|
+
# counted, so a retry could never be bounded.
|
|
382
|
+
counted = delivery.delivery_count is not None or not delivery.redelivered
|
|
383
|
+
verdict = await self._run(_Attempt(decoded, attempt, deadline, reporter, log, run, counted))
|
|
384
|
+
finally:
|
|
385
|
+
# Before any terminal message: nothing is published as progress after it.
|
|
386
|
+
await reporter.close()
|
|
387
|
+
|
|
388
|
+
if isinstance(verdict, _Retry):
|
|
389
|
+
self._observer.task_retried()
|
|
390
|
+
delay = backoff_seconds(
|
|
391
|
+
attempt,
|
|
392
|
+
base_s=self._settings.worker_retry_backoff_base_s,
|
|
393
|
+
max_s=self._settings.worker_retry_backoff_max_s,
|
|
394
|
+
uniform=self._uniform,
|
|
395
|
+
)
|
|
396
|
+
await self._backoff(delay)
|
|
397
|
+
await settler.nack_requeue(DeliveryOutcome.RETRY, code=verdict.error.code)
|
|
398
|
+
return
|
|
399
|
+
await self._conclude(settler, verdict, received)
|
|
400
|
+
|
|
401
|
+
async def _conclude(self, settler: _Settler, terminal: _Terminal, received: float) -> None:
|
|
402
|
+
"""Publish the terminal message, then ack. A failed publish requeues the delivery instead."""
|
|
403
|
+
message_type = MessageType.SUCCESS if terminal.code is None else MessageType.FAILURE
|
|
404
|
+
if not await self._publish(terminal.body, message_type):
|
|
405
|
+
await settler.nack_requeue(DeliveryOutcome.PUBLISH_FAILED, code=terminal.code)
|
|
406
|
+
return
|
|
407
|
+
outcome = DeliveryOutcome.SUCCESS if terminal.code is None else DeliveryOutcome.FAILURE
|
|
408
|
+
await settler.ack(outcome, code=terminal.code, concluded=True)
|
|
409
|
+
status = TaskStatus.SUCCESS if terminal.code is None else TaskStatus.FAILURE
|
|
410
|
+
self._observer.task_concluded(status, terminal.code, time.monotonic() - received)
|
|
411
|
+
|
|
412
|
+
async def _run(self, attempt: _Attempt) -> _Terminal | _Retry:
|
|
413
|
+
try:
|
|
414
|
+
task_input = self._definition.input_model.model_validate(attempt.envelope.data.body)
|
|
415
|
+
except ValidationError as error:
|
|
416
|
+
return self._failure(attempt.task_id, ErrorCode.INPUT_INVALID, describe_validation_error(error))
|
|
417
|
+
try:
|
|
418
|
+
result = await self._invoke(attempt, task_input)
|
|
419
|
+
except asyncio.CancelledError as error:
|
|
420
|
+
task = asyncio.current_task()
|
|
421
|
+
if task is not None and task.cancelling() > 0:
|
|
422
|
+
raise # a real cancellation (shutdown): the delivery is requeued by the caller
|
|
423
|
+
# The handler's own code was cancelled (a cancelled child task or future): it is its failure, and a
|
|
424
|
+
# redelivery would fail the same way.
|
|
425
|
+
return self._on_error(attempt, error)
|
|
426
|
+
except Exception as error:
|
|
427
|
+
return self._on_error(attempt, error)
|
|
428
|
+
return self._success(attempt, result)
|
|
429
|
+
|
|
430
|
+
def _on_error(self, attempt: _Attempt, error: BaseException) -> _Terminal | _Retry:
|
|
431
|
+
"""Map what the handler (or the deadline) raised to the message the client gets."""
|
|
432
|
+
log, task_id = attempt.log, attempt.task_id
|
|
433
|
+
if isinstance(error, _DeadlineExceededError):
|
|
434
|
+
log.error("Task exceeded its time limit of %s s.", self._timeout_s)
|
|
435
|
+
message = f"The task exceeded its time limit of {self._timeout_s:g} s."
|
|
436
|
+
return self._failure(task_id, ErrorCode.TASK_TIMEOUT, message)
|
|
437
|
+
if isinstance(error, RetryableError):
|
|
438
|
+
if not attempt.counted:
|
|
439
|
+
self._warn_uncounted()
|
|
440
|
+
log.warning("Retryable error %s on an uncounted redelivery, concluding the task.", error.code)
|
|
441
|
+
return self._failure(task_id, error)
|
|
442
|
+
if attempt.number >= self._settings.worker_delivery_limit:
|
|
443
|
+
log.warning("Retryable error on the last attempt, concluding with %s.", error.code)
|
|
444
|
+
return self._failure(task_id, error)
|
|
445
|
+
log.warning("Retryable error %s, requeueing after a backoff.", error.code)
|
|
446
|
+
return _Retry(error)
|
|
447
|
+
if isinstance(error, WorkerError):
|
|
448
|
+
log.warning("Task failed with %s.", error.code)
|
|
449
|
+
return self._failure(task_id, error)
|
|
450
|
+
# ``str(error)`` is never published: it could leak anything. The traceback stays in the logs.
|
|
451
|
+
log.error("Unexpected error while processing the task.", exc_info=error)
|
|
452
|
+
return self._failure(task_id, ErrorCode.INTERNAL_ERROR)
|
|
453
|
+
|
|
454
|
+
def _warn_uncounted(self) -> None:
|
|
455
|
+
"""Once per process: the queue gives no delivery counter, so retries cannot be bounded."""
|
|
456
|
+
if self._warned_uncounted:
|
|
457
|
+
return
|
|
458
|
+
self._warned_uncounted = True
|
|
459
|
+
_LOGGER.warning(
|
|
460
|
+
"The input queue gives no delivery counter (it is not a quorum queue): a RetryableError concludes the "
|
|
461
|
+
"task on its first redelivery, and a crash loop is not bounded. Retries and crash-loop protection need "
|
|
462
|
+
"quorum queues.",
|
|
463
|
+
)
|
|
464
|
+
|
|
465
|
+
async def _invoke(self, attempt: _Attempt, task_input: BaseModel) -> BaseModel:
|
|
466
|
+
remaining = max(0.0, attempt.deadline - time.monotonic())
|
|
467
|
+
context = attempt.envelope.data.context
|
|
468
|
+
client_id = context.client_id if context else None
|
|
469
|
+
inputs: Mapping[str, InputRef] = context.input_refs if context else {}
|
|
470
|
+
identity = (attempt.task_id, client_id, attempt.number, attempt.deadline, attempt.log, inputs)
|
|
471
|
+
|
|
472
|
+
if self._definition.is_async:
|
|
473
|
+
handler = cast("AsyncHandler", self._definition.handler)
|
|
474
|
+
timeout = asyncio.timeout(remaining)
|
|
475
|
+
try:
|
|
476
|
+
async with timeout:
|
|
477
|
+
return await handler(task_input, TaskContext(*identity, attempt.reporter.report))
|
|
478
|
+
except TimeoutError:
|
|
479
|
+
if timeout.expired():
|
|
480
|
+
raise _DeadlineExceededError from None
|
|
481
|
+
raise
|
|
482
|
+
|
|
483
|
+
loop = asyncio.get_running_loop()
|
|
484
|
+
cancel_event = threading.Event()
|
|
485
|
+
progress = make_threadsafe_progress(loop, attempt.reporter)
|
|
486
|
+
sync_handler = cast("SyncHandler", self._definition.handler)
|
|
487
|
+
call = partial(sync_handler, task_input, SyncTaskContext(*identity, progress, cancel_event))
|
|
488
|
+
future = self._bridge.submit(loop, call)
|
|
489
|
+
attempt.run.thread_future = future
|
|
490
|
+
# A thread that outlives the task must not leave an unretrieved exception behind.
|
|
491
|
+
future.add_done_callback(lambda done: done.cancelled() or done.exception())
|
|
492
|
+
try:
|
|
493
|
+
return await asyncio.wait_for(asyncio.shield(future), remaining)
|
|
494
|
+
except TimeoutError:
|
|
495
|
+
if future.done():
|
|
496
|
+
# Finished at the same instant as the deadline (or raised a TimeoutError itself): the outcome of the
|
|
497
|
+
# thread wins, not the timer.
|
|
498
|
+
return future.result()
|
|
499
|
+
cancel_event.set()
|
|
500
|
+
raise _DeadlineExceededError from None
|
|
501
|
+
except asyncio.CancelledError:
|
|
502
|
+
cancel_event.set()
|
|
503
|
+
raise
|
|
504
|
+
|
|
505
|
+
def _success(self, attempt: _Attempt, result: BaseModel) -> _Terminal:
|
|
506
|
+
expected = self._definition.output_model
|
|
507
|
+
if not isinstance(result, expected):
|
|
508
|
+
attempt.log.error("The handler returned %s, expected %s.", type(result).__name__, expected.__name__)
|
|
509
|
+
return self._failure(attempt.task_id, ErrorCode.OUTPUT_INVALID)
|
|
510
|
+
try:
|
|
511
|
+
return _Terminal(self._encoder.success(attempt.task_id, result.model_dump(mode="json")), None)
|
|
512
|
+
except Exception:
|
|
513
|
+
attempt.log.exception("The handler output cannot be serialized.")
|
|
514
|
+
return self._failure(attempt.task_id, ErrorCode.OUTPUT_INVALID)
|
|
515
|
+
|
|
516
|
+
def _failure(self, task_id: str, error: WorkerError | ErrorCode, message: str | None = None) -> _Terminal:
|
|
517
|
+
code = error.code if isinstance(error, WorkerError) else error.value
|
|
518
|
+
return _Terminal(self._encoder.failure(task_id, error, message), code)
|
|
519
|
+
|
|
520
|
+
async def _publish(self, body: bytes, message_type: MessageType) -> bool:
|
|
521
|
+
try:
|
|
522
|
+
await self._publisher.publish(body)
|
|
523
|
+
except Exception:
|
|
524
|
+
self._observer.publish_failed(message_type)
|
|
525
|
+
_LOGGER.exception("Publishing a %s message failed.", message_type)
|
|
526
|
+
return False
|
|
527
|
+
self._observer.message_published(message_type)
|
|
528
|
+
return True
|
|
529
|
+
|
|
530
|
+
async def _publish_progress(self, body: bytes) -> None:
|
|
531
|
+
"""Progress is best effort: a failed publish is counted and logged, never raised."""
|
|
532
|
+
await self._publish(body, MessageType.PROGRESS)
|
|
533
|
+
|
|
534
|
+
async def _backoff(self, delay_s: float) -> None:
|
|
535
|
+
"""Wait ``delay_s``, or until a shutdown is requested."""
|
|
536
|
+
sleeper = asyncio.ensure_future(self._sleep(delay_s))
|
|
537
|
+
waiter = asyncio.ensure_future(self._shutdown.wait())
|
|
538
|
+
try:
|
|
539
|
+
await asyncio.wait({sleeper, waiter}, return_when=asyncio.FIRST_COMPLETED)
|
|
540
|
+
finally:
|
|
541
|
+
for pending in (sleeper, waiter):
|
|
542
|
+
pending.cancel()
|
|
543
|
+
await asyncio.wait({sleeper, waiter})
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Registry of the tasks being processed: what a drain waits for and what overrun detection looks at."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterator
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from itertools import count
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True, slots=True)
|
|
9
|
+
class InFlightTask:
|
|
10
|
+
token: int
|
|
11
|
+
task_id: str
|
|
12
|
+
deadline: float
|
|
13
|
+
is_sync: bool
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(slots=True)
|
|
17
|
+
class InFlightRegistry:
|
|
18
|
+
_entries: dict[int, InFlightTask] = field(default_factory=dict)
|
|
19
|
+
_tokens: Iterator[int] = field(default_factory=count)
|
|
20
|
+
|
|
21
|
+
def register(self, task_id: str, deadline: float, *, is_sync: bool) -> InFlightTask:
|
|
22
|
+
entry = InFlightTask(next(self._tokens), task_id, deadline, is_sync)
|
|
23
|
+
self._entries[entry.token] = entry
|
|
24
|
+
return entry
|
|
25
|
+
|
|
26
|
+
def unregister(self, entry: InFlightTask) -> None:
|
|
27
|
+
self._entries.pop(entry.token, None)
|
|
28
|
+
|
|
29
|
+
def snapshot(self) -> tuple[InFlightTask, ...]:
|
|
30
|
+
"""A copy: callers may iterate it while tasks come and go."""
|
|
31
|
+
return tuple(self._entries.values())
|
|
32
|
+
|
|
33
|
+
def __len__(self) -> int:
|
|
34
|
+
return len(self._entries)
|
|
35
|
+
|
|
36
|
+
def overrun(self, now: float, grace_s: float) -> tuple[InFlightTask, ...]:
|
|
37
|
+
"""Tasks still registered ``grace_s`` after their deadline (a sync handler that never returns)."""
|
|
38
|
+
return tuple(entry for entry in self._entries.values() if now > entry.deadline + grace_s)
|