Backend: real alert test results, state cleanup on delete/rename, node trigger errors, queue and collector fixes

`AlertManager.send` swallowed every delivery failure, so the alerts screen's
Test button answered 200 whatever happened — the one thing it exists for. It
takes `raise_on_error` now, which only the test route passes; the per-channel
loop keeps the swallow, because one dead channel must not stop the others
hearing about the same fault. A refused delivery answers 502 with whatever the
sender said.

Renaming a flow left its values under the old name for good: the delete path
already swept them, the rename path never did. It calls the same `forget_flow`,
which covers the messages and the `__ts__`/`__version__`/`__history__`
bookkeeping keyed by message name. Cleanup, not migration — they repopulate
under the new name on the next run.

Triggering a node by hand ran `Node.__call__` with nothing catching it, so a
node that raised produced a 500 and a stack trace in the server log, and
nothing at all on the canvas. `Pipeline.publish_error` is the reporting half of
`_execute_node` lifted out; both paths go through it, so a manual failure now
reads the same on the canvas and in the metrics as a queued one. The route
answers 400 with the node's error.

`MemoryWorkQueue.stats()` counts claimed-but-unacknowledged work rather than
reporting zero, so the health tile means something without Redis. The metrics
collector's held tracebacks are capped at `DETAIL_CAP` and swept on the same
`RUN_STALE_S` cutoff the open runs use, instead of one untruncated traceback
per node kept for the life of the process — a traceback still survives the
flush between the log and the failure it belongs to.

`GET /observability/events` takes `since`/`until`, the window `/runs` already
took, so a failures list can cover the span the charts beside it are drawn from.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XC2jX6Hdj7pxGGKzBTrbqB
This commit is contained in:
2026-08-17 11:33:25 +02:00
co-authored by Claude Opus 5
parent 69421b857d
commit 2554488a73
13 changed files with 322 additions and 39 deletions
+14 -8
View File
@@ -74,12 +74,18 @@ async def test_channel(channel_name: str, controller: FlowControllerDep) -> Any:
if channel is None: if channel is None:
raise HTTPException(status_code=404, detail=f"No channel '{channel_name}'") raise HTTPException(status_code=404, detail=f"No channel '{channel_name}'")
await controller.alerts.send( try:
channel, await controller.alerts.send(
Alert( channel,
title="Fluksio test alert", Alert(
body="If you are reading this, the channel works.", title="Fluksio test alert",
severity="warning", body="If you are reading this, the channel works.",
), severity="warning",
) ),
raise_on_error=True,
)
except Exception as exc:
# Whatever the sender said, verbatim: it is the only clue the operator
# has about why the channel does not work.
raise HTTPException(status_code=502, detail=str(exc) or type(exc).__name__)
return Message(message=f"Sent a test alert through '{channel_name}'") return Message(message=f"Sent a test alert through '{channel_name}'")
+9 -1
View File
@@ -432,6 +432,10 @@ async def rename_flow(
except FlowExists as exc: except FlowExists as exc:
raise HTTPException(status_code=409, detail=str(exc)) raise HTTPException(status_code=409, detail=str(exc))
# The old name is nobody's namespace now; its values would sit there under a
# flow that no longer exists. They repopulate under the new name on the next
# run, so this is cleanup rather than a migration.
await run_in_threadpool(controller.forget_flow, name)
await controller.reload() await controller.reload()
return _detail(controller, renamed) return _detail(controller, renamed)
@@ -633,13 +637,17 @@ async def trigger_node(
"""Feed values into a single node.""" """Feed values into a single node."""
_require_enabled(controller, name) _require_enabled(controller, name)
try: try:
await run_in_threadpool( error = await run_in_threadpool(
controller.trigger_node, f"{name}.{node_id}", body.values controller.trigger_node, f"{name}.{node_id}", body.values
) )
except KeyError: except KeyError:
raise HTTPException( raise HTTPException(
status_code=404, detail=f"No node named '{node_id}' in flow '{name}'" status_code=404, detail=f"No node named '{node_id}' in flow '{name}'"
) )
if error:
# The canvas already has the failure from the bus; the person who
# clicked gets to read it too, instead of a stack trace in the log.
raise HTTPException(status_code=400, detail=error)
return _flow_state(controller, name) return _flow_state(controller, name)
+11 -1
View File
@@ -306,9 +306,15 @@ def read_events(
session: SessionDep, session: SessionDep,
kind: Literal["failure", "audit"] = "failure", kind: Literal["failure", "audit"] = "failure",
flow: str | None = None, flow: str | None = None,
since: datetime | None = None,
until: datetime | None = None,
limit: int = 100, limit: int = 100,
) -> Any: ) -> Any:
"""What went wrong, or who changed what. Newest first.""" """What went wrong, or who changed what. Newest first.
``since`` is inclusive and ``until`` exclusive, the same window ``/runs``
takes, so a list can cover the span the charts beside it are drawn from.
"""
statement = select(EngineEvent).order_by(col(EngineEvent.ts).desc()) statement = select(EngineEvent).order_by(col(EngineEvent.ts).desc())
if kind == "audit": if kind == "audit":
statement = statement.where(col(EngineEvent.type) == "audit") statement = statement.where(col(EngineEvent.type) == "audit")
@@ -316,6 +322,10 @@ def read_events(
statement = statement.where(col(EngineEvent.type) != "audit") statement = statement.where(col(EngineEvent.type) != "audit")
if flow: if flow:
statement = statement.where(col(EngineEvent.flow) == flow) statement = statement.where(col(EngineEvent.flow) == flow)
if since:
statement = statement.where(col(EngineEvent.ts) >= _aware(since))
if until:
statement = statement.where(col(EngineEvent.ts) < _aware(until))
return list(session.exec(statement.limit(min(limit, 500)))) return list(session.exec(statement.limit(min(limit, 500))))
+14 -1
View File
@@ -271,10 +271,21 @@ class AlertManager:
# Delivery # Delivery
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
async def send(self, channel: Channel, alert: Alert) -> None: async def send(
self, channel: Channel, alert: Alert, raise_on_error: bool = False
) -> None:
"""Deliver one alert.
A failure is logged and swallowed, because one dead channel must not
stop the others hearing about the same fault. The test button passes
``raise_on_error``: telling a working channel from a broken one is the
only thing it exists for.
"""
try: try:
config = resolve_params(channel.config) config = resolve_params(channel.config)
except Exception as exc: except Exception as exc:
if raise_on_error:
raise
logger.error("Channel '%s' has unusable settings: %s", channel.name, exc) logger.error("Channel '%s' has unusable settings: %s", channel.name, exc)
return return
@@ -286,6 +297,8 @@ class AlertManager:
else: else:
await self._send_email(config, alert) await self._send_email(config, alert)
except Exception as exc: except Exception as exc:
if raise_on_error:
raise
logger.error("Could not alert through '%s': %s", channel.name, exc) logger.error("Could not alert through '%s': %s", channel.name, exc)
async def _send_ntfy(self, config: dict[str, Any], alert: Alert) -> None: async def _send_ntfy(self, config: dict[str, Any], alert: Alert) -> None:
+15 -5
View File
@@ -836,19 +836,29 @@ class FlowController:
) )
pipeline.run(inputs or {}) pipeline.run(inputs or {})
def trigger_node(self, node_id: str, values: dict[str, Any] | None = None) -> None: def trigger_node(self, node_id: str, values: dict[str, Any] | None = None) -> str:
"""Feed values into one node. Blocking — call from a worker thread. """Feed values into one node. Blocking — call from a worker thread.
Runs here rather than through the queue: the caller is a person waiting Runs here rather than through the queue: the caller is a person waiting
on the response, and wants the state it produced. on the response, and wants the state it produced.
Returns what the node raised, or an empty string. A node failing by hand
is reported the way a queued run reports it — on the canvas and in the
metrics — rather than as a stack trace in the server log.
""" """
node = self.get_node(node_id) node = self.get_node(node_id)
if node is None: if node is None:
raise KeyError(node_id) raise KeyError(node_id)
if node.requires and values: try:
node.trigger(values, durable=False) if node.requires and values:
else: node.trigger(values, durable=False)
node.inject(values or {}, durable=False) else:
node.inject(values or {}, durable=False)
except Exception as exc:
if self.pipeline is None:
raise
return self.pipeline.publish_error(node, exc)
return ""
def _publish(self, event: dict[str, Any]) -> None: def _publish(self, event: dict[str, Any]) -> None:
if self.events is not None: if self.events is not None:
+10 -5
View File
@@ -87,8 +87,9 @@ class MetricsCollector:
self._buckets: dict[tuple[str, str, datetime], dict[str, float]] = {} self._buckets: dict[tuple[str, str, datetime], dict[str, float]] = {}
self._runs: dict[str, dict[str, Any]] = {} self._runs: dict[str, dict[str, Any]] = {}
self._pending: list[EngineEvent] = [] self._pending: list[EngineEvent] = []
# The traceback arrives one event before the failure it belongs to. # The traceback arrives one event before the failure it belongs to,
self._tracebacks: dict[tuple[str, str], str] = {} # held with the time it arrived so an unpaired one does not stay put.
self._tracebacks: dict[tuple[str, str], tuple[float, str]] = {}
self._last_prune = 0.0 self._last_prune = 0.0
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
@@ -171,7 +172,7 @@ class MetricsCollector:
# Held for the node_error that follows it from the same thread. # Held for the node_error that follows it from the same thread.
if event.get("level") == "error": if event.get("level") == "error":
key = (str(event.get("flow") or ""), str(event.get("node") or "")) key = (str(event.get("flow") or ""), str(event.get("node") or ""))
self._tracebacks[key] = str(event.get("text") or "") self._tracebacks[key] = (ts, str(event.get("text") or "")[:DETAIL_CAP])
return return
if kind == "node_error": if kind == "node_error":
@@ -179,7 +180,7 @@ class MetricsCollector:
if run is not None: if run is not None:
run["errors"] += 1 run["errors"] += 1
key = (str(event.get("flow") or ""), str(event.get("node") or "")) key = (str(event.get("flow") or ""), str(event.get("node") or ""))
traceback = self._tracebacks.pop(key, "") traceback = self._tracebacks.pop(key, (0.0, ""))[1]
error = str(event.get("error") or "") error = str(event.get("error") or "")
self._pending.append( self._pending.append(
EngineEvent( EngineEvent(
@@ -293,7 +294,11 @@ class MetricsCollector:
del self._runs[run_id] del self._runs[run_id]
# Held tracebacks survive the flush: the log and the failure it belongs # Held tracebacks survive the flush: the log and the failure it belongs
# to are two events, and a flush can fall between them. One per node, # to are two events, and a flush can fall between them. One per node,
# each replaced by that node's next failure. # each replaced by that node's next failure — and dropped once it is
# clear the failure it was waiting for is never coming.
for key, (held, _) in list(self._tracebacks.items()):
if held < cutoff:
del self._tracebacks[key]
def _hold( def _hold(
self, self,
+32 -14
View File
@@ -569,6 +569,37 @@ class Pipeline:
} }
) )
def publish_error(
self,
node: Node,
exc: Exception,
collected: logs.Collector | None = None,
entry_id: str = "",
) -> str:
"""Report a node failure, and hand the one-line version back.
Call it from an ``except`` block — the traceback comes from the
exception being handled. Every way of running a node reports through
here, so a manual run reads the same on the canvas and in the metrics
as a queued one.
"""
logger.exception("Node '%s' failed", node.id)
# The one-line error goes on the node; the traceback goes to the log
# panel, which is where there is room to read it.
self.publish_log(node, collected or logs.Collector(), logs.node_traceback())
error = f"{type(exc).__name__}: {exc}"
self._publish(
{
"type": "node_error",
"flow": node.flow,
"node": node.id,
"error": error,
"run": entry_id,
"ts": time.time(),
}
)
return error
def _already_done(self, entry_id: str, node: Node) -> bool: def _already_done(self, entry_id: str, node: Node) -> bool:
"""Did this node's side effect already happen for this work item?""" """Did this node's side effect already happen for this work item?"""
if node.idempotent or self._queue is None: if node.idempotent or self._queue is None:
@@ -650,20 +681,7 @@ class Pipeline:
return result return result
except Exception as exc: except Exception as exc:
# One failing node must not take the rest of the graph down. # One failing node must not take the rest of the graph down.
logger.exception("Node '%s' failed", node.id) self.publish_error(node, exc, collected, entry_id)
# The one-line error goes on the node; the traceback goes to the
# log panel, which is where there is room to read it.
self.publish_log(node, collected, logs.node_traceback())
self._publish(
{
"type": "node_error",
"flow": node.flow,
"node": node.id,
"error": f"{type(exc).__name__}: {exc}",
"run": entry_id,
"ts": time.time(),
}
)
return None return None
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
+11 -3
View File
@@ -177,6 +177,8 @@ class MemoryWorkQueue(WorkQueue):
self._delayed: list[tuple[float, int, WorkItem]] = [] self._delayed: list[tuple[float, int, WorkItem]] = []
self._parked: dict[str, list[WorkItem]] = {} self._parked: dict[str, list[WorkItem]] = {}
self._done: set[tuple[str, str]] = set() self._done: set[tuple[str, str]] = set()
# Claimed and not yet acknowledged, which is what Redis's `pending` is.
self._in_flight = 0
self._counter = 0 self._counter = 0
self._seq = 0 self._seq = 0
self._lock = threading.Lock() self._lock = threading.Lock()
@@ -210,10 +212,16 @@ class MemoryWorkQueue(WorkQueue):
if remaining <= 0: if remaining <= 0:
return [] return []
self._wake.wait(remaining) self._wake.wait(remaining)
return [self._items.popleft() for _ in range(min(count, len(self._items)))] claimed = [
self._items.popleft() for _ in range(min(count, len(self._items)))
]
self._in_flight += len(claimed)
return claimed
def ack(self, item: WorkItem) -> None: def ack(self, item: WorkItem) -> None:
"""Nothing to acknowledge: claiming already removed it.""" """Nothing to take off the queue — claiming did that. Only counted."""
with self._lock:
self._in_flight = max(0, self._in_flight - 1)
def reclaim_stale(self, min_idle_ms: int) -> list[WorkItem]: def reclaim_stale(self, min_idle_ms: int) -> list[WorkItem]:
"""Nothing to reclaim: an item lost here died with the process.""" """Nothing to reclaim: an item lost here died with the process."""
@@ -253,7 +261,7 @@ class MemoryWorkQueue(WorkQueue):
with self._lock: with self._lock:
return { return {
"depth": len(self._items), "depth": len(self._items),
"pending": 0, "pending": self._in_flight,
"delayed": len(self._delayed), "delayed": len(self._delayed),
"parked": sum(len(v) for v in self._parked.values()), "parked": sum(len(v) for v in self._parked.values()),
"oldest_pending_s": 0.0, "oldest_pending_s": 0.0,
+66
View File
@@ -0,0 +1,66 @@
"""The Test button: it exists to tell a working channel from a broken one."""
from typing import Any
import pytest
from fastapi.testclient import TestClient
from app.core.config import settings
from app.flow.alerts import AlertsConfig, Channel
PREFIX = f"{settings.API_V1_STR}/alerts"
def _one_channel(client: TestClient) -> Any:
alerts = client.app.state.flow_controller.alerts # type: ignore[attr-defined]
alerts.config = AlertsConfig(
channels=[Channel(name="phone", kind="ntfy", config={"topic": "t"})]
)
return alerts
def test_a_channel_that_cannot_deliver_is_not_a_success(
client: TestClient,
superuser_token_headers: dict[str, str],
monkeypatch: pytest.MonkeyPatch,
) -> None:
alerts = _one_channel(client)
async def refuse(_config: dict[str, Any], _alert: Any) -> None:
raise RuntimeError("nobody home")
monkeypatch.setattr(alerts, "_send_ntfy", refuse)
response = client.post(f"{PREFIX}/test/phone", headers=superuser_token_headers)
assert response.status_code == 502
assert "nobody home" in response.json()["detail"]
def test_a_channel_that_delivers_says_so(
client: TestClient,
superuser_token_headers: dict[str, str],
monkeypatch: pytest.MonkeyPatch,
) -> None:
alerts = _one_channel(client)
sent = []
async def accept(_config: dict[str, Any], alert: Any) -> None:
sent.append(alert)
monkeypatch.setattr(alerts, "_send_ntfy", accept)
response = client.post(f"{PREFIX}/test/phone", headers=superuser_token_headers)
assert response.status_code == 200
assert len(sent) == 1
def test_an_unknown_channel_is_a_404(
client: TestClient, superuser_token_headers: dict[str, str]
) -> None:
_one_channel(client)
response = client.post(f"{PREFIX}/test/nowhere", headers=superuser_token_headers)
assert response.status_code == 404
+66
View File
@@ -357,3 +357,69 @@ def test_rename_rejects_an_invalid_name(
json={"new_name": "Not A Flow Name"}, json={"new_name": "Not A Flow Name"},
) )
assert response.status_code == 400 assert response.status_code == 400
def test_a_node_that_raises_answers_with_its_error(
client: TestClient, superuser_token_headers: dict[str, str]
) -> None:
"""A manual run fails the way a scheduled one does, not as a 500."""
saved = client.put(
f"{PREFIX}/failing", headers=superuser_token_headers, json=a_flow("failing")
).json()
client.put(
f"{PREFIX}/failing/nodes/sensor/source",
headers=superuser_token_headers,
json={"code": BROKEN_NODE},
)
client.post(
f"{PREFIX}/failing/publish",
headers=superuser_token_headers,
json={"version": saved["definition"]["version"]},
)
response = client.post(
f"{PREFIX}/failing/nodes/sensor/trigger",
headers=superuser_token_headers,
json={"values": {}},
)
assert response.status_code == 400
assert "boom" in response.json()["detail"]
client.delete(f"{PREFIX}/failing", headers=superuser_token_headers)
def test_renaming_a_flow_leaves_nothing_under_the_old_name(
client: TestClient, superuser_token_headers: dict[str, str]
) -> None:
saved = client.put(
f"{PREFIX}/movable", headers=superuser_token_headers, json=a_flow("movable")
).json()
client.put(
f"{PREFIX}/movable/nodes/sensor/source",
headers=superuser_token_headers,
json={"code": WORKING_NODE},
)
client.post(
f"{PREFIX}/movable/publish",
headers=superuser_token_headers,
json={"version": saved["definition"]["version"]},
)
client.post(
f"{PREFIX}/movable/run", headers=superuser_token_headers, json={"inputs": {}}
)
state = client.app.state.flow_controller.state
assert [key for key in state.keys() if "movable." in key]
client.post(
f"{PREFIX}/movable/rename",
headers=superuser_token_headers,
json={"new_name": "moved"},
)
# The values repopulate under the new name on the next run; what the old
# name left behind would sit there for good.
assert [key for key in state.keys() if "movable." in key] == []
client.delete(f"{PREFIX}/moved", headers=superuser_token_headers)
@@ -134,3 +134,35 @@ def test_runs_narrow_to_one_minute(
# The upper bound is exclusive, so the run starting the next minute is not # The upper bound is exclusive, so the run starting the next minute is not
# in this one. # in this one.
assert [run["id"] for run in runs] == ["minute-in"] assert [run["id"] for run in runs] == ["minute-in"]
def test_events_narrow_to_one_minute(
client: TestClient, superuser_token_headers: dict[str, str], db: Session
) -> None:
"""The failures list reaches as far back as the charts beside it."""
minute = datetime.now(timezone.utc).replace(second=0, microsecond=0) - timedelta(
hours=4
)
db.add(EngineEvent(ts=minute, type="node_error", flow=FLOW, detail="minute-in"))
db.add(
EngineEvent(
ts=minute + timedelta(minutes=1),
type="node_error",
flow=FLOW,
detail="minute-after",
)
)
db.commit()
events = client.get(
f"{PREFIX}/events",
headers=superuser_token_headers,
params={
"flow": FLOW,
"since": minute.isoformat(),
"until": (minute + timedelta(minutes=1)).isoformat(),
},
).json()
# The upper bound is exclusive, so the failure a minute later is not in it.
assert [event["detail"] for event in events] == ["minute-in"]
+14
View File
@@ -60,6 +60,20 @@ def test_a_deleted_flow_leaves_nothing_parked():
assert queue.unpark("gone") == [] assert queue.unpark("gone") == []
def test_claimed_work_counts_as_in_flight_until_it_is_acknowledged():
"""The health tile's "in flight" reads zero without this."""
queue = MemoryWorkQueue()
queue.add(WorkItem(kind="cascade", node="f.n", flow="f"))
assert queue.stats()["pending"] == 0
(item,) = queue.claim(1, 10)
assert queue.stats()["pending"] == 1
queue.ack(item)
assert queue.stats()["pending"] == 0
def _pipeline_with_a_consumer() -> tuple[Pipeline, Node, MemoryState, list]: def _pipeline_with_a_consumer() -> tuple[Pipeline, Node, MemoryState, list]:
"""A source whose message a consumer records.""" """A source whose message a consumer records."""
seen: list[float] = [] seen: list[float] = []
+28 -1
View File
@@ -11,7 +11,7 @@ from datetime import datetime, timezone
from sqlmodel import Session, select from sqlmodel import Session, select
from app.flow.events import EventBus from app.flow.events import EventBus
from app.flow.metrics import MetricsCollector from app.flow.metrics import DETAIL_CAP, RUN_STALE_S, MetricsCollector
from app.models import EngineEvent, FlowRun, MetricBucket from app.models import EngineEvent, FlowRun, MetricBucket
FLOW = "metrics-test" FLOW = "metrics-test"
@@ -197,3 +197,30 @@ def test_a_run_that_did_not_fail_reads_ok(db: Session) -> None:
run = db.exec(select(FlowRun).where(FlowRun.id == "manual-abc")).one() run = db.exec(select(FlowRun).where(FlowRun.id == "manual-abc")).one()
assert (run.status, run.source) == ("ok", "manual") assert (run.status, run.source) == ("ok", "manual")
def test_a_traceback_no_failure_ever_claims_is_dropped() -> None:
"""One entry per node that ever errored would else last the process out."""
node = f"{FLOW}.stale"
collector = MetricsCollector(EventBus())
collector.handle(
{
"type": "node_log",
"flow": FLOW,
"node": node,
"level": "error",
"text": "x" * (DETAIL_CAP * 2),
"ts": time.time() - RUN_STALE_S - 1,
}
)
# Untruncated, one of these holds a whole run of a chatty node.
assert len(collector._tracebacks[(FLOW, node)][1]) == DETAIL_CAP
# Something to write, so the flush does not stop at its early return.
collector.handle(
{"type": "node_executed", "flow": FLOW, "node": node, "ts": time.time()}
)
asyncio.run(collector.flush())
assert collector._tracebacks == {}