89 lines
3.2 KiB
Python
89 lines
3.2 KiB
Python
"""The engine's event bus, read the way a browser reads it.
|
|
|
|
Polling answers "what is true now", so a run that starts and finishes between
|
|
two ticks was only ever a row in the history. This subscribes to the same
|
|
websocket the dashboard in a browser does — nothing new is served for it.
|
|
|
|
Blocking on purpose: `subscribe` is a thread's whole job, and the caller
|
|
brings the thread. Reconnecting is this module's, since a dashboard outlives
|
|
the engine it supervises.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import threading
|
|
from collections.abc import Callable, Collection
|
|
from typing import Any
|
|
from urllib.parse import urlsplit, urlunsplit
|
|
|
|
#: What a reconnect waits before trying again, growing to a minute — the shape
|
|
#: the engine's own supervision backs off with.
|
|
BACKOFF = (1.0, 5.0, 30.0, 60.0)
|
|
|
|
#: How long a read waits before checking `stop`, so an idle connection does
|
|
#: not delay closing the dashboard.
|
|
POLL_S = 1.0
|
|
|
|
|
|
def socket_url(url: str) -> str:
|
|
"""The websocket beside an engine's HTTP address.
|
|
|
|
The token is not in it: it goes as a header on the handshake, since a URL
|
|
ends up in access logs.
|
|
"""
|
|
parts = urlsplit(url)
|
|
scheme = "wss" if parts.scheme == "https" else "ws"
|
|
return urlunsplit((scheme, parts.netloc, "/api/v1/flows/ws", "", ""))
|
|
|
|
|
|
def subscribe(
|
|
url: str,
|
|
token: str,
|
|
on_event: Callable[[dict[str, Any]], None],
|
|
stop: threading.Event,
|
|
kinds: Collection[str] | None = None,
|
|
on_error: Callable[[Exception], None] | None = None,
|
|
) -> None:
|
|
"""Hand every published event to `on_event` until `stop` is set.
|
|
|
|
`kinds` keeps only the event types named, which is worth doing here rather
|
|
than in the caller: `message_value` is most of what the bus carries and a
|
|
screen drawing runs wants none of it.
|
|
|
|
A failure is reported once per outage. A dashboard is the thing that stops
|
|
and starts the engine, so a reconnect loop announcing itself every second
|
|
would bury the log pane it shares.
|
|
"""
|
|
from websockets.sync.client import connect
|
|
|
|
attempt = 0
|
|
while not stop.is_set():
|
|
try:
|
|
with connect(
|
|
socket_url(url),
|
|
additional_headers={"Authorization": f"Bearer {token}"},
|
|
open_timeout=POLL_S * 5,
|
|
) as socket:
|
|
attempt = 0
|
|
while not stop.is_set():
|
|
try:
|
|
frame = socket.recv(timeout=POLL_S)
|
|
except TimeoutError:
|
|
continue
|
|
payload = json.loads(frame)
|
|
events = (
|
|
payload.get("events") or []
|
|
if payload.get("type") == "batch"
|
|
else [payload]
|
|
)
|
|
for event in events:
|
|
if kinds is None or event.get("type") in kinds:
|
|
on_event(event)
|
|
return
|
|
except Exception as exc: # noqa: BLE001 — any transport failure retries
|
|
if attempt == 0 and on_error is not None:
|
|
on_error(exc)
|
|
stop.wait(BACKOFF[min(attempt, len(BACKOFF) - 1)])
|
|
attempt += 1
|