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