Let a quarantine expire, and stop two tasks spending one budget
Docs / docs (push) Successful in 23s
Playwright Tests / test-playwright (1, 2) (push) Successful in 3m1s
Playwright Tests / test-playwright (2, 2) (push) Successful in 1m44s
pre-commit / pre-commit (push) Failing after 2m50s
Test Backend / test-backend (push) Successful in 2m39s
Compose Smoke Test / test-compose (push) Successful in 31s
Playwright Tests / merge-reports (push) Successful in 1m9s

A house's inverter broker dropped at 04:27 and the power flow was quarantined
20 seconds later. Quarantine was terminal — the supervised task returned and
only a publish or an engine restart could bring it back — so five hours of
power and battery readings are missing, and what ended it was an unrelated
`git pull` restarting uvicorn.

Two changes, both in that path:

- the failure budget is per task, not per flow. `power` runs an MQTT subscriber
  and a Victron keepalive publisher against the same broker; they died together
  and spent one shared budget in 41s, giving up before the 60s backoff step was
  ever reached.
- quarantine is now a rest. The task sits out 5min, then 15, then an hour, and
  each time gets its budget back and tries again, so a broker that comes back
  is picked up without anyone watching. `quarantined` reads from whichever
  tasks are currently resting.

The alert for it says when it will try again.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C5H4uLCCpsbipL1R7WKCee
This commit is contained in:
2026-08-28 10:35:13 +02:00
co-authored by Claude Opus 5
parent e40bbb9245
commit 8f1e685526
3 changed files with 140 additions and 36 deletions
+3 -1
View File
@@ -114,10 +114,12 @@ def describe(event: dict[str, Any]) -> Alert | None:
node=node, node=node,
) )
if kind == "flow_quarantined": if kind == "flow_quarantined":
retry = event.get("retry_in_s")
again = f" Trying again in {round(float(retry) / 60)} min." if retry else ""
return Alert( return Alert(
title=f"Flow '{flow}' was quarantined", title=f"Flow '{flow}' was quarantined",
body=( body=(
f"It kept crashing, so the engine stopped restarting it. {error}" f"It kept crashing, so the engine stood it down. {error}{again}"
).strip(), ).strip(),
flow=flow, flow=flow,
) )
+71 -30
View File
@@ -3,9 +3,15 @@
A node's subscription, schedule or poll loop is an `asyncio.Task`, and a task 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 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 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 stands a flow down when one keeps failing, because a flow crash-looping every
second is worse than a flow that is visibly stopped. second is worse than a flow that is visibly stopped.
Standing down is a rest, not a verdict. It used to be the end of the task: the
flow stayed dead until somebody published it or the engine restarted. On a
house that runs unattended that turns a broker rebooting at four in the morning
into a hole in the record until someone notices — so the rest expires, the
budget comes back, and the task tries again on a much longer clock.
Deliberately a plain task registry rather than a `TaskGroup`: a group cancels 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 its siblings when one member fails, which is the opposite of what supervision
means here. means here.
@@ -25,10 +31,20 @@ from fluksio.flow.events import EventBus
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
BACKOFF = (1.0, 5.0, 30.0, 60.0) BACKOFF = (1.0, 5.0, 30.0, 60.0)
# A flow that burns through this many restarts in the window is not going to # A task that burns through this many restarts in the window is not going to
# recover by being restarted again. # recover by being restarted again *now*.
#
# Per task rather than per flow: a flow whose subscriber and its keepalive
# publisher both go down with the same broker spends one shared budget twice as
# fast as it was sized for, and gives up before the backoff above has reached
# its longest step. That is what took the house's inverter readings out for
# nearly five hours — six crashes across two tasks inside 41 seconds.
FAILURE_BUDGET = 5 FAILURE_BUDGET = 5
FAILURE_WINDOW = 300.0 FAILURE_WINDOW = 300.0
# How long a task sits out before its budget is handed back. Minutes rather
# than seconds because a device that is really gone should not be hammered, and
# capped in the hour because nothing here is worth losing a day of readings to.
QUARANTINE_BACKOFF = (300.0, 900.0, 3600.0)
# How long a cancelled task gets to notice. A loop that is still waiting after # 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 # 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 # no longer answers on is the case seen in the wild — and the rebuild asking
@@ -48,8 +64,15 @@ class Supervisor:
#: task's name, because names are a flow and a node joined by a dot and #: task's name, because names are a flow and a node joined by a dot and
#: matching on that prefix would let 'hea' cancel 'heating'. #: matching on that prefix would let 'hea' cancel 'heating'.
self._flows: dict[str, str] = {} self._flows: dict[str, str] = {}
#: Crash times per *task*, keyed by task name.
self._failures: dict[str, deque[float]] = {} self._failures: dict[str, deque[float]] = {}
self.quarantined: set[str] = set() #: The tasks sitting out a quarantine, and the flow each belongs to.
self._resting: dict[str, str] = {}
@property
def quarantined(self) -> set[str]:
"""The flows with at least one task currently standing down."""
return set(self._resting.values())
def spawn(self, name: str, flow: str, factory: TaskFactory) -> None: def spawn(self, name: str, flow: str, factory: TaskFactory) -> None:
"""Run `factory()` and keep running it until told to stop.""" """Run `factory()` and keep running it until told to stop."""
@@ -62,6 +85,7 @@ class Supervisor:
async def _supervise(self, name: str, flow: str, factory: TaskFactory) -> None: async def _supervise(self, name: str, flow: str, factory: TaskFactory) -> None:
attempt = 0 attempt = 0
rests = 0
while True: while True:
try: try:
await factory() await factory()
@@ -69,7 +93,10 @@ class Supervisor:
raise raise
except Exception as exc: except Exception as exc:
if not self._record_failure(name, flow, exc): if not self._record_failure(name, flow, exc):
return await self._rest(name, flow, exc, rests)
rests += 1
attempt = 0
continue
else: else:
# A clean return means the loop decided it was done. # A clean return means the loop decided it was done.
return return
@@ -78,8 +105,37 @@ class Supervisor:
attempt += 1 attempt += 1
await asyncio.sleep(delay) await asyncio.sleep(delay)
async def _rest(self, name: str, flow: str, exc: Exception, rests: int) -> None:
"""Stand the task down, then hand its budget back and let it retry."""
delay = QUARANTINE_BACKOFF[min(rests, len(QUARANTINE_BACKOFF) - 1)]
logger.error(
"Flow '%s' crashed %d times in %.0fs — quarantined, retrying in %.0fs",
flow,
FAILURE_BUDGET,
FAILURE_WINDOW,
delay,
)
self._resting[name] = flow
self._publish(
{
"type": "flow_quarantined",
"flow": flow,
"task": name,
"error": f"{type(exc).__name__}: {exc}",
"retry_in_s": delay,
"ts": time.time(),
}
)
try:
await asyncio.sleep(delay)
finally:
# Both, and in this order: a task woken by a cancellation is being
# torn down, and must not leave the flow looking quarantined.
self._resting.pop(name, None)
self._failures.pop(name, None)
def _record_failure(self, name: str, flow: str, exc: Exception) -> bool: def _record_failure(self, name: str, flow: str, exc: Exception) -> bool:
"""Note the crash; False when the flow has spent its budget.""" """Note the crash; False when this task has spent its budget."""
logger.warning("Supervised task '%s' crashed: %s", name, exc, exc_info=True) logger.warning("Supervised task '%s' crashed: %s", name, exc, exc_info=True)
self._publish( self._publish(
{ {
@@ -92,36 +148,20 @@ class Supervisor:
) )
now = time.monotonic() now = time.monotonic()
window = self._failures.setdefault(flow, deque()) window = self._failures.setdefault(name, deque())
window.append(now) window.append(now)
while window and window[0] < now - FAILURE_WINDOW: while window and window[0] < now - FAILURE_WINDOW:
window.popleft() window.popleft()
if len(window) < FAILURE_BUDGET: return 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: async def cancel_all(self) -> None:
"""Stop supervising. Idempotent, and safe to call mid-restart.""" """Stop supervising. Idempotent, and safe to call mid-restart."""
tasks = list(self._tasks.values()) tasks = list(self._tasks.values())
self._tasks.clear() self._tasks.clear()
self._flows.clear() self._flows.clear()
self._resting.clear()
self._failures.clear()
await self._cancel(tasks) await self._cancel(tasks)
async def cancel_flow(self, flow: str) -> None: async def cancel_flow(self, flow: str) -> None:
@@ -136,11 +176,12 @@ class Supervisor:
tasks = [self._tasks.pop(name) for name in names if name in self._tasks] tasks = [self._tasks.pop(name) for name in names if name in self._tasks]
for name in names: for name in names:
del self._flows[name] del self._flows[name]
# The clean slate: whatever these tasks spent before, the build
# that follows starts their budgets again — and a rebuild is not
# made to wait out a rest somebody has just fixed the cause of.
self._failures.pop(name, None)
self._resting.pop(name, None)
await self._cancel(tasks) await self._cancel(tasks)
# The clean slate: whatever this flow spent before, the build that
# follows starts its budget again.
self.quarantined.discard(flow)
self._failures.pop(flow, None)
async def _cancel(self, tasks: list[asyncio.Task[None]]) -> None: async def _cancel(self, tasks: list[asyncio.Task[None]]) -> None:
"""Ask these tasks to stop, and wait no longer than the grace period.""" """Ask these tasks to stop, and wait no longer than the grace period."""
+66 -5
View File
@@ -13,6 +13,9 @@ from fluksio.flow.supervision import FAILURE_BUDGET, Supervisor
def no_backoff(monkeypatch: pytest.MonkeyPatch): def no_backoff(monkeypatch: pytest.MonkeyPatch):
"""Real backoff would make these tests a minute long.""" """Real backoff would make these tests a minute long."""
monkeypatch.setattr(supervision, "BACKOFF", (0.0,)) monkeypatch.setattr(supervision, "BACKOFF", (0.0,))
# Long enough that a quarantine holds for the length of an assertion, short
# enough that the test asking it to expire does not wait five minutes.
monkeypatch.setattr(supervision, "QUARANTINE_BACKOFF", (0.05,))
async def _settle() -> None: async def _settle() -> None:
@@ -61,7 +64,7 @@ def test_a_loop_that_returns_is_left_alone():
asyncio.run(scenario()) asyncio.run(scenario())
def test_a_flow_that_keeps_crashing_is_quarantined(): def test_a_flow_that_keeps_crashing_is_stood_down():
attempts = 0 attempts = 0
events: list[dict] = [] events: list[dict] = []
bus = EventBus() bus = EventBus()
@@ -81,13 +84,14 @@ def test_a_flow_that_keeps_crashing_is_quarantined():
assert attempts == FAILURE_BUDGET assert attempts == FAILURE_BUDGET
assert supervisor.quarantined == {"heating"} assert supervisor.quarantined == {"heating"}
assert [e["type"] for e in events].count("task_crashed") == FAILURE_BUDGET assert [e["type"] for e in events].count("task_crashed") == FAILURE_BUDGET
assert [e["type"] for e in events][-1] == "flow_quarantined" quarantine = [e for e in events if e["type"] == "flow_quarantined"][-1]
assert quarantine["retry_in_s"] == 0.05
await supervisor.cancel_all() await supervisor.cancel_all()
asyncio.run(scenario()) asyncio.run(scenario())
def test_the_budget_is_per_flow(): def test_one_flows_failure_does_not_stand_another_down():
ran = 0 ran = 0
async def failing() -> None: async def failing() -> None:
@@ -111,6 +115,63 @@ def test_the_budget_is_per_flow():
asyncio.run(scenario()) asyncio.run(scenario())
def test_two_tasks_on_one_flow_do_not_spend_each_others_budget():
"""The house's power flow: an MQTT subscriber and its keepalive publisher.
Both die with the same broker. Sharing one budget meant the flow was stood
down after half the restarts either task was allowed on its own.
"""
tries = {"sub": 0, "pub": 0}
def crasher(which: str):
async def loop() -> None:
tries[which] += 1
raise ConnectionError("broker went away")
return loop
async def scenario() -> None:
supervisor = Supervisor()
supervisor.spawn("power.cerbo:mqtt", "power", crasher("sub"))
supervisor.spawn("power.keepalive:mqtt-out", "power", crasher("pub"))
await _settle()
assert tries == {"sub": FAILURE_BUDGET, "pub": FAILURE_BUDGET}
await supervisor.cancel_all()
asyncio.run(scenario())
def test_a_quarantine_expires_and_the_task_tries_again():
"""The whole point: a broker that comes back is picked up without a human."""
attempts = 0
async def loop() -> None:
nonlocal attempts
attempts += 1
if attempts <= FAILURE_BUDGET:
raise ConnectionError("broker went away")
await asyncio.sleep(3600)
async def scenario() -> None:
supervisor = Supervisor()
supervisor.spawn("mqtt", "power", loop)
await _settle()
assert attempts == FAILURE_BUDGET
assert supervisor.quarantined == {"power"}
# Sit out the rest, which the fixture has shortened to 50ms.
await asyncio.sleep(0.1)
await _settle()
assert attempts == FAILURE_BUDGET + 1
assert supervisor.quarantined == set()
await supervisor.cancel_all()
asyncio.run(scenario())
def test_cancel_all_is_idempotent(): def test_cancel_all_is_idempotent():
async def loop() -> None: async def loop() -> None:
await asyncio.sleep(3600) await asyncio.sleep(3600)
@@ -165,8 +226,8 @@ def test_cancelling_one_flow_lifts_only_its_quarantine():
assert supervisor.quarantined == {"also-broken"} assert supervisor.quarantined == {"also-broken"}
# And its budget, or the flow would be quarantined again on the first # And its budget, or the flow would be quarantined again on the first
# crash after the rebuild that was meant to fix it. # crash after the rebuild that was meant to fix it.
assert "broken" not in supervisor._failures assert "a" not in supervisor._failures
assert "also-broken" in supervisor._failures assert "b" in supervisor._failures
await supervisor.cancel_all() await supervisor.cancel_all()
asyncio.run(scenario()) asyncio.run(scenario())