Supervise the loops a flow starts, and give up loudly
A node's subscription, schedule or poll loop was a bare asyncio task: one that raised outside its own retry handling was simply gone, and the node went on being listed as running while nothing listened any more. Those loops now run under a supervisor that restarts them with a growing delay and quarantines a flow that burns through five restarts in five minutes — a flow crash-looping every second is worse than one that is visibly stopped, and the dashboard can now say which. The MQTT subscription loses its private five-second reconnect in the process: one backoff policy per socket, and it belongs to the supervisor. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011LF61rxW1FG5YCD2J9YqjY
This commit is contained in:
@@ -76,6 +76,10 @@ external interfaces. See `docs/architecture/structure.canvas` → *Backend – M
|
|||||||
reachability and fails the container healthcheck, so a wedged engine is restarted
|
reachability and fails the container healthcheck, so a wedged engine is restarted
|
||||||
rather than counted as up. One engine per deployment — the API image runs a single
|
rather than counted as up. One engine per deployment — the API image runs a single
|
||||||
worker, because a second one would be a second engine
|
worker, because a second one would be a second engine
|
||||||
|
- [x] Supervised background tasks: a node's subscription, schedule or poll loop is
|
||||||
|
restarted with growing delay when it dies, and a flow that spends its failure
|
||||||
|
budget is quarantined and surfaced rather than left crash-looping. The loops
|
||||||
|
themselves no longer carry private retry logic
|
||||||
- [ ] Test nodes: a small node dragged onto an existing one, smoke or unit, blocking
|
- [ ] Test nodes: a small node dragged onto an existing one, smoke or unit, blocking
|
||||||
deployment on failure
|
deployment on failure
|
||||||
- [ ] User management scoped per flow and per data set
|
- [ ] User management scoped per flow and per data set
|
||||||
|
|||||||
@@ -172,6 +172,7 @@ def read_flows(controller: FlowControllerDep) -> Any:
|
|||||||
has_draft=controller.store.has_draft(name),
|
has_draft=controller.store.has_draft(name),
|
||||||
enabled=controller.is_enabled(name),
|
enabled=controller.is_enabled(name),
|
||||||
paused=controller.is_paused(name),
|
paused=controller.is_paused(name),
|
||||||
|
quarantined=controller.is_quarantined(name),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return FlowsPublic(data=summaries, count=len(summaries))
|
return FlowsPublic(data=summaries, count=len(summaries))
|
||||||
|
|||||||
@@ -109,21 +109,22 @@ class ConnectorNode(Node):
|
|||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
async def start(self, app: FastAPI | None = None) -> None:
|
async def start(self, app: FastAPI | None = None) -> None:
|
||||||
if self.config.poll_interval > 0 and self._poll_task is None:
|
if self.config.poll_interval > 0 and self._stop_event is None:
|
||||||
self._stop_event = asyncio.Event()
|
self._stop_event = asyncio.Event()
|
||||||
self._poll_task = asyncio.create_task(self._poll_loop())
|
self._poll_task = self._run_supervised("poll", self._poll_loop)
|
||||||
|
|
||||||
async def stop(self, app: FastAPI | None = None) -> None:
|
async def stop(self, app: FastAPI | None = None) -> None:
|
||||||
if self._poll_task is None:
|
if self._stop_event is None:
|
||||||
return
|
return
|
||||||
if self._stop_event is not None:
|
self._stop_event.set()
|
||||||
self._stop_event.set()
|
if self._poll_task is not None:
|
||||||
self._poll_task.cancel()
|
self._poll_task.cancel()
|
||||||
try:
|
try:
|
||||||
await self._poll_task
|
await self._poll_task
|
||||||
except (asyncio.CancelledError, Exception): # noqa: B014 - shutting down
|
except (asyncio.CancelledError, Exception): # noqa: B014 - shutting down
|
||||||
pass
|
pass
|
||||||
self._poll_task = None
|
self._poll_task = None
|
||||||
|
self._stop_event = None
|
||||||
self._last_published = {}
|
self._last_published = {}
|
||||||
|
|
||||||
async def _poll_loop(self) -> None:
|
async def _poll_loop(self) -> None:
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ from app.flow.schemas import (
|
|||||||
from app.flow.secrets import SecretNotFound, resolve_params
|
from app.flow.secrets import SecretNotFound, resolve_params
|
||||||
from app.flow.state import MemoryState, StateBackend
|
from app.flow.state import MemoryState, StateBackend
|
||||||
from app.flow.store import LIB_DIR, FlowNotFound, FlowStore, LibNotFound
|
from app.flow.store import LIB_DIR, FlowNotFound, FlowStore, LibNotFound
|
||||||
|
from app.flow.supervision import Supervisor
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -207,6 +208,7 @@ class FlowController:
|
|||||||
self.loaded: dict[str, LoadedNode] = {}
|
self.loaded: dict[str, LoadedNode] = {}
|
||||||
self.issues: list[ValidationIssue] = []
|
self.issues: list[ValidationIssue] = []
|
||||||
self.disabled: set[str] = set()
|
self.disabled: set[str] = set()
|
||||||
|
self.supervisor = Supervisor(events)
|
||||||
self._lock = asyncio.Lock()
|
self._lock = asyncio.Lock()
|
||||||
|
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
@@ -228,6 +230,9 @@ class FlowController:
|
|||||||
"""Rebuild the whole pipeline from what is currently stored."""
|
"""Rebuild the whole pipeline from what is currently stored."""
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
await self._teardown()
|
await self._teardown()
|
||||||
|
# A fresh supervisor per build, so a flow quarantined by the last
|
||||||
|
# one gets another chance once its author has changed something.
|
||||||
|
self.supervisor = Supervisor(self.events)
|
||||||
|
|
||||||
published = self.store.read_all()
|
published = self.store.read_all()
|
||||||
self.disabled = {
|
self.disabled = {
|
||||||
@@ -272,6 +277,8 @@ class FlowController:
|
|||||||
await node.stop(self.app)
|
await node.stop(self.app)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Error stopping node '%s'", entry.id)
|
logger.exception("Error stopping node '%s'", entry.id)
|
||||||
|
# After the nodes, so a loop still winding down is not restarted.
|
||||||
|
await self.supervisor.cancel_all()
|
||||||
|
|
||||||
async def _activate(self) -> None:
|
async def _activate(self) -> None:
|
||||||
"""Start subscriptions, schedules and webhooks of the new pipeline.
|
"""Start subscriptions, schedules and webhooks of the new pipeline.
|
||||||
@@ -287,6 +294,7 @@ class FlowController:
|
|||||||
# that is what stopping it means.
|
# that is what stopping it means.
|
||||||
if entry.flow in self.disabled:
|
if entry.flow in self.disabled:
|
||||||
continue
|
continue
|
||||||
|
node.supervisor = self.supervisor
|
||||||
try:
|
try:
|
||||||
await node.start(self.app)
|
await node.start(self.app)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -474,6 +482,13 @@ class FlowController:
|
|||||||
def is_paused(self, flow: str) -> bool:
|
def is_paused(self, flow: str) -> bool:
|
||||||
return self.pipeline is not None and flow in self.pipeline.paused_flows()
|
return self.pipeline is not None and flow in self.pipeline.paused_flows()
|
||||||
|
|
||||||
|
def is_quarantined(self, flow: str) -> bool:
|
||||||
|
return flow in self.supervisor.quarantined
|
||||||
|
|
||||||
|
@property
|
||||||
|
def quarantined(self) -> set[str]:
|
||||||
|
return self.supervisor.quarantined
|
||||||
|
|
||||||
def paused_flows(self) -> list[str]:
|
def paused_flows(self) -> list[str]:
|
||||||
return self.pipeline.paused_flows() if self.pipeline else []
|
return self.pipeline.paused_flows() if self.pipeline else []
|
||||||
|
|
||||||
|
|||||||
+69
-51
@@ -5,7 +5,7 @@ import hmac
|
|||||||
import logging
|
import logging
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from collections.abc import Callable, Iterable
|
from collections.abc import Callable, Coroutine, Iterable
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import TYPE_CHECKING, Any, Literal
|
from typing import TYPE_CHECKING, Any, Literal
|
||||||
|
|
||||||
@@ -15,6 +15,7 @@ from pydantic import BaseModel, ConfigDict, Field
|
|||||||
|
|
||||||
from app.flow import logs
|
from app.flow import logs
|
||||||
from app.flow.messages import MessageSpec, qualify
|
from app.flow.messages import MessageSpec, qualify
|
||||||
|
from app.flow.supervision import Supervisor
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
@@ -75,6 +76,7 @@ class Node:
|
|||||||
"_pipeline",
|
"_pipeline",
|
||||||
"synchronous",
|
"synchronous",
|
||||||
"_on_health",
|
"_on_health",
|
||||||
|
"supervisor",
|
||||||
)
|
)
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
@@ -88,6 +90,9 @@ class Node:
|
|||||||
self.f = f
|
self.f = f
|
||||||
self._pipeline: Pipeline | None = None
|
self._pipeline: Pipeline | None = None
|
||||||
self._on_health: Callable[[Node, str, str | None], None] | None = None
|
self._on_health: Callable[[Node, str, str | None], None] | None = None
|
||||||
|
# Set by the controller before start(); a node built on its own (tests,
|
||||||
|
# previews) runs its loops unsupervised.
|
||||||
|
self.supervisor: Supervisor | None = None
|
||||||
self.params = dict(params) if params else {}
|
self.params = dict(params) if params else {}
|
||||||
self.synchronous = bool(self.params.get("synchronous", False))
|
self.synchronous = bool(self.params.get("synchronous", False))
|
||||||
|
|
||||||
@@ -155,6 +160,19 @@ class Node:
|
|||||||
async def stop(self, app: FastAPI | None = None) -> None:
|
async def stop(self, app: FastAPI | None = None) -> None:
|
||||||
"""Undo :meth:`start`. Called before a rebuild, and must be idempotent."""
|
"""Undo :meth:`start`. Called before a rebuild, and must be idempotent."""
|
||||||
|
|
||||||
|
def _run_supervised(
|
||||||
|
self, name: str, factory: Callable[[], Coroutine[Any, Any, None]]
|
||||||
|
) -> asyncio.Task | None:
|
||||||
|
"""Run a background loop under supervision where there is one.
|
||||||
|
|
||||||
|
Returns the task only when unsupervised, since that is the one case
|
||||||
|
where the caller has to cancel it itself.
|
||||||
|
"""
|
||||||
|
if self.supervisor is not None:
|
||||||
|
self.supervisor.spawn(f"{self.id}:{name}", self.flow, factory)
|
||||||
|
return None
|
||||||
|
return asyncio.create_task(factory())
|
||||||
|
|
||||||
def report_health(self, status: str, detail: str | None = None) -> None:
|
def report_health(self, status: str, detail: str | None = None) -> None:
|
||||||
"""Say how this node's connection is doing: ok, degraded or down."""
|
"""Say how this node's connection is doing: ok, degraded or down."""
|
||||||
if self._on_health is not None:
|
if self._on_health is not None:
|
||||||
@@ -854,9 +872,7 @@ class MqttNode(Node):
|
|||||||
broker_host: str = "localhost"
|
broker_host: str = "localhost"
|
||||||
broker_port: int = 1883
|
broker_port: int = 1883
|
||||||
username: str | None = None
|
username: str | None = None
|
||||||
password: str | None = Field(
|
password: str | None = Field(default=None, json_schema_extra={"x-secret": True})
|
||||||
default=None, json_schema_extra={"x-secret": True}
|
|
||||||
)
|
|
||||||
client_id: str | None = None
|
client_id: str | None = None
|
||||||
qos: int = 0
|
qos: int = 0
|
||||||
retain: bool = False
|
retain: bool = False
|
||||||
@@ -1071,7 +1087,7 @@ class MqttNode(Node):
|
|||||||
return # Already running
|
return # Already running
|
||||||
|
|
||||||
self._stop_event = asyncio.Event()
|
self._stop_event = asyncio.Event()
|
||||||
self._subscription_task = asyncio.create_task(self._subscription_loop())
|
self._subscription_task = self._run_supervised("mqtt", self._subscription_loop)
|
||||||
logger.info(
|
logger.info(
|
||||||
"Started MQTT subscription for node '%s' to topics %s",
|
"Started MQTT subscription for node '%s' to topics %s",
|
||||||
self.name,
|
self.name,
|
||||||
@@ -1082,19 +1098,21 @@ class MqttNode(Node):
|
|||||||
"""
|
"""
|
||||||
Stop the MQTT subscription.
|
Stop the MQTT subscription.
|
||||||
|
|
||||||
Gracefully stops the background subscription task.
|
Gracefully stops the background subscription task. A supervised
|
||||||
|
subscription is cancelled with the rest of them at teardown; only an
|
||||||
|
unsupervised one is this method's to cancel.
|
||||||
"""
|
"""
|
||||||
if self._subscription_task is None:
|
if self._stop_event is None:
|
||||||
return
|
return
|
||||||
|
|
||||||
if self._stop_event:
|
self._stop_event.set()
|
||||||
self._stop_event.set()
|
|
||||||
|
|
||||||
self._subscription_task.cancel()
|
if self._subscription_task is not None:
|
||||||
try:
|
self._subscription_task.cancel()
|
||||||
await self._subscription_task
|
try:
|
||||||
except asyncio.CancelledError:
|
await self._subscription_task
|
||||||
pass
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
|
||||||
self._subscription_task = None
|
self._subscription_task = None
|
||||||
self._stop_event = None
|
self._stop_event = None
|
||||||
@@ -1105,17 +1123,21 @@ class MqttNode(Node):
|
|||||||
|
|
||||||
async def _subscription_loop(self) -> None:
|
async def _subscription_loop(self) -> None:
|
||||||
"""
|
"""
|
||||||
Background loop that listens for MQTT messages and triggers the pipeline.
|
Listen for MQTT messages and trigger the pipeline.
|
||||||
|
|
||||||
Subscribes to all unique topics from the ``topics`` mapping and
|
Subscribes to all unique topics from the ``topics`` mapping and
|
||||||
uses the reverse lookup ``_topic_to_ports`` to route incoming
|
uses the reverse lookup ``_topic_to_ports`` to route incoming
|
||||||
payloads to the correct pipeline message names.
|
payloads to the correct pipeline message names.
|
||||||
|
|
||||||
|
One connection attempt: a dropped broker raises, and the supervisor
|
||||||
|
decides when to try again. Reconnecting here as well would mean two
|
||||||
|
backoff policies fighting over the same socket.
|
||||||
"""
|
"""
|
||||||
import json
|
import json
|
||||||
|
|
||||||
import aiomqtt
|
import aiomqtt
|
||||||
|
|
||||||
while not (self._stop_event and self._stop_event.is_set()):
|
if not (self._stop_event and self._stop_event.is_set()):
|
||||||
try:
|
try:
|
||||||
async with aiomqtt.Client(
|
async with aiomqtt.Client(
|
||||||
hostname=self.broker_host,
|
hostname=self.broker_host,
|
||||||
@@ -1189,27 +1211,25 @@ class MqttNode(Node):
|
|||||||
exc_info=True,
|
exc_info=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
except aiomqtt.MqttError as e:
|
# Falling out of the message iterator without being told to
|
||||||
|
# stop means the broker went away quietly. Raising is how the
|
||||||
|
# supervisor hears about it.
|
||||||
|
if not (self._stop_event and self._stop_event.is_set()):
|
||||||
|
self.report_health("down", "subscription ended")
|
||||||
|
raise ConnectionError(
|
||||||
|
f"MQTT subscription for '{self.name}' ended unexpectedly"
|
||||||
|
)
|
||||||
|
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"MQTT connection error in node '%s': %s. Retrying in 5s...",
|
"MQTT subscription for node '%s' failed: %s", self.name, e
|
||||||
self.name,
|
|
||||||
e,
|
|
||||||
)
|
)
|
||||||
self.report_health("down", str(e))
|
self.report_health("down", str(e))
|
||||||
if not (self._stop_event and self._stop_event.is_set()):
|
if self._stop_event and self._stop_event.is_set():
|
||||||
# Reconnect after a delay
|
return
|
||||||
await asyncio.sleep(5)
|
raise
|
||||||
except asyncio.CancelledError:
|
|
||||||
break
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(
|
|
||||||
"Unexpected error in MQTT subscription for node '%s': %s. Retrying in 5s...",
|
|
||||||
self.name,
|
|
||||||
e,
|
|
||||||
exc_info=True,
|
|
||||||
)
|
|
||||||
if not (self._stop_event and self._stop_event.is_set()):
|
|
||||||
await asyncio.sleep(5)
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_subscribed(self) -> bool:
|
def is_subscribed(self) -> bool:
|
||||||
@@ -1880,22 +1900,22 @@ class DelayNode(Node):
|
|||||||
return
|
return
|
||||||
|
|
||||||
self._stop_cron = asyncio.Event()
|
self._stop_cron = asyncio.Event()
|
||||||
self._cron_task = asyncio.create_task(self._cron_loop())
|
self._cron_task = self._run_supervised("cron", self._cron_loop)
|
||||||
logger.info("Started cron for node '%s': %s", self.name, self.cron_expr)
|
logger.info("Started cron for node '%s': %s", self.name, self.cron_expr)
|
||||||
|
|
||||||
async def stop_cron(self) -> None:
|
async def stop_cron(self) -> None:
|
||||||
"""Stop the cron scheduler."""
|
"""Stop the cron scheduler."""
|
||||||
if self._cron_task is None:
|
if self._stop_cron is None:
|
||||||
return
|
return
|
||||||
|
|
||||||
if self._stop_cron:
|
self._stop_cron.set()
|
||||||
self._stop_cron.set()
|
|
||||||
|
|
||||||
self._cron_task.cancel()
|
if self._cron_task is not None:
|
||||||
try:
|
self._cron_task.cancel()
|
||||||
await self._cron_task
|
try:
|
||||||
except asyncio.CancelledError:
|
await self._cron_task
|
||||||
pass
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
|
||||||
self._cron_task = None
|
self._cron_task = None
|
||||||
self._stop_cron = None
|
self._stop_cron = None
|
||||||
@@ -1955,16 +1975,14 @@ class DelayNode(Node):
|
|||||||
await self._trigger_cron()
|
await self._trigger_cron()
|
||||||
|
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
break
|
raise
|
||||||
except Exception as e:
|
except Exception:
|
||||||
|
# One bad tick should not cost the schedule; the supervisor
|
||||||
|
# picks it up from here, backoff included.
|
||||||
logger.error(
|
logger.error(
|
||||||
"Error in cron loop for node '%s': %s",
|
"Error in cron loop for node '%s'", self.name, exc_info=True
|
||||||
self.name,
|
|
||||||
e,
|
|
||||||
exc_info=True,
|
|
||||||
)
|
)
|
||||||
# Back off on error to avoid tight loops
|
raise
|
||||||
await asyncio.sleep(60)
|
|
||||||
|
|
||||||
async def _trigger_cron(self):
|
async def _trigger_cron(self):
|
||||||
"""Emit data into the pipeline on a cron tick."""
|
"""Emit data into the pipeline on a cron tick."""
|
||||||
|
|||||||
@@ -126,6 +126,8 @@ class FlowSummary(BaseModel):
|
|||||||
has_draft: bool = False
|
has_draft: bool = False
|
||||||
enabled: bool = True
|
enabled: bool = True
|
||||||
paused: bool = False
|
paused: bool = False
|
||||||
|
# Its background tasks kept crashing, so the engine stopped restarting them.
|
||||||
|
quarantined: bool = False
|
||||||
|
|
||||||
|
|
||||||
class FlowsPublic(BaseModel):
|
class FlowsPublic(BaseModel):
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
"""Supervision for the long-lived tasks a pipeline starts.
|
||||||
|
|
||||||
|
A node's subscription, schedule or poll loop is an `asyncio.Task`, and a task
|
||||||
|
that raises is simply gone — the node stays listed as running while nothing
|
||||||
|
listens any more. The supervisor restarts those tasks with a growing delay and
|
||||||
|
gives up on a flow that keeps failing, because a flow crash-looping every
|
||||||
|
second is worse than a flow that is visibly stopped.
|
||||||
|
|
||||||
|
Deliberately a plain task registry rather than a `TaskGroup`: a group cancels
|
||||||
|
its siblings when one member fails, which is the opposite of what supervision
|
||||||
|
means here.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from collections import deque
|
||||||
|
from collections.abc import Callable, Coroutine
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.flow.events import EventBus
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
BACKOFF = (1.0, 5.0, 30.0, 60.0)
|
||||||
|
# A flow that burns through this many restarts in the window is not going to
|
||||||
|
# recover by being restarted again.
|
||||||
|
FAILURE_BUDGET = 5
|
||||||
|
FAILURE_WINDOW = 300.0
|
||||||
|
|
||||||
|
TaskFactory = Callable[[], Coroutine[Any, Any, None]]
|
||||||
|
|
||||||
|
|
||||||
|
class Supervisor:
|
||||||
|
"""Keeps the pipeline's background tasks alive, or admits it cannot."""
|
||||||
|
|
||||||
|
def __init__(self, events: EventBus | None = None) -> None:
|
||||||
|
self._events = events
|
||||||
|
self._tasks: dict[str, asyncio.Task[None]] = {}
|
||||||
|
self._failures: dict[str, deque[float]] = {}
|
||||||
|
self.quarantined: set[str] = set()
|
||||||
|
|
||||||
|
def spawn(self, name: str, flow: str, factory: TaskFactory) -> None:
|
||||||
|
"""Run `factory()` and keep running it until told to stop."""
|
||||||
|
if name in self._tasks:
|
||||||
|
return
|
||||||
|
self._tasks[name] = asyncio.create_task(
|
||||||
|
self._supervise(name, flow, factory), name=f"supervised:{name}"
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _supervise(self, name: str, flow: str, factory: TaskFactory) -> None:
|
||||||
|
attempt = 0
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
await factory()
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
if not self._record_failure(name, flow, exc):
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
# A clean return means the loop decided it was done.
|
||||||
|
return
|
||||||
|
|
||||||
|
delay = BACKOFF[min(attempt, len(BACKOFF) - 1)]
|
||||||
|
attempt += 1
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
|
||||||
|
def _record_failure(self, name: str, flow: str, exc: Exception) -> bool:
|
||||||
|
"""Note the crash; False when the flow has spent its budget."""
|
||||||
|
logger.warning("Supervised task '%s' crashed: %s", name, exc, exc_info=True)
|
||||||
|
self._publish(
|
||||||
|
{
|
||||||
|
"type": "task_crashed",
|
||||||
|
"task": name,
|
||||||
|
"flow": flow,
|
||||||
|
"error": f"{type(exc).__name__}: {exc}",
|
||||||
|
"ts": time.time(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
now = time.monotonic()
|
||||||
|
window = self._failures.setdefault(flow, deque())
|
||||||
|
window.append(now)
|
||||||
|
while window and window[0] < now - FAILURE_WINDOW:
|
||||||
|
window.popleft()
|
||||||
|
|
||||||
|
if len(window) < FAILURE_BUDGET:
|
||||||
|
return True
|
||||||
|
|
||||||
|
logger.error(
|
||||||
|
"Flow '%s' crashed %d times in %.0fs — quarantined",
|
||||||
|
flow,
|
||||||
|
len(window),
|
||||||
|
FAILURE_WINDOW,
|
||||||
|
)
|
||||||
|
self.quarantined.add(flow)
|
||||||
|
self._publish(
|
||||||
|
{
|
||||||
|
"type": "flow_quarantined",
|
||||||
|
"flow": flow,
|
||||||
|
"error": f"{type(exc).__name__}: {exc}",
|
||||||
|
"ts": time.time(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def cancel_all(self) -> None:
|
||||||
|
"""Stop supervising. Idempotent, and safe to call mid-restart."""
|
||||||
|
tasks = list(self._tasks.values())
|
||||||
|
self._tasks.clear()
|
||||||
|
for task in tasks:
|
||||||
|
task.cancel()
|
||||||
|
for task in tasks:
|
||||||
|
try:
|
||||||
|
await task
|
||||||
|
except (asyncio.CancelledError, Exception): # noqa: B014 - shutting down
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _publish(self, event: dict[str, Any]) -> None:
|
||||||
|
if self._events is not None:
|
||||||
|
self._events.publish(event)
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
"""Supervision: a background task that dies gets restarted, until it is hopeless."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.flow import supervision
|
||||||
|
from app.flow.events import EventBus
|
||||||
|
from app.flow.supervision import FAILURE_BUDGET, Supervisor
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def no_backoff(monkeypatch: pytest.MonkeyPatch):
|
||||||
|
"""Real backoff would make these tests a minute long."""
|
||||||
|
monkeypatch.setattr(supervision, "BACKOFF", (0.0,))
|
||||||
|
|
||||||
|
|
||||||
|
async def _settle() -> None:
|
||||||
|
"""Let the supervisor work through its restarts."""
|
||||||
|
for _ in range(50):
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_crashing_loop_is_restarted():
|
||||||
|
attempts = 0
|
||||||
|
|
||||||
|
async def loop() -> None:
|
||||||
|
nonlocal attempts
|
||||||
|
attempts += 1
|
||||||
|
if attempts < 3:
|
||||||
|
raise ConnectionError("broker went away")
|
||||||
|
|
||||||
|
async def scenario() -> None:
|
||||||
|
supervisor = Supervisor()
|
||||||
|
supervisor.spawn("mqtt", "heating", loop)
|
||||||
|
await _settle()
|
||||||
|
|
||||||
|
assert attempts == 3
|
||||||
|
assert supervisor.quarantined == set()
|
||||||
|
await supervisor.cancel_all()
|
||||||
|
|
||||||
|
asyncio.run(scenario())
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_loop_that_returns_is_left_alone():
|
||||||
|
"""Returning is how a loop says it is finished, not that it failed."""
|
||||||
|
attempts = 0
|
||||||
|
|
||||||
|
async def loop() -> None:
|
||||||
|
nonlocal attempts
|
||||||
|
attempts += 1
|
||||||
|
|
||||||
|
async def scenario() -> None:
|
||||||
|
supervisor = Supervisor()
|
||||||
|
supervisor.spawn("cron", "heating", loop)
|
||||||
|
await _settle()
|
||||||
|
|
||||||
|
assert attempts == 1
|
||||||
|
await supervisor.cancel_all()
|
||||||
|
|
||||||
|
asyncio.run(scenario())
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_flow_that_keeps_crashing_is_quarantined():
|
||||||
|
attempts = 0
|
||||||
|
events: list[dict] = []
|
||||||
|
bus = EventBus()
|
||||||
|
bus.publish = events.append # type: ignore[method-assign]
|
||||||
|
|
||||||
|
async def loop() -> None:
|
||||||
|
nonlocal attempts
|
||||||
|
attempts += 1
|
||||||
|
raise RuntimeError("nope")
|
||||||
|
|
||||||
|
async def scenario() -> None:
|
||||||
|
supervisor = Supervisor(bus)
|
||||||
|
supervisor.spawn("mqtt", "heating", loop)
|
||||||
|
await _settle()
|
||||||
|
|
||||||
|
# Restarting stops once the budget is gone, rather than spinning forever.
|
||||||
|
assert attempts == FAILURE_BUDGET
|
||||||
|
assert supervisor.quarantined == {"heating"}
|
||||||
|
assert [e["type"] for e in events].count("task_crashed") == FAILURE_BUDGET
|
||||||
|
assert [e["type"] for e in events][-1] == "flow_quarantined"
|
||||||
|
await supervisor.cancel_all()
|
||||||
|
|
||||||
|
asyncio.run(scenario())
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_budget_is_per_flow():
|
||||||
|
ran = 0
|
||||||
|
|
||||||
|
async def failing() -> None:
|
||||||
|
raise RuntimeError("nope")
|
||||||
|
|
||||||
|
async def healthy() -> None:
|
||||||
|
nonlocal ran
|
||||||
|
ran += 1
|
||||||
|
await asyncio.sleep(3600)
|
||||||
|
|
||||||
|
async def scenario() -> None:
|
||||||
|
supervisor = Supervisor()
|
||||||
|
supervisor.spawn("a", "broken", failing)
|
||||||
|
supervisor.spawn("b", "fine", healthy)
|
||||||
|
await _settle()
|
||||||
|
|
||||||
|
assert supervisor.quarantined == {"broken"}
|
||||||
|
assert ran == 1
|
||||||
|
await supervisor.cancel_all()
|
||||||
|
|
||||||
|
asyncio.run(scenario())
|
||||||
|
|
||||||
|
|
||||||
|
def test_cancel_all_is_idempotent():
|
||||||
|
async def loop() -> None:
|
||||||
|
await asyncio.sleep(3600)
|
||||||
|
|
||||||
|
async def scenario() -> None:
|
||||||
|
supervisor = Supervisor()
|
||||||
|
supervisor.spawn("mqtt", "heating", loop)
|
||||||
|
await _settle()
|
||||||
|
|
||||||
|
await supervisor.cancel_all()
|
||||||
|
await supervisor.cancel_all()
|
||||||
|
|
||||||
|
asyncio.run(scenario())
|
||||||
@@ -364,6 +364,11 @@ export const FlowSummarySchema = {
|
|||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
title: 'Paused',
|
title: 'Paused',
|
||||||
default: false
|
default: false
|
||||||
|
},
|
||||||
|
quarantined: {
|
||||||
|
type: 'boolean',
|
||||||
|
title: 'Quarantined',
|
||||||
|
default: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
type: 'object',
|
type: 'object',
|
||||||
|
|||||||
@@ -101,6 +101,7 @@ export type FlowSummary = {
|
|||||||
has_draft?: boolean;
|
has_draft?: boolean;
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
paused?: boolean;
|
paused?: boolean;
|
||||||
|
quarantined?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user