Supervise the engine's host: deep health, loop watchdog, one worker

The API image ran four uvicorn workers, and each one built a full flow
controller — four sets of MQTT subscriptions, cron ticks and webhooks.
Runs one worker now; scaling out is the worker split, not more processes.

Adds a loop-lag watchdog and a deep /utils/health/ that fails when the
event loop is wedged or Redis is unreachable, the two failure modes a
process-alive check never sees. Autoheal restarts on that signal, behind
a compose profile because it mounts the Docker socket.

The private user-seeding routes now need an explicit opt-in rather than
just ENVIRONMENT=local, so a deployment that kept the default never
exposes them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011LF61rxW1FG5YCD2J9YqjY
This commit is contained in:
root
2026-08-16 07:14:28 +02:00
co-authored by Claude Fable 5
parent 8d82d6c4ec
commit 5462842b8a
16 changed files with 325 additions and 13 deletions
+7 -4
View File
@@ -14,6 +14,9 @@ COMPOSE_PROJECT := fluksio-app
# Compose interpolation needs the project-local .env before reading compose.yml.
COMPOSE := docker compose -p $(COMPOSE_PROJECT) --env-file $(COMPOSE_ROOT)/.env
COMPOSE_PROD := $(COMPOSE) -f docker/compose.yml
# Production also runs autoheal, which restarts the backend when its deep
# health check fails. It is profile-gated because it mounts the Docker socket.
COMPOSE_PROD_RUN := $(COMPOSE_PROD) --profile autoheal
COMPOSE_DEV := $(COMPOSE_PROD) -f docker/compose.dev.yml
# Integrated local stack: dev stack wired onto the shared `proxy` network.
COMPOSE_LOCAL := $(COMPOSE_DEV) -f docker/compose.local.yml
@@ -34,17 +37,17 @@ dev-local: ## Start the integrated local stack (called by the root `make dev`)
$(COMPOSE_LOCAL) up --build -d proxy db adminer prestart backend frontend mailcatcher
up: ## Start the production stack
$(COMPOSE_PROD) up --build -d
$(COMPOSE_PROD_RUN) up --build -d
update: ## Pull, rebuild using the layer cache, and recreate changed containers
git pull
$(COMPOSE_PROD) build
$(COMPOSE_PROD) up -d --remove-orphans
$(COMPOSE_PROD_RUN) build
$(COMPOSE_PROD_RUN) up -d --remove-orphans
docker image prune -f
down: ## Stop all running containers
-$(COMPOSE_LOCAL) down
-$(COMPOSE_PROD) down
-$(COMPOSE_PROD_RUN) down
# ── Development (local, no Docker) ───────────────────────────────
# Run `make dev-backend` and `make dev-frontend` in two separate terminals.
+4
View File
@@ -72,6 +72,10 @@ external interfaces. See `docs/architecture/structure.canvas` → *Backend M
wakes its node, at most every n seconds. State keeps the latest value, so
only the delivery is skipped
- [ ] Alert / notification handler
- [x] Deep health check (`GET /utils/health/`): reports event-loop lag and state-backend
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
worker, because a second one would be a second engine
- [ ] Test nodes: a small node dragged onto an existing one, smoke or unit, blocking
deployment on failure
- [ ] User management scoped per flow and per data set
+4 -1
View File
@@ -42,4 +42,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
WORKDIR /app/backend/
CMD ["fastapi", "run", "--workers", "4", "app/main.py"]
# Single worker on purpose: the process hosts the flow engine, and a second
# worker would be a second engine — duplicated subscriptions, cron ticks and
# webhooks. Scaling out is the M5 worker split, not more uvicorn processes.
CMD ["fastapi", "run", "app/main.py"]
+2 -2
View File
@@ -1,7 +1,6 @@
from fastapi import APIRouter
from app.api.routes import flows, login, oauth, private, secrets, users, utils
from app.core.config import settings
api_router = APIRouter()
api_router.include_router(login.router)
@@ -15,5 +14,6 @@ api_router.include_router(secrets.router)
api_router.include_router(oauth.router)
if settings.ENVIRONMENT == "local":
# Always mounted like oauth, so the generated SDK keeps its shape; the
# endpoints refuse to work unless the private API is explicitly enabled.
api_router.include_router(private.router)
+8 -1
View File
@@ -1,9 +1,10 @@
from typing import Any
from fastapi import APIRouter
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from app.api.deps import SessionDep
from app.core.config import settings
from app.core.security import get_password_hash
from app.models import (
User,
@@ -13,6 +14,11 @@ from app.models import (
router = APIRouter(tags=["private"], prefix="/private")
def _require_private_api() -> None:
if not (settings.ENVIRONMENT == "local" and settings.PRIVATE_API_ENABLED):
raise HTTPException(status_code=403, detail="Private API is disabled")
class PrivateUserCreate(BaseModel):
email: str
password: str
@@ -25,6 +31,7 @@ def create_user(user_in: PrivateUserCreate, session: SessionDep) -> Any:
"""
Create a new user.
"""
_require_private_api()
user = User(
email=user_in.email,
+64 -1
View File
@@ -1,7 +1,12 @@
from fastapi import APIRouter, Depends
import time
from typing import Any
from fastapi import APIRouter, Depends, Request, Response
from fastapi.concurrency import run_in_threadpool
from pydantic.networks import EmailStr
from app.api.deps import get_current_active_superuser
from app.flow.state import RedisState
from app.models import Message
from app.utils import generate_test_email, send_email
@@ -29,3 +34,61 @@ def test_email(email_to: EmailStr) -> Message:
@router.get("/health-check/")
async def health_check() -> bool:
return True
@router.get("/health/")
async def health(request: Request, response: Response) -> dict[str, Any]:
"""Deep health: 200 while the engine can serve its purpose, 503 otherwise.
"Serve its purpose" means the event loop is responsive and the configured
state backend answers — the two failure modes a process-alive check never
sees. The queue and pool sections are filled by the execution service.
"""
controller = getattr(request.app.state, "flow_controller", None)
watchdog = getattr(request.app.state, "watchdog", None)
problems: list[str] = []
loop_lag = watchdog.snapshot() if watchdog else {"ewma": 0.0, "max_60s": 0.0}
if watchdog is not None and watchdog.degraded:
problems.append("event loop lagging")
redis_info: dict[str, Any] = {
"configured": False,
"connected": None,
"rtt_ms": None,
}
engine: dict[str, Any] = {"flows": 0, "nodes": 0, "quarantined": []}
queue: dict[str, Any] = {}
if controller is not None:
state = controller.state
if isinstance(state, RedisState):
redis_info["configured"] = True
start = time.perf_counter()
connected = await run_in_threadpool(state.ping)
redis_info["connected"] = connected
redis_info["rtt_ms"] = round((time.perf_counter() - start) * 1000, 1)
if not connected:
problems.append("redis unreachable")
engine["flows"] = len(getattr(controller, "loaded", {}) or {})
engine["nodes"] = len(
getattr(getattr(controller, "pipeline", None), "nodes", []) or []
)
engine["quarantined"] = sorted(getattr(controller, "quarantined", ()) or ())
stats = getattr(controller, "queue_stats", None)
if callable(stats):
queue = await run_in_threadpool(stats)
if queue.get("oldest_pending_s", 0) > 120:
problems.append("queue stalled")
status = "degraded" if problems else "ok"
if problems:
response.status_code = 503
return {
"status": status,
"problems": problems,
"loop_lag_ms": loop_lag,
"redis": redis_info,
"engine": engine,
"queue": queue,
"ts": time.time(),
}
+4
View File
@@ -44,6 +44,10 @@ class Settings(BaseSettings):
# The MCP endpoint, and the OAuth server agents authenticate against. Off
# until someone asks for it: it opens client registration to the network.
MCP_ENABLED: bool = False
# Unauthenticated test-only endpoints (user seeding). Requires an explicit
# opt-in on top of ENVIRONMENT=local, so a deployment that merely kept the
# default environment never exposes them.
PRIVATE_API_ENABLED: bool = False
DOMAIN: str = "localhost"
OAUTH_PRIVATE_KEY_FILE: Path = Path("flow-data/oauth-key.pem")
OAUTH_CODE_EXPIRE_SECONDS: int = 60
+83
View File
@@ -0,0 +1,83 @@
"""Engine self-observation: event-loop lag watchdog and the deliberate exit.
Docker's restart policy only fires when the process exits, so a wedged event
loop would otherwise stay "up" forever. The watchdog measures how late a
sleeping task wakes up — the standard loop-lag trick — and feeds the deep
health endpoint; `engine_fatal` is the deliberate handoff to the outer
supervisor when a clean restart beats limping on.
"""
from __future__ import annotations
import asyncio
import logging
import os
import time
from collections import deque
from app.flow.events import EventBus
logger = logging.getLogger(__name__)
INTERVAL = 1.0
EWMA_ALPHA = 0.2
# One late wake-up is a busy moment; several in a row is a blocked loop.
DEGRADED_LAG_S = 5.0
DEGRADED_STRIKES = 3
# Sustained lag above this marks the engine degraded in /health.
DEGRADED_EWMA_MS = 200.0
class LoopWatchdog:
"""Measures event-loop lag and reports it as engine health."""
def __init__(self, events: EventBus | None = None) -> None:
self._events = events
self.ewma_ms = 0.0
self._window: deque[tuple[float, float]] = deque() # (monotonic ts, lag ms)
self._strikes = 0
async def run(self) -> None:
while True:
before = time.monotonic()
await asyncio.sleep(INTERVAL)
self._record(max(0.0, time.monotonic() - before - INTERVAL))
def _record(self, lag_s: float) -> None:
lag_ms = lag_s * 1000.0
self.ewma_ms += EWMA_ALPHA * (lag_ms - self.ewma_ms)
now = time.monotonic()
self._window.append((now, lag_ms))
while self._window and self._window[0][0] < now - 60.0:
self._window.popleft()
if lag_s >= DEGRADED_LAG_S:
self._strikes += 1
if self._strikes == DEGRADED_STRIKES and self._events is not None:
logger.warning("event loop lagging: %.1fs late", lag_s)
self._events.publish(
{
"type": "engine_degraded",
"reason": f"event loop lag {lag_s:.1f}s",
"ts": time.time(),
}
)
else:
self._strikes = 0
@property
def degraded(self) -> bool:
return self.ewma_ms > DEGRADED_EWMA_MS
def snapshot(self) -> dict[str, float]:
return {
"ewma": round(self.ewma_ms, 1),
"max_60s": round(max((lag for _, lag in self._window), default=0.0), 1),
}
def engine_fatal(reason: str, events: EventBus | None = None) -> None:
"""Log, tell whoever still listens, and exit so Docker restarts us clean."""
logger.critical("engine fatal: %s", reason)
if events is not None:
events.publish({"type": "engine_fatal", "reason": reason, "ts": time.time()})
os._exit(1)
+5
View File
@@ -19,6 +19,7 @@ from app.flow.plugins import load_plugins
from app.flow.secrets import init_secrets
from app.flow.state import MemoryState, RedisState, StateBackend
from app.flow.store import FlowStore
from app.flow.watchdog import LoopWatchdog
def custom_generate_unique_id(route: APIRoute) -> str:
@@ -62,6 +63,9 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
fastapi_app=app,
)
app.state.flow_controller = controller
watchdog = LoopWatchdog(event_bus)
app.state.watchdog = watchdog
watchdog_task = asyncio.create_task(watchdog.run(), name="loop-watchdog")
await controller.start()
try:
# A mounted sub-app gets no lifespan of its own, so the MCP session
@@ -69,6 +73,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
async with _mcp_sessions():
yield
finally:
watchdog_task.cancel()
await controller.stop()
if settings.MCP_ENABLED:
from app.mcp.http import aclose
+3
View File
@@ -13,3 +13,6 @@ os.environ["POSTGRES_DB"] = "app_test"
# builds a TestClient — and so a lifespan — per test module. Tests that want the
# endpoint mount it themselves.
os.environ["MCP_ENABLED"] = "false"
# The private seeding endpoints are opt-in; the suite is one of the two places
# (with the dev stack) where they are meant to work.
os.environ["PRIVATE_API_ENABLED"] = "true"
+20
View File
@@ -1,3 +1,4 @@
import pytest
from fastapi.testclient import TestClient
from sqlmodel import Session, select
@@ -24,3 +25,22 @@ def test_create_user(client: TestClient, db: Session) -> None:
assert user
assert user.email == "pollo@listo.com"
assert user.full_name == "Pollo Listo"
def test_creating_a_user_is_refused_unless_the_private_api_is_enabled(
client: TestClient, monkeypatch: pytest.MonkeyPatch
) -> None:
"""The route is always mounted so the SDK keeps its shape; the opt-in is
what decides whether unauthenticated user seeding actually works."""
monkeypatch.setattr(settings, "PRIVATE_API_ENABLED", False)
r = client.post(
f"{settings.API_V1_STR}/private/users/",
json={
"email": "nobody@listo.com",
"password": "password123",
"full_name": "Nobody",
},
)
assert r.status_code == 403
+68
View File
@@ -0,0 +1,68 @@
"""Loop-lag watchdog: what the deep health check reads."""
from fastapi.testclient import TestClient
from app.flow.events import EventBus
from app.flow.watchdog import DEGRADED_STRIKES, LoopWatchdog
from app.main import app
def test_a_responsive_loop_stays_healthy():
watchdog = LoopWatchdog()
for _ in range(10):
watchdog._record(0.002)
assert not watchdog.degraded
assert watchdog.snapshot()["max_60s"] == 2.0
def test_sustained_lag_marks_the_engine_degraded():
watchdog = LoopWatchdog()
for _ in range(20):
watchdog._record(1.0)
assert watchdog.degraded
def test_a_blocked_loop_is_announced_once_it_keeps_happening():
events = []
bus = EventBus()
bus.publish = events.append # type: ignore[method-assign]
watchdog = LoopWatchdog(bus)
for _ in range(DEGRADED_STRIKES - 1):
watchdog._record(6.0)
assert events == []
watchdog._record(6.0)
assert [e["type"] for e in events] == ["engine_degraded"]
# A recovered loop resets the count, so the next stall is announced again.
watchdog._record(0.001)
for _ in range(DEGRADED_STRIKES):
watchdog._record(6.0)
assert len(events) == 2
def test_health_reports_503_once_the_loop_is_wedged():
"""The point of the deep check: unhealthy without the process being dead."""
watchdog = LoopWatchdog()
app.state.watchdog = watchdog
# No lifespan here, so there is no controller either — the endpoint has to
# cope with a half-built app rather than assume the engine is up.
client = TestClient(app)
try:
assert client.get("/api/v1/utils/health/").status_code == 200
for _ in range(20):
watchdog._record(1.0)
response = client.get("/api/v1/utils/health/")
assert response.status_code == 503
body = response.json()
assert body["status"] == "degraded"
assert body["problems"] == ["event loop lagging"]
finally:
del app.state.watchdog
+2
View File
@@ -77,6 +77,8 @@ services:
SMTP_PORT: "1025"
SMTP_TLS: "false"
EMAILS_FROM_EMAIL: "noreply@fluksio.com"
# Test-only user seeding, needed by the Playwright suite. Dev stack only.
PRIVATE_API_ENABLED: "true"
mailcatcher:
image: schickling/mailcatcher
+27 -1
View File
@@ -154,16 +154,20 @@ services:
volumes:
- app-flow-data:/data
# Deep health: fails when the event loop is wedged or Redis is gone, not
# just when the process is dead. Autoheal restarts on unhealthy.
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/api/v1/utils/health-check/"]
test: ["CMD", "curl", "-f", "http://localhost:8000/api/v1/utils/health/"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
build:
context: ..
dockerfile: backend/Dockerfile
labels:
- autoheal=true
- traefik.enable=true
- traefik.docker.network=proxy
- traefik.constraint-label=proxy
@@ -212,6 +216,28 @@ services:
- traefik.http.routers.${STACK_NAME?Variable not set}-frontend-http.middlewares=https-redirect
# Docker never restarts a merely *unhealthy* container on its own; autoheal
# closes that gap for the services labeled autoheal=true.
#
# Behind a profile because it needs the Docker socket, which is host-wide
# authority: on a machine that runs anything besides this stack, that is a
# deliberate operator decision. `make up` opts in; the dev stacks do not.
autoheal:
image: willfarrell/autoheal:latest
container_name: fluksio-autoheal
profiles: ["autoheal"]
restart: always
security_opt:
- no-new-privileges:true
networks:
- default
environment:
# Scoped by label, so it only ever restarts this stack's backend.
- AUTOHEAL_CONTAINER_LABEL=autoheal
- AUTOHEAL_INTERVAL=15
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
volumes:
app-db-data:
app-redis-data:
+18 -1
View File
@@ -3,7 +3,7 @@
import type { CancelablePromise } from './core/CancelablePromise';
import { OpenAPI } from './core/OpenAPI';
import { request as __request } from './core/request';
import type { FlowsReadFlowsResponse, FlowsReadNodeTypesResponse, FlowsReadLibraryResponse, FlowsDeleteSharedNodeData, FlowsDeleteSharedNodeResponse, FlowsReadFlowData, FlowsReadFlowResponse, FlowsSaveFlowData, FlowsSaveFlowResponse, FlowsDeleteFlowData, FlowsDeleteFlowResponse, FlowsPublishFlowData, FlowsPublishFlowResponse, FlowsDiscardDraftData, FlowsDiscardDraftResponse, FlowsRenameFlowData, FlowsRenameFlowResponse, FlowsReadNodeSourceData, FlowsReadNodeSourceResponse, FlowsSaveNodeSourceData, FlowsSaveNodeSourceResponse, FlowsShareNodeData, FlowsShareNodeResponse, FlowsUnshareNodeData, FlowsUnshareNodeResponse, FlowsStartFlowData, FlowsStartFlowResponse, FlowsStopFlowData, FlowsStopFlowResponse, FlowsPauseFlowData, FlowsPauseFlowResponse, FlowsResumeFlowData, FlowsResumeFlowResponse, FlowsValidateFlowData, FlowsValidateFlowResponse, FlowsRunFlowData, FlowsRunFlowResponse, FlowsTriggerNodeData, FlowsTriggerNodeResponse, FlowsReadFlowStateData, FlowsReadFlowStateResponse, FlowsReadMessageHistoryData, FlowsReadMessageHistoryResponse, LoginLoginAccessTokenData, LoginLoginAccessTokenResponse, LoginTestTokenResponse, LoginRecoverPasswordData, LoginRecoverPasswordResponse, LoginResetPasswordData, LoginResetPasswordResponse, LoginRecoverPasswordHtmlContentData, LoginRecoverPasswordHtmlContentResponse, OauthRegisterClientData, OauthRegisterClientResponse, OauthAuthorizeValidateData, OauthAuthorizeValidateResponse, OauthAuthorizeData, OauthAuthorizeResponse, OauthTokenData, OauthTokenResponse, PrivateCreateUserData, PrivateCreateUserResponse, SecretsReadSecretsResponse, SecretsSaveSecretData, SecretsSaveSecretResponse, SecretsDeleteSecretData, SecretsDeleteSecretResponse, UsersReadUsersData, UsersReadUsersResponse, UsersCreateUserData, UsersCreateUserResponse, UsersReadUserMeResponse, UsersDeleteUserMeResponse, UsersUpdateUserMeData, UsersUpdateUserMeResponse, UsersUpdatePasswordMeData, UsersUpdatePasswordMeResponse, UsersRegisterUserData, UsersRegisterUserResponse, UsersReadUserByIdData, UsersReadUserByIdResponse, UsersUpdateUserData, UsersUpdateUserResponse, UsersDeleteUserData, UsersDeleteUserResponse, UtilsTestEmailData, UtilsTestEmailResponse, UtilsHealthCheckResponse } from './types.gen';
import type { FlowsReadFlowsResponse, FlowsReadNodeTypesResponse, FlowsReadLibraryResponse, FlowsDeleteSharedNodeData, FlowsDeleteSharedNodeResponse, FlowsReadFlowData, FlowsReadFlowResponse, FlowsSaveFlowData, FlowsSaveFlowResponse, FlowsDeleteFlowData, FlowsDeleteFlowResponse, FlowsPublishFlowData, FlowsPublishFlowResponse, FlowsDiscardDraftData, FlowsDiscardDraftResponse, FlowsRenameFlowData, FlowsRenameFlowResponse, FlowsReadNodeSourceData, FlowsReadNodeSourceResponse, FlowsSaveNodeSourceData, FlowsSaveNodeSourceResponse, FlowsShareNodeData, FlowsShareNodeResponse, FlowsUnshareNodeData, FlowsUnshareNodeResponse, FlowsStartFlowData, FlowsStartFlowResponse, FlowsStopFlowData, FlowsStopFlowResponse, FlowsPauseFlowData, FlowsPauseFlowResponse, FlowsResumeFlowData, FlowsResumeFlowResponse, FlowsValidateFlowData, FlowsValidateFlowResponse, FlowsRunFlowData, FlowsRunFlowResponse, FlowsTriggerNodeData, FlowsTriggerNodeResponse, FlowsReadFlowStateData, FlowsReadFlowStateResponse, FlowsReadMessageHistoryData, FlowsReadMessageHistoryResponse, LoginLoginAccessTokenData, LoginLoginAccessTokenResponse, LoginTestTokenResponse, LoginRecoverPasswordData, LoginRecoverPasswordResponse, LoginResetPasswordData, LoginResetPasswordResponse, LoginRecoverPasswordHtmlContentData, LoginRecoverPasswordHtmlContentResponse, OauthRegisterClientData, OauthRegisterClientResponse, OauthAuthorizeValidateData, OauthAuthorizeValidateResponse, OauthAuthorizeData, OauthAuthorizeResponse, OauthTokenData, OauthTokenResponse, PrivateCreateUserData, PrivateCreateUserResponse, SecretsReadSecretsResponse, SecretsSaveSecretData, SecretsSaveSecretResponse, SecretsDeleteSecretData, SecretsDeleteSecretResponse, UsersReadUsersData, UsersReadUsersResponse, UsersCreateUserData, UsersCreateUserResponse, UsersReadUserMeResponse, UsersDeleteUserMeResponse, UsersUpdateUserMeData, UsersUpdateUserMeResponse, UsersUpdatePasswordMeData, UsersUpdatePasswordMeResponse, UsersRegisterUserData, UsersRegisterUserResponse, UsersReadUserByIdData, UsersReadUserByIdResponse, UsersUpdateUserData, UsersUpdateUserResponse, UsersDeleteUserData, UsersDeleteUserResponse, UtilsTestEmailData, UtilsTestEmailResponse, UtilsHealthCheckResponse, UtilsHealthResponse } from './types.gen';
export class FlowsService {
/**
@@ -1015,4 +1015,21 @@ export class UtilsService {
url: '/api/v1/utils/health-check/'
});
}
/**
* Health
* Deep health: 200 while the engine can serve its purpose, 503 otherwise.
*
* "Serve its purpose" means the event loop is responsive and the configured
* state backend answers — the two failure modes a process-alive check never
* sees. The queue and pool sections are filled by the execution service.
* @returns unknown Successful Response
* @throws ApiError
*/
public static health(): CancelablePromise<UtilsHealthResponse> {
return __request(OpenAPI, {
method: 'GET',
url: '/api/v1/utils/health/'
});
}
}
+4
View File
@@ -668,3 +668,7 @@ export type UtilsTestEmailData = {
export type UtilsTestEmailResponse = (Message);
export type UtilsHealthCheckResponse = (boolean);
export type UtilsHealthResponse = ({
[key: string]: unknown;
});