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,463 @@
1
+ """Process lifecycle: startup checks, signals, graceful drain and shutdown.
2
+
3
+ Start: validate the settings and the time budget, run the startup hooks, connect (retrying with the reconnect
4
+ interval while the broker is unreachable), consume.
5
+
6
+ SIGTERM or SIGINT (first): stop consuming (``basic.cancel``), interrupt the retry backoffs, give the in-flight tasks
7
+ ``WORKER_SHUTDOWN_GRACE_S`` to finish (they publish and ack normally), then cancel the rest, which requeues their
8
+ deliveries, close the broker and run the shutdown hooks in reverse order. A second signal skips what is left of the
9
+ grace period.
10
+
11
+ Job mode (``WORKER_MODE=job``) runs the same steps for a one-shot process: the prefetch is 1, consuming stops at the
12
+ ``WORKER_JOB_MAX_TASKS``-th delivery (so the broker hands nothing more), and the process drains and exits 0 once those
13
+ deliveries are settled, or when nothing arrives for ``WORKER_JOB_IDLE_TIMEOUT_S``. A broker that stays unreachable for
14
+ ``WORKER_JOB_CONNECT_TIMEOUT_S`` ends the process with an error (exit 1) instead of retrying forever. Every outcome of
15
+ a delivery (success, failure, requeue) is a clean exit: the exit code tells the Job controller whether the pod crashed.
16
+ """
17
+
18
+ import asyncio
19
+ import contextlib
20
+ import logging
21
+ import os
22
+ import signal
23
+ import sys
24
+ import threading
25
+ import time
26
+ from collections.abc import Awaitable, Callable
27
+ from concurrent.futures import ThreadPoolExecutor
28
+ from enum import Enum
29
+ from typing import TYPE_CHECKING, Final, NoReturn
30
+
31
+ from async_task_api_worker._runtime.engine import Engine
32
+ from async_task_api_worker._runtime.transport import CLOSE_TIMEOUT_S, Delivery, Transport, TransportUnavailableError
33
+ from async_task_api_worker.errors import WorkerConfigurationError, WorkerDefinitionError
34
+ from async_task_api_worker.observability.health import HealthServer, RuntimeState
35
+ from async_task_api_worker.observability.logging import configure_logging
36
+ from async_task_api_worker.observability.loop_lag import EventLoopLagProbe
37
+ from async_task_api_worker.observability.metrics import WorkerMetrics
38
+ from async_task_api_worker.settings import LogLevel, WorkerSettings
39
+
40
+ if TYPE_CHECKING:
41
+ from async_task_api_worker.worker import Worker
42
+
43
+ _LOGGER: Final[logging.Logger] = logging.getLogger("async_task_api_worker")
44
+
45
+ EXIT_OK: Final[int] = 0
46
+ EXIT_FAILURE: Final[int] = 1
47
+ EXIT_CONFIGURATION: Final[int] = 2
48
+ # What the broker's consumer timeout must exceed, on top of the task timeout and the longest retry backoff: the time
49
+ # to publish the result and ack.
50
+ ACK_SAFETY_MARGIN_S: Final[float] = 60.0
51
+ # A nack on a half-open connection can block until the heartbeat timeout: do not wait longer than this for them.
52
+ CANCEL_NACK_TIMEOUT_S: Final[float] = 5.0
53
+ # The threads of ``asyncio.to_thread`` and ``run_in_executor(None, ...)``: the loop's default executor.
54
+ DEFAULT_EXECUTOR_THREAD_PREFIX: Final[str] = "async-task-api-worker-default"
55
+ # How long the exit waits for them: ``asyncio.run`` would wait 300 s.
56
+ EXECUTOR_JOIN_TIMEOUT_S: Final[float] = 2.0
57
+ _STOP_SIGNALS: Final[tuple[signal.Signals, ...]] = (signal.SIGTERM, signal.SIGINT)
58
+
59
+
60
+ class LifecycleState(Enum):
61
+ STARTING = "starting"
62
+ READY = "ready"
63
+ DRAINING = "draining"
64
+ STOPPED = "stopped"
65
+
66
+
67
+ def check_time_budget(settings: WorkerSettings, task_timeout_s: float) -> None:
68
+ """The broker takes a delivery back after its consumer timeout: a task must always conclude before that."""
69
+ needed = task_timeout_s + settings.worker_retry_backoff_max_s + ACK_SAFETY_MARGIN_S
70
+ if needed >= settings.worker_consumer_timeout_s:
71
+ msg = (
72
+ f"The time budget does not fit: task timeout {task_timeout_s:g} s + retry backoff "
73
+ f"{settings.worker_retry_backoff_max_s:g} s + safety margin {ACK_SAFETY_MARGIN_S:g} s = {needed:g} s "
74
+ f"must stay under the broker consumer timeout WORKER_CONSUMER_TIMEOUT_S="
75
+ f"{settings.worker_consumer_timeout_s:g} s"
76
+ )
77
+ raise WorkerConfigurationError(msg)
78
+
79
+
80
+ class Lifecycle:
81
+ """Runs one worker on one transport until it is asked to stop."""
82
+
83
+ def __init__(
84
+ self,
85
+ worker: "Worker",
86
+ transport: Transport,
87
+ *,
88
+ install_signals: bool = True,
89
+ cancel_timeout_s: float = CANCEL_NACK_TIMEOUT_S,
90
+ close_timeout_s: float = CLOSE_TIMEOUT_S,
91
+ serve_probes: bool = False,
92
+ ) -> None:
93
+ self._worker = worker
94
+ self._transport = transport
95
+ self._install_signals = install_signals
96
+ self._cancel_timeout_s = cancel_timeout_s
97
+ self._close_timeout_s = close_timeout_s
98
+ self._serve_probes = serve_probes
99
+ self.metrics = WorkerMetrics()
100
+ self.overrunning: tuple[str, ...] = ()
101
+ self._stop = asyncio.Event()
102
+ self._skip_grace = asyncio.Event()
103
+ self._activity = asyncio.Event()
104
+ self._accepted = 0
105
+ self._stop_consuming_task: asyncio.Task[None] | None = None
106
+ self.state = LifecycleState.STARTING
107
+ self._engine: Engine | None = None
108
+
109
+ def request_stop(self) -> None:
110
+ """First call: drain. Any later call: skip what is left of the grace period."""
111
+ if self._stop.is_set():
112
+ self._skip_grace.set()
113
+ self._stop.set()
114
+
115
+ async def run(self) -> None:
116
+ """Start, serve until a stop is requested, drain, close. Raises on a configuration or startup failure."""
117
+ settings = self._worker.settings
118
+ definition = self._worker._require_definition()
119
+ timeout_s = (
120
+ settings.worker_task_timeout_s if settings.worker_task_timeout_s is not None else definition.timeout_s
121
+ )
122
+ check_time_budget(settings, timeout_s)
123
+
124
+ engine = self._worker._create_engine(self._transport.publisher, observer=self.metrics.observer)
125
+ self._engine = engine
126
+ self.metrics.track_in_flight(lambda: len(engine.inflight))
127
+ health = self._health_server(engine, settings)
128
+ loop_lag = EventLoopLagProbe(self.metrics.loop_lag.observe)
129
+ started_hooks = 0
130
+ loop = asyncio.get_running_loop()
131
+ handled = self._add_signal_handlers(loop)
132
+ try:
133
+ # Served from the very start, in both modes: ``/health`` answers while the startup hooks run and ``/ready``
134
+ # stays 503 until the broker is connected. A ScaledJob counts a pod only once it is Ready, so job mode
135
+ # keeps it.
136
+ if health is not None:
137
+ await health.start()
138
+ loop_lag.start()
139
+ for hook in self._worker._startup_hooks:
140
+ if not await self._until_stopped(hook()):
141
+ break # stopped during startup: nothing was consumed, drain and exit
142
+ started_hooks += 1
143
+ if started_hooks == len(self._worker._startup_hooks) and await self._connect(settings):
144
+ self.state = LifecycleState.READY
145
+ _LOGGER.info("Worker %s is consuming %s.", self._worker.service, settings.in_queue_name)
146
+ await (self._wait_job_done(engine, settings) if settings.is_job else self._stop.wait())
147
+ finally:
148
+ self.state = LifecycleState.DRAINING
149
+ try:
150
+ await self._drain(engine, settings)
151
+ finally:
152
+ for signum in handled:
153
+ loop.remove_signal_handler(signum)
154
+ await self._run_shutdown_hooks(started_hooks)
155
+ self.state = LifecycleState.STOPPED
156
+ await loop_lag.stop()
157
+ if health is not None:
158
+ await health.stop()
159
+
160
+ def _health_server(self, engine: Engine, settings: WorkerSettings) -> HealthServer | None:
161
+ if not self._serve_probes:
162
+ return None
163
+ state = RuntimeState(
164
+ ping_broker=self._transport.ping,
165
+ # Not ready once it no longer takes deliveries: shutting down, or a job whose quota is reached.
166
+ is_draining=lambda: (
167
+ self._stop.is_set()
168
+ or self._stop_consuming_task is not None
169
+ or self.state in {LifecycleState.DRAINING, LifecycleState.STOPPED}
170
+ ),
171
+ overrunning=lambda: tuple(
172
+ entry.task_id
173
+ for entry in engine.inflight.overrun(time.monotonic(), settings.worker_task_overrun_grace_s)
174
+ ),
175
+ metrics=self.metrics.exposition,
176
+ )
177
+ return HealthServer(
178
+ host=settings.health_check_host,
179
+ port=settings.health_check_port,
180
+ state=state,
181
+ probes=self._worker._probes,
182
+ probe_timeout_s=settings.worker_probe_timeout_s,
183
+ )
184
+
185
+ def _add_signal_handlers(self, loop: asyncio.AbstractEventLoop) -> list[signal.Signals]:
186
+ handled: list[signal.Signals] = []
187
+ if not self._install_signals:
188
+ return handled
189
+ for signum in _STOP_SIGNALS:
190
+ try:
191
+ loop.add_signal_handler(signum, self.request_stop)
192
+ except (NotImplementedError, RuntimeError, ValueError):
193
+ _LOGGER.warning("Cannot install a handler for %s: not in the main thread.", signum.name)
194
+ else:
195
+ handled.append(signum)
196
+ return handled
197
+
198
+ async def _connect(self, settings: WorkerSettings) -> bool:
199
+ """Start the transport, retrying while the broker is unreachable. False when a stop came first.
200
+
201
+ A job gives up after ``WORKER_JOB_CONNECT_TIMEOUT_S`` and re-raises the last error: the pod must fail, not wait.
202
+ """
203
+ engine = self._engine
204
+ assert engine is not None
205
+ loop = asyncio.get_running_loop()
206
+ give_up_at = loop.time() + settings.worker_job_connect_timeout_s
207
+ while not self._stop.is_set():
208
+ # Bound the attempt itself in job mode: a host that drops packets would otherwise hold it for the AMQP
209
+ # client's own connection timeout, far past the budget.
210
+ attempt_timeout_s = max(give_up_at - loop.time(), 0.0) if settings.is_job else None
211
+ try:
212
+ async with asyncio.timeout(attempt_timeout_s):
213
+ if not await self._until_stopped(self._transport.start(self._on_delivery)):
214
+ return False
215
+ except (TimeoutError, TransportUnavailableError) as error:
216
+ if settings.is_job and loop.time() >= give_up_at:
217
+ limit = f"WORKER_JOB_CONNECT_TIMEOUT_S={settings.worker_job_connect_timeout_s:g} s"
218
+ msg = f"The broker could not be reached within {limit}"
219
+ raise TransportUnavailableError(msg) from error
220
+ _LOGGER.warning(
221
+ "The broker is unreachable, retrying in %s s.",
222
+ settings.worker_broker_reconnect_interval_s,
223
+ )
224
+ await self._wait_stop(settings.worker_broker_reconnect_interval_s)
225
+ else:
226
+ return True
227
+ return False
228
+
229
+ async def _until_stopped(self, work: Awaitable[object]) -> bool:
230
+ """Run ``work``, cancelling it if a stop is requested first. False when it was cancelled by the stop.
231
+
232
+ A SIGTERM during startup must not wait for a connection attempt (up to the AMQP client's own timeout) or a slow
233
+ startup hook: nothing was consumed yet, so the worker has nothing to drain. A hook that blocks the event loop
234
+ itself cannot be interrupted.
235
+ """
236
+ task = asyncio.ensure_future(work)
237
+ stop = asyncio.ensure_future(self._stop.wait())
238
+ try:
239
+ await asyncio.wait({task, stop}, return_when=asyncio.FIRST_COMPLETED)
240
+ finally:
241
+ stop.cancel()
242
+ if not task.done():
243
+ task.cancel()
244
+ await asyncio.wait({task})
245
+ if task.cancelled():
246
+ return False
247
+ task.result()
248
+ return True
249
+
250
+ def _on_delivery(self, delivery: Delivery) -> None:
251
+ assert self._engine is not None
252
+ self._accepted += 1
253
+ if self._worker.settings.is_job and self._accepted >= self._worker.settings.worker_job_max_tasks:
254
+ # The cancel is scheduled before the engine task, so it goes out before this delivery can be acked: with a
255
+ # prefetch of 1, that ack is what would let the broker hand out one more message.
256
+ self._begin_stop_consuming()
257
+ task = self._engine.spawn(delivery)
258
+ task.add_done_callback(lambda _: self._activity.set())
259
+ self._activity.set()
260
+
261
+ async def _wait_job_done(self, engine: Engine, settings: WorkerSettings) -> None:
262
+ """Return when the job is over: quota settled, idle for too long, or a stop was requested."""
263
+ while not self._stop.is_set():
264
+ # Cleared first: a wake-up that happens during the cancel below must not be lost.
265
+ self._activity.clear()
266
+ quota_reached = self._accepted >= settings.worker_job_max_tasks
267
+ if quota_reached:
268
+ await self._stop_consuming_once()
269
+ busy = bool(engine.tasks())
270
+ if quota_reached and not busy:
271
+ _LOGGER.info("Job done: %d delivery(ies) settled.", self._accepted)
272
+ return
273
+ # Only a worker with nothing in hand and quota left can be idle.
274
+ idle_timeout_s = None if busy or quota_reached else settings.worker_job_idle_timeout_s
275
+ if not await self._wait_activity(idle_timeout_s) and not (self._activity.is_set() or engine.tasks()):
276
+ _LOGGER.info("Job idle for %s s: no delivery, exiting.", settings.worker_job_idle_timeout_s)
277
+ return
278
+
279
+ async def _wait_activity(self, timeout_s: float | None) -> bool:
280
+ """Wait for an activity or a stop. False when ``timeout_s`` elapsed first."""
281
+ activity = asyncio.ensure_future(self._activity.wait())
282
+ stop = asyncio.ensure_future(self._stop.wait())
283
+ try:
284
+ done, _ = await asyncio.wait({activity, stop}, timeout=timeout_s, return_when=asyncio.FIRST_COMPLETED)
285
+ finally:
286
+ activity.cancel()
287
+ stop.cancel()
288
+ return bool(done)
289
+
290
+ def _begin_stop_consuming(self) -> asyncio.Task[None]:
291
+ if self._stop_consuming_task is None:
292
+ self._stop_consuming_task = asyncio.ensure_future(self._transport.stop_consuming())
293
+ return self._stop_consuming_task
294
+
295
+ async def _stop_consuming_once(self) -> None:
296
+ await self._begin_stop_consuming()
297
+
298
+ async def _wait_stop(self, seconds: float) -> None:
299
+ try:
300
+ await asyncio.wait_for(self._stop.wait(), seconds)
301
+ except TimeoutError:
302
+ return
303
+
304
+ async def _drain(self, engine: Engine, settings: WorkerSettings) -> None:
305
+ loop = asyncio.get_running_loop()
306
+ # The grace starts at the stop: the consumer cancel, which can wait for a reconnect, is part of it.
307
+ deadline = loop.time() + settings.worker_shutdown_grace_s
308
+ try:
309
+ await self._stop_consuming_once()
310
+ except Exception:
311
+ # The in-flight tasks still get their grace period, and the transport is still closed.
312
+ _LOGGER.exception("Stopping the consumer failed, draining anyway.")
313
+ engine.request_shutdown()
314
+ while pending := engine.tasks():
315
+ remaining = deadline - loop.time()
316
+ if remaining <= 0 or self._skip_grace.is_set():
317
+ break
318
+ skip = asyncio.ensure_future(self._skip_grace.wait())
319
+ try:
320
+ await asyncio.wait({*pending, skip}, timeout=remaining, return_when=asyncio.FIRST_COMPLETED)
321
+ finally:
322
+ skip.cancel()
323
+ leftover = engine.tasks()
324
+ if leftover:
325
+ _LOGGER.warning(
326
+ "%d task(s) did not finish within the grace period: requeueing their deliveries.",
327
+ len(leftover),
328
+ )
329
+ for task in leftover:
330
+ task.cancel()
331
+ _, unsettled = await asyncio.wait(leftover, timeout=self._cancel_timeout_s)
332
+ if unsettled:
333
+ _LOGGER.error(
334
+ "%d delivery(ies) could not be requeued within %s s: the broker redelivers them when the "
335
+ "connection closes.",
336
+ len(unsettled),
337
+ self._cancel_timeout_s,
338
+ )
339
+ await self._close_transport()
340
+ engine.close()
341
+ # Sync handlers whose thread is still running keep their registry entry: the process cannot join them.
342
+ self.overrunning = tuple(entry.task_id for entry in engine.inflight.snapshot())
343
+
344
+ async def _close_transport(self) -> None:
345
+ try:
346
+ async with asyncio.timeout(self._close_timeout_s):
347
+ await self._transport.close()
348
+ except TimeoutError:
349
+ _LOGGER.error("Closing the broker connection took more than %s s: giving up.", self._close_timeout_s)
350
+ except Exception:
351
+ _LOGGER.exception("Closing the broker connection failed.")
352
+
353
+ async def _run_shutdown_hooks(self, started_hooks: int) -> None:
354
+ """Run the shutdown hooks in reverse order, within one budget for all of them.
355
+
356
+ Each hook gets what is left of ``WORKER_SHUTDOWN_HOOKS_TIMEOUT_S``; one that runs out of it is cancelled,
357
+ logged, and the next one goes on. A per-hook timeout would make the bound depend on how many hooks there are.
358
+ A hook that is out of budget before it starts is skipped. A hook that blocks the event loop, or swallows the
359
+ cancellation, cannot be bounded.
360
+ """
361
+ if started_hooks < len(self._worker._startup_hooks):
362
+ return
363
+ loop = asyncio.get_running_loop()
364
+ deadline = loop.time() + self._worker.settings.worker_shutdown_hooks_timeout_s
365
+ for hook in reversed(self._worker._shutdown_hooks):
366
+ if loop.time() >= deadline:
367
+ _LOGGER.error("A shutdown hook was skipped: the shutdown hooks budget is spent.")
368
+ continue
369
+ try:
370
+ async with asyncio.timeout_at(deadline) as budget:
371
+ await hook()
372
+ except TimeoutError:
373
+ if budget.expired():
374
+ _LOGGER.error("A shutdown hook ran out of the shutdown hooks budget and was cancelled.")
375
+ else:
376
+ _LOGGER.exception("A shutdown hook failed.") # its own TimeoutError
377
+ except Exception:
378
+ _LOGGER.exception("A shutdown hook failed.")
379
+
380
+
381
+ def _close_loop(loop: asyncio.AbstractEventLoop, executor: ThreadPoolExecutor, join_timeout_s: float) -> list[str]:
382
+ """Close the loop like ``asyncio.run`` would, but never wait more than ``join_timeout_s`` in all.
383
+
384
+ Returns what is still running after that: the names of the default-executor threads (an ``async def`` handler
385
+ cancelled while it waits in ``asyncio.to_thread`` leaves its thread behind, and ``asyncio.run`` would join it for up
386
+ to 300 s), and the tasks that ignored their cancellation.
387
+ """
388
+ deadline = time.monotonic() + join_timeout_s
389
+ stuck: list[str] = []
390
+ try:
391
+ pending = [task for task in asyncio.all_tasks(loop) if not task.done()]
392
+ for task in pending:
393
+ task.cancel()
394
+ if pending:
395
+ _, ignoring = loop.run_until_complete(asyncio.wait(pending, timeout=join_timeout_s))
396
+ stuck.extend(f"task {task.get_name()}" for task in ignoring)
397
+ with contextlib.suppress(TimeoutError):
398
+ loop.run_until_complete(
399
+ asyncio.wait_for(loop.shutdown_asyncgens(), max(deadline - time.monotonic(), 0.0)),
400
+ )
401
+ finally:
402
+ asyncio.set_event_loop(None)
403
+ loop.close()
404
+ executor.shutdown(wait=False, cancel_futures=True)
405
+ for thread in threading.enumerate():
406
+ if thread.name.startswith(DEFAULT_EXECUTOR_THREAD_PREFIX):
407
+ thread.join(max(deadline - time.monotonic(), 0.0))
408
+ if thread.is_alive():
409
+ stuck.append(thread.name)
410
+ return stuck
411
+
412
+
413
+ def _leave(reason: str, exit_now: Callable[[int], NoReturn]) -> NoReturn:
414
+ _LOGGER.error(reason)
415
+ logging.shutdown()
416
+ sys.stdout.flush()
417
+ sys.stderr.flush()
418
+ exit_now(EXIT_FAILURE)
419
+
420
+
421
+ def run_blocking(
422
+ worker: "Worker",
423
+ *,
424
+ exit_now: Callable[[int], NoReturn] = os._exit,
425
+ executor_join_timeout_s: float = EXECUTOR_JOIN_TIMEOUT_S,
426
+ ) -> int:
427
+ """Run the worker until it stops. Returns the process exit code.
428
+
429
+ A thread that never returns would make the interpreter wait for it at exit: a ``def`` handler's, or the thread of an
430
+ ``async def`` handler cancelled while it waited in ``asyncio.to_thread``. When some are left after the drain, they
431
+ are logged and the process exits at once through ``exit_now``, with code 1: the drain did not complete.
432
+ """
433
+ try:
434
+ settings = worker.settings
435
+ except WorkerConfigurationError as error:
436
+ configure_logging(LogLevel.INFO)
437
+ _LOGGER.error("Invalid worker configuration: %s", error)
438
+ return EXIT_CONFIGURATION
439
+ configure_logging(settings.log_level)
440
+ # The loop is driven by hand, with a default executor of our own whose threads can be recognised and given up on.
441
+ loop = asyncio.new_event_loop()
442
+ executor = ThreadPoolExecutor(thread_name_prefix=DEFAULT_EXECUTOR_THREAD_PREFIX)
443
+ loop.set_default_executor(executor)
444
+ asyncio.set_event_loop(loop)
445
+ overrunning: tuple[str, ...] = ()
446
+ code = EXIT_OK
447
+ try:
448
+ overrunning = loop.run_until_complete(worker._serve_report())
449
+ except (WorkerConfigurationError, WorkerDefinitionError) as error:
450
+ _LOGGER.error("Invalid worker configuration: %s", error)
451
+ code = EXIT_CONFIGURATION
452
+ except TransportUnavailableError as error:
453
+ _LOGGER.error("The broker stayed unreachable, giving up: %s", error)
454
+ code = EXIT_FAILURE
455
+ except Exception:
456
+ _LOGGER.exception("The worker stopped on an error.")
457
+ code = EXIT_FAILURE
458
+ stuck_threads = _close_loop(loop, executor, executor_join_timeout_s)
459
+ if overrunning:
460
+ _leave(f"Exiting with task(s) still running in threads: {', '.join(overrunning)}", exit_now)
461
+ if stuck_threads:
462
+ _leave(f"Exiting with thread(s) or task(s) still running after the drain: {', '.join(stuck_threads)}", exit_now)
463
+ return code
@@ -0,0 +1,20 @@
1
+ """The task a log record belongs to, carried by a context variable.
2
+
3
+ The engine binds it around a delivery, and a logging filter (see ``observability.logging``) copies it onto the records
4
+ that do not carry it already, so that the lines of a library used inside a handler (an HTTP client, a storage client)
5
+ are attributable to the task too. A context variable follows the ``await`` chain of the delivery's task; for ``def``
6
+ handlers the sync bridge runs the thread in a copy of the context.
7
+ """
8
+
9
+ from contextvars import ContextVar
10
+ from dataclasses import dataclass
11
+
12
+
13
+ @dataclass(frozen=True, slots=True)
14
+ class TaskLogContext:
15
+ task_id: str
16
+ service: str
17
+ attempt: int
18
+
19
+
20
+ current_task: ContextVar[TaskLogContext | None] = ContextVar("async_task_api_worker_current_task", default=None)
@@ -0,0 +1,78 @@
1
+ """Events the engine emits. The engine never imports a metrics library: an observer turns them into metrics."""
2
+
3
+ from enum import StrEnum
4
+ from typing import Protocol
5
+
6
+ from async_task_api_worker.protocol.envelope import PoisonReason
7
+
8
+
9
+ class TaskStatus(StrEnum):
10
+ SUCCESS = "success"
11
+ FAILURE = "failure"
12
+
13
+
14
+ class MessageType(StrEnum):
15
+ """The messages a worker publishes, as the metric label values of the observer events."""
16
+
17
+ STARTED = "started"
18
+ PROGRESS = "progress"
19
+ SUCCESS = "success"
20
+ FAILURE = "failure"
21
+
22
+
23
+ class ProgressDropReason(StrEnum):
24
+ """Why a progress update was not published (or not published as such)."""
25
+
26
+ INVALID_FRACTION = "invalid_fraction"
27
+ INVALID_PAYLOAD = "invalid_payload"
28
+ NOT_INCREASING = "not_increasing"
29
+ COALESCED = "coalesced"
30
+ AFTER_CONCLUSION = "after_conclusion"
31
+
32
+
33
+ class RuntimeObserver(Protocol):
34
+ def poison(self, reason: PoisonReason) -> None: ...
35
+
36
+ def task_retried(self) -> None: ...
37
+
38
+ def task_concluded(self, status: TaskStatus, code: str | None, duration_s: float) -> None:
39
+ """Called after the ack, like the broker sees it. ``code`` is the failure code, ``None`` on success."""
40
+ ...
41
+
42
+ def message_published(self, message_type: MessageType) -> None: ...
43
+
44
+ def publish_failed(self, message_type: MessageType) -> None: ...
45
+
46
+ def ack_failed(self) -> None: ...
47
+
48
+ def progress_dropped(self, reason: ProgressDropReason) -> None: ...
49
+
50
+ def shutdown_requeued(self) -> None: ...
51
+
52
+
53
+ class NoopObserver:
54
+ """Observer that ignores every event."""
55
+
56
+ def poison(self, reason: PoisonReason) -> None:
57
+ pass
58
+
59
+ def task_retried(self) -> None:
60
+ pass
61
+
62
+ def task_concluded(self, status: TaskStatus, code: str | None, duration_s: float) -> None:
63
+ pass
64
+
65
+ def message_published(self, message_type: MessageType) -> None:
66
+ pass
67
+
68
+ def publish_failed(self, message_type: MessageType) -> None:
69
+ pass
70
+
71
+ def ack_failed(self) -> None:
72
+ pass
73
+
74
+ def progress_dropped(self, reason: ProgressDropReason) -> None:
75
+ pass
76
+
77
+ def shutdown_requeued(self) -> None:
78
+ pass