A node's stop() and a supervised task's cancellation are both waited on inside the rebuild lock, and neither had a deadline: an MQTT client whose broker never acknowledges the disconnect leaves aiomqtt's __aexit__ waiting forever, so reload() never returned and every start, stop or publish behind it hung until the container was restarted. Each node now gets five seconds to close and is abandoned after that, and cancel_all reports what is still running rather than waiting on it — it also no longer swallows a cancellation aimed at the caller, which used to make the lock holder unkillable. A rebuild asked for by a request gives up on the lock after fifteen seconds with RebuildBusy, answered as a 503. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01StpRc2C6au1WJ1EUU7fsfu
147 lines
5.3 KiB
Python
147 lines
5.3 KiB
Python
"""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 fluksio.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
|
|
# How long a cancelled task gets to notice. A loop that is still waiting after
|
|
# this is not going to stop on its own — a client closing a socket the broker
|
|
# no longer answers on is the case seen in the wild — and the rebuild asking
|
|
# for it must not wait on that forever.
|
|
CANCEL_GRACE = 5.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()
|
|
if not tasks:
|
|
return
|
|
# `wait` hands back what is still going instead of waiting on it, and
|
|
# lets a cancellation aimed at *this* coroutine through — the
|
|
# `except CancelledError` it replaces swallowed that, which left
|
|
# whoever asked for the teardown holding their lock and unkillable.
|
|
done, pending = await asyncio.wait(tasks, timeout=CANCEL_GRACE)
|
|
for task in done:
|
|
if not task.cancelled():
|
|
# Retrieved so a crash on the way out is not reported at exit;
|
|
# the supervisor has already said what it was.
|
|
task.exception()
|
|
for task in pending:
|
|
# Cancelled once and still running means its shutdown is waiting on
|
|
# something that is not answering. A second cancellation interrupts
|
|
# that wait; whether it takes is no longer the rebuild's problem.
|
|
task.cancel()
|
|
logger.warning(
|
|
"Supervised task '%s' did not stop within %.0fs — abandoned",
|
|
task.get_name(),
|
|
CANCEL_GRACE,
|
|
)
|
|
|
|
def _publish(self, event: dict[str, Any]) -> None:
|
|
if self._events is not None:
|
|
self._events.publish(event)
|