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,299 @@
|
|
|
1
|
+
"""The probe and metrics HTTP server: a small asyncio HTTP/1.1 server on one port.
|
|
2
|
+
|
|
3
|
+
- ``/health``: liveness. It consults no dependency, on purpose: a failing liveness probe restarts the container, and
|
|
4
|
+
tying it to a remote dependency would restart every worker at the first outage of that dependency. What it proves is
|
|
5
|
+
that the event loop answers and that no task is stuck past its deadline plus ``WORKER_TASK_OVERRUN_GRACE_S``.
|
|
6
|
+
- ``/ready``: readiness, "can I take work now?". The broker connection, the shutdown state and the probes the worker
|
|
7
|
+
registered, run in parallel under ``WORKER_PROBE_TIMEOUT_S``. Failing it removes the pod from the Service (or keeps a
|
|
8
|
+
Job pending) without killing it.
|
|
9
|
+
- ``/metrics`` and ``/internal/metrics``: the Prometheus exposition. ``/__version__``: the image's version document.
|
|
10
|
+
|
|
11
|
+
Every error body is ``{"status": "error", "error": {"code", "message"}}``, the API's format.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
import contextlib
|
|
16
|
+
import json
|
|
17
|
+
import logging
|
|
18
|
+
from collections.abc import Awaitable, Callable, Mapping
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from enum import StrEnum
|
|
21
|
+
from http import HTTPStatus
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Final
|
|
24
|
+
|
|
25
|
+
from async_task_api_worker.errors import ProbeError
|
|
26
|
+
from async_task_api_worker.protocol.json_types import JsonObject
|
|
27
|
+
|
|
28
|
+
_LOGGER: Final[logging.Logger] = logging.getLogger("async_task_api_worker")
|
|
29
|
+
|
|
30
|
+
# A request line and its headers fit very largely in this: beyond, it is not a probe and the connection is closed.
|
|
31
|
+
MAX_HEADER_BYTES: Final[int] = 8192
|
|
32
|
+
# A client that connects and never finishes its request, or never reads the answer, must not hold a connection
|
|
33
|
+
# forever: reading the request, writing the answer and closing are each bounded by this.
|
|
34
|
+
IO_TIMEOUT_S: Final[float] = 5.0
|
|
35
|
+
# Probes come from the kubelet and a few scrapers. Beyond this many open connections, new ones are closed at once, so
|
|
36
|
+
# that idle clients cannot exhaust the process's file descriptors, which the broker and the dependencies need too.
|
|
37
|
+
MAX_CONNECTIONS: Final[int] = 32
|
|
38
|
+
_MAX_DETAIL_LENGTH: Final[int] = 200
|
|
39
|
+
_METHOD: Final[str] = "GET"
|
|
40
|
+
_REQUEST_LINE_MIN_PARTS: Final[int] = 2 # "METHOD target"
|
|
41
|
+
_HEADER_END: Final[bytes] = b"\r\n\r\n"
|
|
42
|
+
_JSON_CONTENT_TYPE: Final[str] = "application/json; charset=utf-8"
|
|
43
|
+
DEFAULT_VERSION_FILE: Final[Path] = Path("/app/version.json")
|
|
44
|
+
_DEV_VERSION: Final[JsonObject] = {
|
|
45
|
+
"source": "",
|
|
46
|
+
"version": "dev",
|
|
47
|
+
"commit": "",
|
|
48
|
+
"build": "",
|
|
49
|
+
"code_date": "",
|
|
50
|
+
"changes": [],
|
|
51
|
+
"history": [],
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
type Probe = Callable[[], Awaitable[None]]
|
|
55
|
+
type Body = JsonObject | tuple[bytes, str]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class Status(StrEnum):
|
|
59
|
+
OK = "ok"
|
|
60
|
+
ERROR = "error"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class Route(StrEnum):
|
|
64
|
+
HEALTH = "/health"
|
|
65
|
+
READY = "/ready"
|
|
66
|
+
METRICS = "/metrics"
|
|
67
|
+
INTERNAL_METRICS = "/internal/metrics"
|
|
68
|
+
VERSION = "/__version__"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class HealthErrorCode(StrEnum):
|
|
72
|
+
TASK_OVERRUN = "TASK_OVERRUN"
|
|
73
|
+
DEPENDENCIES_NOT_READY = "DEPENDENCIES_NOT_READY"
|
|
74
|
+
NOT_FOUND = "NOT_FOUND"
|
|
75
|
+
METHOD_NOT_ALLOWED = "METHOD_NOT_ALLOWED"
|
|
76
|
+
PROBE_FAILED = "PROBE_FAILED"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class Component(StrEnum):
|
|
80
|
+
"""Components the SDK reports itself, next to the worker's own probes."""
|
|
81
|
+
|
|
82
|
+
BROKER = "broker"
|
|
83
|
+
DRAINING = "draining"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass(frozen=True, slots=True)
|
|
87
|
+
class RuntimeState:
|
|
88
|
+
"""What the server asks the running worker, through callables so that it knows nothing of the runtime."""
|
|
89
|
+
|
|
90
|
+
ping_broker: Callable[[float], Awaitable[bool]]
|
|
91
|
+
is_draining: Callable[[], bool]
|
|
92
|
+
overrunning: Callable[[], tuple[str, ...]]
|
|
93
|
+
metrics: Callable[[], tuple[bytes, str]]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclass(frozen=True, slots=True)
|
|
97
|
+
class ComponentState:
|
|
98
|
+
status: Status
|
|
99
|
+
details: str | None = None
|
|
100
|
+
|
|
101
|
+
def as_json(self) -> JsonObject:
|
|
102
|
+
return {"status": self.status.value, "details": self.details}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _error(code: HealthErrorCode, message: str, components: JsonObject | None = None) -> JsonObject:
|
|
106
|
+
error: JsonObject = {"code": code.value, "message": message}
|
|
107
|
+
if components is not None:
|
|
108
|
+
error["components"] = components
|
|
109
|
+
return {"status": Status.ERROR.value, "error": error}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _detail_of(error: Exception) -> str:
|
|
113
|
+
"""Safe text for a probe failure: a :class:`ProbeError` message as is, any other exception by class name only."""
|
|
114
|
+
if isinstance(error, ProbeError) and (text := " ".join(str(error).split())):
|
|
115
|
+
return text[:_MAX_DETAIL_LENGTH]
|
|
116
|
+
return type(error).__name__
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
async def run_probe(name: str, probe: Probe, timeout_s: float) -> ComponentState:
|
|
120
|
+
"""Run one probe. It never raises: a failing dependency must not take the probe server down."""
|
|
121
|
+
try:
|
|
122
|
+
await asyncio.wait_for(probe(), timeout_s)
|
|
123
|
+
except TimeoutError:
|
|
124
|
+
_LOGGER.warning("Readiness probe %r timed out after %g s.", name, timeout_s)
|
|
125
|
+
return ComponentState(Status.ERROR, f"Timed out after {timeout_s:g} s")
|
|
126
|
+
except Exception as error:
|
|
127
|
+
_LOGGER.warning("Readiness probe %r failed: %s.", name, type(error).__name__)
|
|
128
|
+
return ComponentState(Status.ERROR, _detail_of(error))
|
|
129
|
+
return ComponentState(Status.OK)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class HealthServer:
|
|
133
|
+
def __init__(
|
|
134
|
+
self,
|
|
135
|
+
*,
|
|
136
|
+
host: str,
|
|
137
|
+
port: int,
|
|
138
|
+
state: RuntimeState,
|
|
139
|
+
probes: Mapping[str, Probe],
|
|
140
|
+
probe_timeout_s: float,
|
|
141
|
+
version_file: Path = DEFAULT_VERSION_FILE,
|
|
142
|
+
max_connections: int = MAX_CONNECTIONS,
|
|
143
|
+
io_timeout_s: float = IO_TIMEOUT_S,
|
|
144
|
+
) -> None:
|
|
145
|
+
self._host = host
|
|
146
|
+
self._port = port
|
|
147
|
+
self._state = state
|
|
148
|
+
self._probes = dict(probes)
|
|
149
|
+
self._probe_timeout_s = probe_timeout_s
|
|
150
|
+
self._version_file = version_file
|
|
151
|
+
self._max_connections = max_connections
|
|
152
|
+
self._io_timeout_s = io_timeout_s
|
|
153
|
+
self._connections = 0
|
|
154
|
+
self._readiness_evaluation: asyncio.Future[tuple[HTTPStatus, Body]] | None = None
|
|
155
|
+
self._server: asyncio.Server | None = None
|
|
156
|
+
|
|
157
|
+
@property
|
|
158
|
+
def port(self) -> int:
|
|
159
|
+
"""The port actually bound (useful when 0 was asked for)."""
|
|
160
|
+
assert self._server is not None
|
|
161
|
+
return self._server.sockets[0].getsockname()[1]
|
|
162
|
+
|
|
163
|
+
async def start(self) -> None:
|
|
164
|
+
self._server = await asyncio.start_server(self._handle, self._host, self._port, limit=MAX_HEADER_BYTES)
|
|
165
|
+
_LOGGER.info("Probes and metrics are served on %s:%d.", self._host, self.port)
|
|
166
|
+
|
|
167
|
+
async def stop(self) -> None:
|
|
168
|
+
if self._server is None:
|
|
169
|
+
return
|
|
170
|
+
self._server.close()
|
|
171
|
+
# The probe server stops last, after the drain: a client that does not read its answer must not hold the
|
|
172
|
+
# shutdown, so what is still connected is cut at once.
|
|
173
|
+
self._server.abort_clients()
|
|
174
|
+
if self._readiness_evaluation is not None:
|
|
175
|
+
self._readiness_evaluation.cancel()
|
|
176
|
+
await self._server.wait_closed()
|
|
177
|
+
self._server = None
|
|
178
|
+
|
|
179
|
+
async def _readiness(self) -> tuple[HTTPStatus, Body]:
|
|
180
|
+
"""Single flight: requests that arrive while an evaluation runs share it, instead of each calling the probes."""
|
|
181
|
+
if self._readiness_evaluation is None:
|
|
182
|
+
evaluation = asyncio.ensure_future(self._evaluate_readiness())
|
|
183
|
+
evaluation.add_done_callback(self._forget_evaluation)
|
|
184
|
+
self._readiness_evaluation = evaluation
|
|
185
|
+
return await asyncio.shield(self._readiness_evaluation)
|
|
186
|
+
|
|
187
|
+
def _forget_evaluation(self, _: object) -> None:
|
|
188
|
+
self._readiness_evaluation = None
|
|
189
|
+
|
|
190
|
+
async def _evaluate_readiness(self) -> tuple[HTTPStatus, Body]:
|
|
191
|
+
if self._state.is_draining():
|
|
192
|
+
components: dict[str, ComponentState] = {
|
|
193
|
+
Component.DRAINING: ComponentState(Status.ERROR, "The worker no longer takes deliveries"),
|
|
194
|
+
}
|
|
195
|
+
else:
|
|
196
|
+
names = sorted(self._probes)
|
|
197
|
+
results = await asyncio.gather(
|
|
198
|
+
self._broker_state(),
|
|
199
|
+
*(run_probe(name, self._probes[name], self._probe_timeout_s) for name in names),
|
|
200
|
+
)
|
|
201
|
+
components = dict(zip([Component.BROKER.value, *names], results, strict=True))
|
|
202
|
+
unavailable = [name for name, component in components.items() if component.status is not Status.OK]
|
|
203
|
+
rendered: JsonObject = {name: component.as_json() for name, component in components.items()}
|
|
204
|
+
if unavailable:
|
|
205
|
+
message = f"Dependencies not ready: {', '.join(unavailable)}."
|
|
206
|
+
return HTTPStatus.SERVICE_UNAVAILABLE, _error(HealthErrorCode.DEPENDENCIES_NOT_READY, message, rendered)
|
|
207
|
+
return HTTPStatus.OK, {"status": Status.OK.value, "components": rendered}
|
|
208
|
+
|
|
209
|
+
async def _broker_state(self) -> ComponentState:
|
|
210
|
+
try:
|
|
211
|
+
connected = await asyncio.wait_for(self._state.ping_broker(self._probe_timeout_s), self._probe_timeout_s)
|
|
212
|
+
except Exception:
|
|
213
|
+
connected = False
|
|
214
|
+
return ComponentState(Status.OK) if connected else ComponentState(Status.ERROR, "Not connected")
|
|
215
|
+
|
|
216
|
+
def _liveness(self) -> tuple[HTTPStatus, Body]:
|
|
217
|
+
if self._state.overrunning():
|
|
218
|
+
message = "A task is still running past its deadline."
|
|
219
|
+
return HTTPStatus.SERVICE_UNAVAILABLE, _error(HealthErrorCode.TASK_OVERRUN, message)
|
|
220
|
+
return HTTPStatus.OK, {"status": Status.OK.value}
|
|
221
|
+
|
|
222
|
+
def _version(self) -> JsonObject:
|
|
223
|
+
try:
|
|
224
|
+
document = json.loads(self._version_file.read_text(encoding="utf-8"))
|
|
225
|
+
except (OSError, ValueError):
|
|
226
|
+
return dict(_DEV_VERSION)
|
|
227
|
+
return document if isinstance(document, dict) else dict(_DEV_VERSION)
|
|
228
|
+
|
|
229
|
+
async def _route(self, method: str, path: str) -> tuple[HTTPStatus, Body]:
|
|
230
|
+
if method != _METHOD:
|
|
231
|
+
return HTTPStatus.METHOD_NOT_ALLOWED, _error(
|
|
232
|
+
HealthErrorCode.METHOD_NOT_ALLOWED,
|
|
233
|
+
f"Method {method!r} is not allowed.",
|
|
234
|
+
)
|
|
235
|
+
match path:
|
|
236
|
+
case Route.HEALTH:
|
|
237
|
+
return self._liveness()
|
|
238
|
+
case Route.READY:
|
|
239
|
+
return await self._readiness()
|
|
240
|
+
case Route.METRICS | Route.INTERNAL_METRICS:
|
|
241
|
+
return HTTPStatus.OK, self._state.metrics()
|
|
242
|
+
case Route.VERSION:
|
|
243
|
+
return HTTPStatus.OK, self._version()
|
|
244
|
+
return HTTPStatus.NOT_FOUND, _error(HealthErrorCode.NOT_FOUND, f"Path {path!r} is unknown.")
|
|
245
|
+
|
|
246
|
+
async def _handle(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
|
|
247
|
+
if self._connections >= self._max_connections:
|
|
248
|
+
writer.close() # over the bound: not even read
|
|
249
|
+
return
|
|
250
|
+
self._connections += 1
|
|
251
|
+
try:
|
|
252
|
+
try:
|
|
253
|
+
head = await asyncio.wait_for(reader.readuntil(_HEADER_END), self._io_timeout_s)
|
|
254
|
+
except (asyncio.IncompleteReadError, asyncio.LimitOverrunError, ValueError, TimeoutError):
|
|
255
|
+
return # truncated, oversized or too slow: not a probe, close without a word
|
|
256
|
+
method, path = _request_line(head)
|
|
257
|
+
try:
|
|
258
|
+
status, body = await self._route(method, path)
|
|
259
|
+
except Exception:
|
|
260
|
+
_LOGGER.exception("The probe server failed to answer %s.", path)
|
|
261
|
+
status = HTTPStatus.INTERNAL_SERVER_ERROR
|
|
262
|
+
body = _error(HealthErrorCode.PROBE_FAILED, "The probe server failed.")
|
|
263
|
+
writer.write(_response(status, body))
|
|
264
|
+
with contextlib.suppress(ConnectionError, TimeoutError):
|
|
265
|
+
await asyncio.wait_for(writer.drain(), self._io_timeout_s)
|
|
266
|
+
finally:
|
|
267
|
+
self._connections -= 1
|
|
268
|
+
await self._close(writer)
|
|
269
|
+
|
|
270
|
+
async def _close(self, writer: asyncio.StreamWriter) -> None:
|
|
271
|
+
"""Close, and abort what a client that does not read keeps from flushing: the server must be able to stop."""
|
|
272
|
+
writer.close()
|
|
273
|
+
with contextlib.suppress(ConnectionError):
|
|
274
|
+
try:
|
|
275
|
+
await asyncio.wait_for(writer.wait_closed(), self._io_timeout_s)
|
|
276
|
+
except TimeoutError:
|
|
277
|
+
writer.transport.abort()
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _request_line(head: bytes) -> tuple[str, str]:
|
|
281
|
+
"""Method and path of a request. An unreadable line gives an empty method, so a 405; the query string is dropped."""
|
|
282
|
+
parts = head.split(b"\r\n", 1)[0].decode("latin-1").split()
|
|
283
|
+
if len(parts) < _REQUEST_LINE_MIN_PARTS:
|
|
284
|
+
return "", ""
|
|
285
|
+
return parts[0].upper(), parts[1].split("?", 1)[0]
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _response(status: HTTPStatus, body: Body) -> bytes:
|
|
289
|
+
if isinstance(body, tuple):
|
|
290
|
+
payload, content_type = body
|
|
291
|
+
else:
|
|
292
|
+
payload, content_type = json.dumps(body, ensure_ascii=False).encode(), _JSON_CONTENT_TYPE
|
|
293
|
+
head = (
|
|
294
|
+
f"HTTP/1.1 {status.value} {status.phrase}\r\n"
|
|
295
|
+
f"Content-Type: {content_type}\r\n"
|
|
296
|
+
f"Content-Length: {len(payload)}\r\n"
|
|
297
|
+
"Connection: close\r\n\r\n"
|
|
298
|
+
)
|
|
299
|
+
return head.encode() + payload
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""JSON logging: one object per line on stdout, so that a log collector parses it as is."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import sys
|
|
6
|
+
from datetime import UTC, datetime
|
|
7
|
+
from typing import Final
|
|
8
|
+
|
|
9
|
+
from async_task_api_worker._runtime.log_context import current_task
|
|
10
|
+
from async_task_api_worker.settings import LogLevel
|
|
11
|
+
|
|
12
|
+
_ROOT_LOGGER: Final[str] = ""
|
|
13
|
+
_STANDARD_RECORD_FIELDS: Final[frozenset[str]] = frozenset(
|
|
14
|
+
{*vars(logging.LogRecord("", 0, "", 0, "", (), None)), "message", "asctime", "taskName"},
|
|
15
|
+
)
|
|
16
|
+
_FIXED_FIELDS: Final[tuple[str, ...]] = ("ts", "level", "logger", "msg")
|
|
17
|
+
_EXCEPTION_FIELD: Final[str] = "exc"
|
|
18
|
+
_HANDLER_MARKER: Final[str] = "_async_task_api_worker_handler"
|
|
19
|
+
# aio-pika logs two ERROR lines from its channel callbacks when a connection drops, then reconnects normally: a broker
|
|
20
|
+
# blip is not an error of the worker (a lost connection that does not come back surfaces through the probes).
|
|
21
|
+
_RECONNECT_NOISE_LOGGER: Final[str] = "aio_pika.tools"
|
|
22
|
+
_RECONNECT_CALLBACK: Final[str] = "RobustChannel._on_close"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class TaskContextFilter(logging.Filter):
|
|
26
|
+
"""Put the task being processed on the records that do not carry one: library logs inside a handler get it too."""
|
|
27
|
+
|
|
28
|
+
def filter(self, record: logging.LogRecord) -> bool:
|
|
29
|
+
task = current_task.get()
|
|
30
|
+
if task is not None and not hasattr(record, "task_id"):
|
|
31
|
+
record.task_id = task.task_id
|
|
32
|
+
record.service = task.service
|
|
33
|
+
record.attempt = task.attempt
|
|
34
|
+
return True
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class DemoteReconnectErrorsFilter(logging.Filter):
|
|
38
|
+
"""Log the errors of a channel's close callback, which a dropped connection produces, as warnings.
|
|
39
|
+
|
|
40
|
+
Only those records: aio-pika logs the two lines of a reconnect from its callback collections, naming the callback
|
|
41
|
+
(``RobustChannel._on_close``); any other error of that logger keeps its level.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def filter(self, record: logging.LogRecord) -> bool:
|
|
45
|
+
if record.levelno == logging.ERROR and _RECONNECT_CALLBACK in record.getMessage():
|
|
46
|
+
record.levelno = logging.WARNING
|
|
47
|
+
record.levelname = logging.getLevelName(logging.WARNING)
|
|
48
|
+
return True
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class JsonFormatter(logging.Formatter):
|
|
52
|
+
"""``ts``, ``level``, ``logger``, ``msg``, then every field bound to the record (``task_id``, ``service``,
|
|
53
|
+
``attempt``, the decision fields...), then ``exc`` with the traceback when there is one.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
def format(self, record: logging.LogRecord) -> str:
|
|
57
|
+
entry: dict[str, object] = {
|
|
58
|
+
"ts": datetime.fromtimestamp(record.created, UTC).isoformat(),
|
|
59
|
+
"level": record.levelname,
|
|
60
|
+
"logger": record.name,
|
|
61
|
+
"msg": record.getMessage(),
|
|
62
|
+
}
|
|
63
|
+
for name, value in vars(record).items():
|
|
64
|
+
if name not in _STANDARD_RECORD_FIELDS and name not in _FIXED_FIELDS:
|
|
65
|
+
entry[name] = value
|
|
66
|
+
if record.exc_info:
|
|
67
|
+
entry[_EXCEPTION_FIELD] = self.formatException(record.exc_info)
|
|
68
|
+
return json.dumps(entry, default=str, ensure_ascii=False)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def configure_logging(level: LogLevel) -> None:
|
|
72
|
+
"""Send every log record of the process to stdout as JSON, at ``level``.
|
|
73
|
+
|
|
74
|
+
The root logger belongs to the application: if it already has a handler of its own (the application configured
|
|
75
|
+
logging before running the worker), that handler is kept and none is added, so that no line is printed twice,
|
|
76
|
+
and only the level is applied (the task fields of libraries' logs come from a filter on the SDK's own handler).
|
|
77
|
+
When the SDK installs its handler it also logs the errors of the channel close callbacks aio-pika emits while a
|
|
78
|
+
dropped connection reconnects (logger ``aio_pika.tools``) as warnings. Calling this twice installs the handler once.
|
|
79
|
+
"""
|
|
80
|
+
root = logging.getLogger(_ROOT_LOGGER)
|
|
81
|
+
root.setLevel(level.value)
|
|
82
|
+
if root.handlers:
|
|
83
|
+
return
|
|
84
|
+
handler = logging.StreamHandler(sys.stdout)
|
|
85
|
+
handler.setFormatter(JsonFormatter())
|
|
86
|
+
handler.addFilter(TaskContextFilter())
|
|
87
|
+
setattr(handler, _HANDLER_MARKER, True)
|
|
88
|
+
root.addHandler(handler)
|
|
89
|
+
logging.getLogger(_RECONNECT_NOISE_LOGGER).addFilter(DemoteReconnectErrorsFilter())
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Event loop lag probe.
|
|
2
|
+
|
|
3
|
+
A synchronous call of more than a few milliseconds made from a coroutine blocks the loop: nothing else progresses, the
|
|
4
|
+
liveness probe stops answering and the kubelet kills the pod, with no visible cause. The lag is the one signal that
|
|
5
|
+
rises before the break. The probe sleeps a known interval and observes the overshoot when it wakes up: a free loop
|
|
6
|
+
overshoots by about a millisecond, a blocked one by what is left of the blocking call.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import contextlib
|
|
11
|
+
import time
|
|
12
|
+
from collections.abc import Callable
|
|
13
|
+
from typing import Final
|
|
14
|
+
|
|
15
|
+
from async_task_api_worker._runtime.progress import Clock, Sleep
|
|
16
|
+
|
|
17
|
+
# Shorter would add nothing: the probe looks for blockings of at least a hundred milliseconds, and measures the
|
|
18
|
+
# overshoot, not the moment of the blocking.
|
|
19
|
+
DEFAULT_INTERVAL_S: Final[float] = 1.0
|
|
20
|
+
|
|
21
|
+
type Observe = Callable[[float], object]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class EventLoopLagProbe:
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
observe: Observe,
|
|
28
|
+
*,
|
|
29
|
+
interval_s: float = DEFAULT_INTERVAL_S,
|
|
30
|
+
clock: Clock = time.perf_counter,
|
|
31
|
+
sleep: Sleep = asyncio.sleep,
|
|
32
|
+
) -> None:
|
|
33
|
+
self._observe = observe
|
|
34
|
+
self._interval_s = interval_s
|
|
35
|
+
self._clock = clock
|
|
36
|
+
self._sleep = sleep
|
|
37
|
+
self._task: asyncio.Task[None] | None = None
|
|
38
|
+
|
|
39
|
+
def start(self) -> None:
|
|
40
|
+
if self._task is None:
|
|
41
|
+
self._task = asyncio.get_running_loop().create_task(self._loop())
|
|
42
|
+
|
|
43
|
+
async def stop(self) -> None:
|
|
44
|
+
if self._task is None:
|
|
45
|
+
return
|
|
46
|
+
self._task.cancel()
|
|
47
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
48
|
+
await self._task
|
|
49
|
+
self._task = None
|
|
50
|
+
|
|
51
|
+
async def _loop(self) -> None:
|
|
52
|
+
while True:
|
|
53
|
+
started = self._clock()
|
|
54
|
+
await self._sleep(self._interval_s)
|
|
55
|
+
# Clock granularity can make the overshoot slightly negative: observed as is it would skew the first bucket.
|
|
56
|
+
self._observe(max(self._clock() - started - self._interval_s, 0.0))
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""Prometheus metrics of a worker.
|
|
2
|
+
|
|
3
|
+
One registry per worker process, of its own (never the default one: a library must not write to a global namespace).
|
|
4
|
+
No task, client or service label: the scrape adds ``service_name`` and ``pod`` itself.
|
|
5
|
+
|
|
6
|
+
The engine emits events to a :class:`RuntimeObserver`; :class:`PrometheusObserver` turns them into series, so the engine
|
|
7
|
+
never imports this module.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
from typing import Final
|
|
12
|
+
|
|
13
|
+
from prometheus_client import CONTENT_TYPE_LATEST, CollectorRegistry, Counter, Gauge, Histogram, generate_latest
|
|
14
|
+
from prometheus_client.gc_collector import GCCollector
|
|
15
|
+
from prometheus_client.platform_collector import PlatformCollector
|
|
16
|
+
from prometheus_client.process_collector import ProcessCollector
|
|
17
|
+
|
|
18
|
+
from async_task_api_worker._runtime.observer import MessageType, ProgressDropReason, TaskStatus
|
|
19
|
+
from async_task_api_worker._version import __version__
|
|
20
|
+
from async_task_api_worker.protocol.envelope import PoisonReason
|
|
21
|
+
from async_task_api_worker.protocol.version import PROTOCOL_VERSION
|
|
22
|
+
|
|
23
|
+
_MS: Final[float] = 0.001
|
|
24
|
+
_S: Final[float] = 1.0
|
|
25
|
+
_MINUTE: Final[float] = 60.0
|
|
26
|
+
_INF: Final[float] = float("inf")
|
|
27
|
+
|
|
28
|
+
# The same buckets as the listener measures the interval between the two messages of a task with: the two views are
|
|
29
|
+
# comparable bucket by bucket. A test at the repository root forbids drift.
|
|
30
|
+
TASK_DURATION_BUCKETS: Final[tuple[float, ...]] = (
|
|
31
|
+
0.005 * _S,
|
|
32
|
+
0.01 * _S,
|
|
33
|
+
0.025 * _S,
|
|
34
|
+
0.05 * _S,
|
|
35
|
+
0.1 * _S,
|
|
36
|
+
0.15 * _S,
|
|
37
|
+
0.2 * _S,
|
|
38
|
+
0.25 * _S,
|
|
39
|
+
0.3 * _S,
|
|
40
|
+
0.4 * _S,
|
|
41
|
+
0.5 * _S,
|
|
42
|
+
1.0 * _S,
|
|
43
|
+
2.5 * _S,
|
|
44
|
+
5.0 * _S,
|
|
45
|
+
10.0 * _S,
|
|
46
|
+
30.0 * _S,
|
|
47
|
+
1.0 * _MINUTE,
|
|
48
|
+
2.0 * _MINUTE,
|
|
49
|
+
5.0 * _MINUTE,
|
|
50
|
+
_INF,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
# The same buckets as the API, so that the event loop lag of every component overlays on one dashboard.
|
|
54
|
+
EVENT_LOOP_LAG_BUCKETS: Final[tuple[float, ...]] = (
|
|
55
|
+
1.0 * _MS,
|
|
56
|
+
5.0 * _MS,
|
|
57
|
+
10.0 * _MS,
|
|
58
|
+
50.0 * _MS,
|
|
59
|
+
100.0 * _MS,
|
|
60
|
+
250.0 * _MS,
|
|
61
|
+
500.0 * _MS,
|
|
62
|
+
1.0 * _S,
|
|
63
|
+
2.5 * _S,
|
|
64
|
+
5.0 * _S,
|
|
65
|
+
_INF,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
LOOP_LAG_COMPONENT: Final[str] = "worker"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class WorkerMetrics:
|
|
72
|
+
"""The series of one worker, and the observer that feeds them."""
|
|
73
|
+
|
|
74
|
+
def __init__(self) -> None:
|
|
75
|
+
self.registry: Final[CollectorRegistry] = CollectorRegistry()
|
|
76
|
+
ProcessCollector(registry=self.registry)
|
|
77
|
+
PlatformCollector(registry=self.registry)
|
|
78
|
+
GCCollector(registry=self.registry)
|
|
79
|
+
registry = self.registry
|
|
80
|
+
self.tasks = Counter("worker_tasks_total", "Tasks concluded, by outcome.", ["status"], registry=registry)
|
|
81
|
+
self.task_duration = Histogram(
|
|
82
|
+
"worker_task_duration_seconds",
|
|
83
|
+
"Time from taking a delivery to its conclusion.",
|
|
84
|
+
buckets=TASK_DURATION_BUCKETS,
|
|
85
|
+
registry=registry,
|
|
86
|
+
)
|
|
87
|
+
self.messages_published = Counter(
|
|
88
|
+
"worker_messages_published_total",
|
|
89
|
+
"Messages published to the output queue, by type.",
|
|
90
|
+
["message_type"],
|
|
91
|
+
registry=registry,
|
|
92
|
+
)
|
|
93
|
+
self.loop_lag = Histogram(
|
|
94
|
+
"event_loop_lag_seconds",
|
|
95
|
+
"Overshoot observed when a wait of a known interval wakes up.",
|
|
96
|
+
["component"],
|
|
97
|
+
buckets=EVENT_LOOP_LAG_BUCKETS,
|
|
98
|
+
registry=registry,
|
|
99
|
+
).labels(component=LOOP_LAG_COMPONENT)
|
|
100
|
+
self.task_failures = Counter(
|
|
101
|
+
"worker_task_failures_total",
|
|
102
|
+
"Tasks concluded as a failure, by error code.",
|
|
103
|
+
["code"],
|
|
104
|
+
registry=registry,
|
|
105
|
+
)
|
|
106
|
+
self.task_retries = Counter(
|
|
107
|
+
"worker_task_retries_total",
|
|
108
|
+
"Deliveries requeued after a retryable error.",
|
|
109
|
+
registry=registry,
|
|
110
|
+
)
|
|
111
|
+
self.poison_messages = Counter(
|
|
112
|
+
"worker_poison_messages_total",
|
|
113
|
+
"Undecodable deliveries, acked and dropped, by reason.",
|
|
114
|
+
["reason"],
|
|
115
|
+
registry=registry,
|
|
116
|
+
)
|
|
117
|
+
self.publish_failures = Counter(
|
|
118
|
+
"worker_publish_failures_total",
|
|
119
|
+
"Publishes that failed, by message type.",
|
|
120
|
+
["message_type"],
|
|
121
|
+
registry=registry,
|
|
122
|
+
)
|
|
123
|
+
self.ack_failures = Counter(
|
|
124
|
+
"worker_ack_failures_total",
|
|
125
|
+
"Acks that failed after the result was published.",
|
|
126
|
+
registry=registry,
|
|
127
|
+
)
|
|
128
|
+
self.in_flight = Gauge("worker_tasks_in_flight", "Tasks being processed right now.", registry=registry)
|
|
129
|
+
self.shutdown_requeued = Counter(
|
|
130
|
+
"worker_shutdown_requeued_total",
|
|
131
|
+
"Deliveries requeued because the worker was shutting down.",
|
|
132
|
+
registry=registry,
|
|
133
|
+
)
|
|
134
|
+
self.progress_dropped = Counter(
|
|
135
|
+
"worker_progress_dropped_total",
|
|
136
|
+
"Progress updates not published as such, by reason.",
|
|
137
|
+
["reason"],
|
|
138
|
+
registry=registry,
|
|
139
|
+
)
|
|
140
|
+
Gauge(
|
|
141
|
+
"worker_sdk_info",
|
|
142
|
+
"Constant 1, labelled with the SDK and protocol versions.",
|
|
143
|
+
["sdk_version", "protocol_version"],
|
|
144
|
+
registry=registry,
|
|
145
|
+
).labels(sdk_version=__version__, protocol_version=PROTOCOL_VERSION).set(1)
|
|
146
|
+
self.observer: Final[PrometheusObserver] = PrometheusObserver(self)
|
|
147
|
+
|
|
148
|
+
def track_in_flight(self, count: Callable[[], int]) -> None:
|
|
149
|
+
"""Read the in-flight gauge from ``count`` at scrape time."""
|
|
150
|
+
self.in_flight.set_function(lambda: float(count()))
|
|
151
|
+
|
|
152
|
+
def exposition(self) -> tuple[bytes, str]:
|
|
153
|
+
"""The body of ``/metrics`` and its content type."""
|
|
154
|
+
return generate_latest(self.registry), CONTENT_TYPE_LATEST
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class PrometheusObserver:
|
|
158
|
+
"""A :class:`RuntimeObserver` that increments the series of a :class:`WorkerMetrics`."""
|
|
159
|
+
|
|
160
|
+
def __init__(self, metrics: WorkerMetrics) -> None:
|
|
161
|
+
self._metrics = metrics
|
|
162
|
+
|
|
163
|
+
def poison(self, reason: PoisonReason) -> None:
|
|
164
|
+
self._metrics.poison_messages.labels(reason=reason.value).inc()
|
|
165
|
+
|
|
166
|
+
def task_retried(self) -> None:
|
|
167
|
+
self._metrics.task_retries.inc()
|
|
168
|
+
|
|
169
|
+
def task_concluded(self, status: TaskStatus, code: str | None, duration_s: float) -> None:
|
|
170
|
+
self._metrics.tasks.labels(status=status.value).inc()
|
|
171
|
+
self._metrics.task_duration.observe(duration_s)
|
|
172
|
+
if code is not None:
|
|
173
|
+
self._metrics.task_failures.labels(code=code).inc()
|
|
174
|
+
|
|
175
|
+
def message_published(self, message_type: MessageType) -> None:
|
|
176
|
+
self._metrics.messages_published.labels(message_type=message_type.value).inc()
|
|
177
|
+
|
|
178
|
+
def publish_failed(self, message_type: MessageType) -> None:
|
|
179
|
+
self._metrics.publish_failures.labels(message_type=message_type.value).inc()
|
|
180
|
+
|
|
181
|
+
def ack_failed(self) -> None:
|
|
182
|
+
self._metrics.ack_failures.inc()
|
|
183
|
+
|
|
184
|
+
def progress_dropped(self, reason: ProgressDropReason) -> None:
|
|
185
|
+
self._metrics.progress_dropped.labels(reason=reason.value).inc()
|
|
186
|
+
|
|
187
|
+
def shutdown_requeued(self) -> None:
|
|
188
|
+
self._metrics.shutdown_requeued.inc()
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Wire protocol: inbound envelope decoding and outbound message encoding."""
|
|
2
|
+
|
|
3
|
+
from async_task_api_worker.protocol.envelope import (
|
|
4
|
+
EnvelopeContext,
|
|
5
|
+
InputRef,
|
|
6
|
+
PoisonDelivery,
|
|
7
|
+
PoisonReason,
|
|
8
|
+
SubmissionData,
|
|
9
|
+
TaskEnvelope,
|
|
10
|
+
decode_envelope,
|
|
11
|
+
)
|
|
12
|
+
from async_task_api_worker.protocol.messages import (
|
|
13
|
+
ErrorDetail,
|
|
14
|
+
FailureData,
|
|
15
|
+
InvalidProgressError,
|
|
16
|
+
MessageEncoder,
|
|
17
|
+
OutMessage,
|
|
18
|
+
ProgressData,
|
|
19
|
+
StartedData,
|
|
20
|
+
SuccessData,
|
|
21
|
+
)
|
|
22
|
+
from async_task_api_worker.protocol.version import PROTOCOL_VERSION
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"PROTOCOL_VERSION",
|
|
26
|
+
"EnvelopeContext",
|
|
27
|
+
"ErrorDetail",
|
|
28
|
+
"FailureData",
|
|
29
|
+
"InputRef",
|
|
30
|
+
"InvalidProgressError",
|
|
31
|
+
"MessageEncoder",
|
|
32
|
+
"OutMessage",
|
|
33
|
+
"PoisonDelivery",
|
|
34
|
+
"PoisonReason",
|
|
35
|
+
"ProgressData",
|
|
36
|
+
"StartedData",
|
|
37
|
+
"SubmissionData",
|
|
38
|
+
"SuccessData",
|
|
39
|
+
"TaskEnvelope",
|
|
40
|
+
"decode_envelope",
|
|
41
|
+
]
|