Der Workflow meldete Erfolg. Jeden Tag, über Wochen. Nur die Rechnung, wegen der er läuft, kam nicht. Als ich im Oktober nachsah, war die neueste Mail im Postfach 97 Tage alt — das Postfach war noch da, der Abruf lief, die Automatik war grün. Es fehlte nur das Ergebnis.
Das ist kein exotischer Fall, sondern der Normalfall bei Automatisierung: Man überwacht den Prozess, den man gebaut hat, und nicht das Ergebnis, das er liefern soll. Ein Workflow, der fehlerfrei durchläuft und trotzdem nie ein Dokument weiterleitet, ist aus Sicht jedes Health-Checks ein gesundes System. Aus Sicht der Buchhaltung ist er seit drei Monaten kaputt.
Das Problem
Der Workflow holt eingehende Dokumente aus einem Postfach, prüft den Anhang, entpackt ihn und leitet ihn weiter. Wenn kein passendes Dokument da ist, überspringt er den Lauf sauber und beendet sich mit Erfolg. Das ist korrekt — und genau das Problem. „Nichts zu tun" und „die Quelle liefert nichts mehr" sehen im Ausführungsprotokoll identisch aus.
Im August fiel das zum ersten Mal auf. Ich baute einen Check, der nicht den Workflow betrachtet, sondern das Postfach: Wie alt ist die neueste Mail im Zielordner? Das Ergebnis war unangenehm eindeutig. Die Gegenprobe lieferte 3647370 s — 42,2 Tage — und beide Altersschwellen des Triggers gingen korrekt auf PROBLEM. Der Check funktionierte. Die Kette war seit sechs Wochen tot.
Ein paar Wochen später kam die zweite Änderung: Der Versand des Dokumentenlieferanten — in diesem Fall United Parcel Service, der Paketdienst, nicht die USV — wurde auf Microsoft Graph umgestellt. Damit war die Grundlage des alten Checks weg. Es gab kein IMAP-Postfach mehr, in dem man das Alter der neuesten Mail hätte messen können.
Die Diagnose
Der naheliegende Reflex ist, weiter das Postfach zu beobachten. Nur: Das Postfach ist nicht mehr die Wahrheit. Die Wahrheit ist, ob ein Dokument tatsächlich weitergeleitet wurde.
Also habe ich die Messquelle verschoben — von der Mailbox in die Datenbank von n8n. Der Workflow legt in seinem staticData einen Wert lastForwardedReceived ab: die Eingangszeit des zuletzt erfolgreich weitergeleiteten Dokuments. Nicht die Zeit des letzten Laufs. Nicht die Zeit des letzten Erfolgs. Die Zeit des Dokuments selbst.
Der Unterschied ist der ganze Punkt. Ein Lauf kann um 06:31 erfolgreich sein und trotzdem ein Dokument von vor drei Wochen verarbeiten, wenn es gerade erst im Ordner aufgetaucht ist. Misst man den Lauf, ist alles frisch. Misst man das Dokument, sieht man die Lücke.
SELECT staticData->'global'->>'lastForwardedReceived'
FROM workflow_entity
WHERE staticData->'global' ? 'lastForwardedReceived';
Daraus wird ein Sekundenwert: jetzt minus Eingangszeit. Ist der Wert nicht vorhanden — weil noch nie etwas weitergeleitet wurde, weil die Datenbank nicht antwortet oder weil das Feld fehlt —, wird nicht geraten, sondern ein Sentinel gesendet: 2000000. Ein absichtlich unmöglicher Wert, der jede Altersschwelle reißt. Fehlende Daten werden damit als Alarm behandelt, nicht als OK.
Der Rest ist ein Dead-Man’s-Switch: Ein Host-Cron schiebt den Wert alle vier Stunden per Trapper an Zabbix — aktiv zugestellte Messwerte statt passiv abgefragter.
15 */4 * * * /home/ops/document-age-check.py
Die Schwellen sind aus dem realen Lieferrhythmus abgeleitet: Warnung nach 9 Tagen, hohe Priorität nach 14 Tagen. Dazu ein separates nodata(6h) auf denselben Messwert. Das ist nicht die Überwachung des Dokuments, sondern die Überwachung des Überwachers: Wenn der Pusher selbst stirbt, kommt kein Wert mehr, und der Alarm schweigt nicht — er feuert.
Die Ursache
Die Ursache war nicht der Workflow und nicht der neue Graph-Zugang. Die Ursache war die Frage, die das Monitoring stellte.
Prozessgesundheit ist billig zu messen und deshalb das, was standardmäßig gebaut wird: Läuft der Container, war der letzte Lauf erfolgreich, gab es Fehler im Log. All das war grün. Aber kein einziger dieser Werte verändert sich, wenn die Quelle versiegt. Der Prozess war gesund und das Ergebnis war es nicht, und das Monitoring hatte nur für die erste Hälfte einen Sensor.
Der zweite Teil der Ursache ist unangenehmer. Der Alarm existierte bereits. Er war korrekt kalibriert, er hatte die richtigen Schwellen, er hätte gefeuert. Er war nur im Dashboard sichtbar — ohne Benachrichtigung, ohne Kanal, ohne jemanden, der hinsieht. Ein Alarmzustand, der niemanden erreicht, ist von einem fehlenden Alarm praktisch nicht zu unterscheiden.
Die 97 Tage sind deshalb auch keine Laufzeit des Alarms. Der Alarm hätte spätestens nach 14 Tagen auf hoher Priorität gestanden. Die 97 Tage sind die Lücke in der Lieferkette selbst: So lange kam kein Dokument mehr an, das hätte weitergeleitet werden können. Das ist der Unterschied zwischen „das Monitoring hat versagt" und „niemand hat auf das Monitoring geschaut".
Zwei kleinere Baustellen kamen bei der Umstellung dazu. Erstens: Duplikatverwaltung allein in staticData ist fragil. Der Workflow merkt sich dort, welche Nachrichten er schon gesehen hat — aber dieser Zustand lebt im Workflow selbst. Deshalb gibt es jetzt dauerhafte Kategorien im Exchange-Postfach: in Arbeit, weitergeleitet, keine Rechnung, Fehler. Der Zustand liegt damit beim Objekt, nicht beim Verarbeiter.
Zweitens: Die Markierung „in Arbeit" wird vor dem Versand gesetzt. Das verhindert, dass ein abgebrochener Lauf beim nächsten Durchgang dieselbe Mail ein zweites Mal verschickt. Der Preis: Bricht der Lauf zwischen Markierung und Abschluss ab, bleibt die Mail in einem hängenden Bearbeitungszustand — markiert, aber nicht weitergeleitet. Fail-safe gegen Doppelversand, fail-open gegen Liegenbleiben. Ein bewusster Tausch, kein Versehen.
Der Fix
Der Fix ist die Messung selbst. Nicht ein besserer Workflow, sondern ein Sensor, der das Ergebnis beobachtet.
Die Architektur in drei Teilen:
- Die Quelle der Wahrheit verschieben. Der Wert kommt aus der n8n-Datenbank und beschreibt das zuletzt erfolgreich weitergeleitete Dokument — unabhängig davon, über welchen Mail-Protokollweg es hereinkam. Der nächste Wechsel von IMAP auf Graph auf irgendetwas anderes berührt die Messung nicht.
- Aktiv zustellen statt passiv hoffen. Alle vier Stunden ein Trapper-Push. Bleibt der Push aus, feuert
nodata(6h). - Fail-closed bei fehlenden Daten. Kein Zustand, kein Feld, kein Datenbankzugriff → Sentinel
2000000. Der Alarm geht eher los als zu spät.
Die Trigger-Seite ist bewusst stumpf gehalten:
last(age) > 777600 → Warnung (9 Tage)
last(age) > 1209600 → hohe Prio (14 Tage)
nodata(age, 6h) → Warnung (Pusher tot)
Drei Trigger, ein Messwert. Kein and mit Prozesswerten, keine Bedingung auf „Workflow aktiv" — das würde die alte Fehlerklasse nur wieder einbauen. Der Alarm soll nicht wissen, ob der Workflow gesund ist. Er soll wissen, ob das Ergebnis frisch ist.
Betrieben werden n8n und Zabbix hier auf demselben Host, srv-rebellion; die Graph-Anbindung läuft über srv-coruscant — passend benannt, denn von dort kommt die Identität für alles darunter. Wer beide Dienste dauerhaft laufen lässt, braucht keinen großen Server: Mini-PC N100 Beelink S12 Pro reicht für diese Größenordnung, wenn Datenbank und n8n sich eine Maschine teilen dürfen. Wer es billiger will, nimmt einen gebrauchten Lenovo ThinkCentre M720q Tiny (i5-8400T, 16 GB, generalüberholt); wer mehr Container und Reserve möchte, greift zur oberen Klasse Minisforum MS-01 (i5-12600H, Barebone ohne RAM/SSD).
Der Rest ist Handwerk: Der Cron-Eintrag liegt beim Betriebsnutzer, nicht bei root. Der Push nutzt das rohe Trapper-Protokoll, weil auf dem Zielhost kein Agent-Plugin installiert werden kann — dasselbe Muster wie bei jedem Watchdog, der nur Rechte für einen einzelnen Port hat. Und die Schwellen kommen aus dem Lieferrhythmus, nicht aus dem Bauch: Wenn wöchentlich geliefert wird, ist 9 Tage „eine Lieferung verpasst plus Toleranz", nicht „irgendwann mal".
Was noch offen ist: Ein vollständiger Durchlauf mit einer echten Rechnung — entpacken, versandfertig machen, weiterleiten, Sentinel fällt auf einen realen Wert zurück — war zum Zeitpunkt dieses Textes nicht belegt. Belegt ist der Oktober-Testlauf mit einer Mail ohne Anhang: Sie wurde korrekt übersprungen, und der Sentinel wurde übertragen. Der Pfad für den Normalfall steht also, der Pfad für den Ernstfall ist noch eine Bringschuld.
Was du mitnimmst
Miss die Frische des Ergebnisses, nicht die Gesundheit des Prozesses. Ein grüner Workflow sagt dir, dass Code gelaufen ist. Er sagt dir nichts darüber, ob das, wofür der Code existiert, noch passiert. Der Sensor muss am Ende der Kette sitzen, nicht in der Mitte.
Überwache den Messwertlieferanten separat. Ein Dead-Man’s-Switch, dessen Pusher stirbt, ist ein schweigender Alarm. nodata auf denselben Wert ist keine Redundanz, sondern die Bedingung dafür, dass der Switch überhaupt funktioniert.
Fehlende Daten sind ein Alarm, kein OK. Der Unterschied zwischen „Wert ist gut" und „Wert ist unbekannt" verschwindet in fast jedem Dashboard. Ein Sentinel macht ihn wieder sichtbar — und zwar in die sichere Richtung.
Ein Alarmzustand ist keine Benachrichtigung. Das war die teuerste Lektion dieser Geschichte. Der Trigger war richtig, die Schwellen waren richtig, das Dashboard war grün-rot — und niemand hat es gesehen, weil kein Kanal daran hing. Wer einen Alarm baut, baut zwei Dinge: die Erkennung und den Weg zum Menschen. Fehlt der zweite, ist der erste nur Dekoration.
Und wenn der Alarm dann feuert, heißt das nicht, dass das Problem neu ist. Die 97 Tage waren keine 97 Tage Alarm. Sie waren 97 Tage Lieferlücke, von denen der größte Teil unbemerkt verging. Genau dafür ist der Switch da — nicht um zu verhindern, dass etwas ausfällt, sondern um zu verhindern, dass es unbemerkt ausfällt.