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