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