Ein stündlicher n8n-Workflow, der sich nicht selbst überholt: Sperren, Merge-Nodes und leere Läufe
Teil 4 von 6 der Serie Die Follow-up-Maschine
- Überblick, Angebote nachfassen: eine Follow-up-Maschine, die sich meldet, wenn sie ausfällt
- Architektur, n8n-Workflow-Beispiel aus dem Echtbetrieb: eine Architektur, die Wiederholungen übersteht
- Registrieren, EspoCRM-Webhooks in n8n: Ereignisname, Signatur und eine Phase, die es nicht gab
- Auswerten, Ein stündlicher n8n-Workflow, der sich nicht selbst überholt: Sperren, Merge-Nodes und leere Läufe
- Anreichern, KI-Zusammenfassungen aus CRM-E-Mails mit n8n und Mistral: Prompt, Parser und das Komma
- Warnungen, n8n Error Workflows, die wirklich warnen: Deduplizierung, hängende Sperren und die Freitagsmail
Alle 6 Teile
- Überblick, Angebote nachfassen: eine Follow-up-Maschine, die sich meldet, wenn sie ausfällt
- Architektur, n8n-Workflow-Beispiel aus dem Echtbetrieb: eine Architektur, die Wiederholungen übersteht
- Registrieren, EspoCRM-Webhooks in n8n: Ereignisname, Signatur und eine Phase, die es nicht gab
- Auswerten, Ein stündlicher n8n-Workflow, der sich nicht selbst überholt: Sperren, Merge-Nodes und leere Läufe
- Anreichern, KI-Zusammenfassungen aus CRM-E-Mails mit n8n und Mistral: Prompt, Parser und das Komma
- Warnungen, n8n Error Workflows, die wirklich warnen: Deduplizierung, hängende Sperren und die Freitagsmail
Das ist Teil 4 der Serie zur Follow-up-Maschine. Teil 3 zeigt, wie Deals registriert werden. Dieser Teil behandelt „Followup 2: Evaluate“, den Workflow, der jede Stunde entscheidet, welcher nächste Schritt für jeden beobachteten Deal ansteht.
Er hat 45 Nodes. Die lehrreichsten Fehler darin passierten in Läufen, in denen es nichts zu tun gab.
Was ein Lauf tut
Der Zeitplan lautet 0 7-19 * * 1-5 in Europe/Vienna: zur vollen Stunde, 7 bis 19 Uhr, werktags. Ein Lauf hat sechs Stufen:
- Sperre setzen, damit sich zwei Läufe nie überschneiden.
- Abgleichen mit dem CRM: Jeder relevante Deal ohne Beobachtung wird registriert.
- Auswählen, welche Termine heute fällig sind.
- Entscheiden pro Deal: Beobachtung schließen, Uhr zurücksetzen oder Aufgabe anlegen.
- Aufgabe anlegen oder übernehmen, genau einmal pro fälligem Termin.
- Abschließen: Ergebnis festhalten und warnen, wenn das CRM drei Läufe hintereinander nicht erreichbar war.
Stufe 1: die Sperre
Teil 2 zeigt die Abfrage dafür: ein Insert in run_log, der nur gelingt, wenn kein anderer Lauf aktiv ist. Was Teil 2 nicht zeigt, ist der Fall, in dem er nicht gelingt.
Ein Postgres-Node, dessen Abfrage null Zeilen liefert, gibt nicht null Einträge aus. Er gibt einen Eintrag aus: { success: true }. Der nächste Node bekommt also in jedem Fall etwas, und ohne Prüfung macht ein gesperrter Lauf einfach weiter, als gehöre ihm die Arbeit.
In den Implementierungsnotizen stand: „Kommt keine Zeile zurück, Lauf beenden.“ Gebaut wurde der Node dafür nie. Ein gesperrter Lauf durchlief trotzdem den gesamten Workflow ohne run_log_id und stürzte am letzten Node mit there is no parameter $6 ab, was dann auch noch einen falschen Alarm auslöste.
Die Lösung ist ein IF-Node direkt nach der Sperre:
Run claimed? {{ $json.run_log_id ? 'claimed' : 'busy' }} equals claimed
true → weiter
false → "Skipped (run already live)" (No Operation)
Warum ist das nicht früher aufgefallen?
Weil der Zeitplan es versteckt hat. Eine Sperre gilt 55 Minuten, stündliche Läufe liegen 60 Minuten auseinander. Sie sind sich nie begegnet.
Sichtbar wurde es erst beim Test, ob zwei Läufe hintereinander keine Duplikate erzeugen: Der zweite startete, während die Sperre des ersten noch galt.
Stufe 2: Abgleich, und der Merge-Node, der ohne Eingaben stehen bleibt
Der Abgleich holt die relevanten Deals aus dem CRM und die offenen Beobachtungen aus Postgres parallel. Dann berechnet ein Code-Node den Unterschied:
for (const d of espoDeals) {
if (watched.has(d.id)) continue;
out.push({ op: 'create', subject_id: d.id, /* ... */ });
}
for (const w of openWatches) {
if (!inScope.has(w.subject_id)) out.push({ op: 'close_out_of_scope', watch_id: w.watch_id });
}
if (!out.some((o) => o.op === 'create')) {
out.push({ op: 'none' });
}
return out;
Die create-Einträge gehen an einen Postgres-Node, der Beobachtungen anlegt. Danach wartet ein Merge-Node namens „Wait for apply“ im Modus „Choose Branch“, bis diese Inserts fertig sind, bevor der Lauf die fälligen Termine liest. Ein Lauf soll den Zustand erst lesen, wenn seine eigenen Schreibvorgänge abgeschlossen sind.
Achten Sie auf die letzten drei Zeilen des Code-Nodes. In der ersten Version fehlten sie.
Meistens gibt es nichts anzulegen. Jeder Deal hat schon eine Beobachtung. Das ist der Normalzustand, nicht die Ausnahme.
In diesem Zustand erreichte kein Eintrag den Insert-Node, also erreichte auch kein Eintrag diesen Eingang des Merge-Nodes. Ein Merge-Node, der auf beide Eingänge wartet, geht nicht weiter, wenn einer davon nie Daten bekommt. Der Lauf endete einfach dort, ohne Meldung, und der Eintrag in run_log blieb auf running. Damit war jeder Lauf der nächsten 55 Minuten gesperrt.
Aufgetreten ist das beim ersten echten Lauf im Normalzustand, am 3. August um 17 Uhr.
Die Lösung ist der Platzhalter { op: 'none' }. Gibt es nichts anzulegen, erzeugt der Abgleich einen einzelnen Markierungseintrag, und ein Filter namens „No changes signal“ leitet ihn direkt zum Merge-Node. So erhält der Merge-Node bei jedem Lauf über genau einen der beiden Wege einen Eintrag.
Drei Tage später kam noch ein Detail des Merge-Nodes dazu: Er wartet darauf, dass jeder Eingang Daten bekommt, nicht jede Verbindung in diesen Eingang. Als ich eine neue Abfrage als weitere Zuleitung an denselben Eingang hängte, hatte der Platzhalter bereits Daten an diesen Eingang geliefert, und ein Testlauf las die fälligen Termine, bevor die zusätzliche Aktualisierung abgeschlossen war. Die neue Abfrage sitzt deshalb jetzt in Reihe vor dem Lesen.
Schließen, ohne den Grund zu verlieren
Der Abgleich weiß auch, welche Beobachtungen zu Deals gehören, die die relevanten Phasen verlassen haben. Warum, weiß er nicht: gewonnen, verloren, gelöscht oder zurückgestuft. Dieser Grund zählt, weil die Freitagsübersicht ihn nennt.
Also schließt der Abgleich sie nicht. Die erste Version verarbeitete diese Einträge nicht weiter und nahm an, dass die Beobachtung geschlossen wird, sobald ihr nächster Termin fällig ist und die Entscheidung pro Deal die Phase liest. Bei 5, 12 und 21 Werktagen Abstand dauerte das Tage. Am 6. August zählten zwei verlorene Deals noch als offene Nachfass-Fälle.
Die Lösung setzt den nächsten Termin der Beobachtung auf heute und lässt die reguläre Entscheidungslogik die Beobachtung mit dem passenden Abschlussgrund schließen, im selben Lauf:
update watch_steps ws
set due_on = (now() at time zone 'Europe/Vienna')::date
from watches w, p
where ws.watch_id = w.id
and w.status = 'open'
and ws.fired_at is null and ws.skipped_at is null
and ws.due_on > (now() at time zone 'Europe/Vienna')::date
and jsonb_array_length(p.j -> 'inScope') > 0 -- nie bei leerer Liste
and not exists (select 1 from jsonb_array_elements_text(p.j -> 'inScope') e
where e = w.subject_id)
and ws.step_no = (select min(x.step_no) from watch_steps x
where x.watch_id = w.id and x.fired_at is null and x.skipped_at is null);
Zwei Sicherungen sind wichtig. Bei einer leeren Liste tut die Abfrage nichts, denn ein Fehler, der eine leere Liste liefert, würde sonst alle offenen Beobachtungen auf einmal vorziehen. Und sie läuft nie, wenn die CRM-Abfrage gescheitert ist: Ein IF-Node namens „Espo partial?“ leitet den Lauf dann komplett am Abgleich vorbei.
Stufe 3: fällige Termine, und Split In Batches ohne Inhalt
select distinct on (w.id)
s.id as step_id, s.step_no, s.due_on::text as due_on, w.id as watch_id, w.subject_id
-- gekürzt
from watch_steps s
join watches w on w.id = s.watch_id
where w.status = 'open'
and s.fired_at is null
and s.skipped_at is null
and s.due_on <= (now() at time zone 'Europe/Vienna')::date
order by w.id, s.due_on, s.step_no;
distinct on (w.id) begrenzt das auf einen Termin pro Beobachtung pro Lauf. War die Maschine einen Monat aus, bekommt ein Deal eine Aufgabe pro Lauf, als überfällig markiert, statt aller verpassten Termine in derselben Minute.
Die Zeilen gehen in einen Loop (Split In Batches, Stapelgröße 10). Und hier liegt die zweite Blockade leerer Läufe. Liefert die Abfrage nichts, gibt Split In Batches auf keinem seiner Ausgänge etwas aus. Weder auf „loop“ noch auf „done“. Also lief „Finish run“, das an „done“ hängt, nie, und die Sperre blieb wieder auf running.
Die Lösung hat zwei Teile:
- Always Output Data AN bei „Select due steps“, damit ein leeres Ergebnis trotzdem einen leeren Eintrag ausgibt.
- Ein IF-Node „Any due steps?“ prüft
{{ $json.step_id ? 'due' : 'none' }}.duegeht in den Loop,nonedirekt zu „Finish run“.
Dieser leere Platzhaltereintrag muss aus der Zählung in run_log herausgefiltert werden, sonst zählt jeder leere Lauf fälschlich einen Eintrag.
Testen Sie den Lauf, in dem nichts passiert. Es ist der häufigste.
Dasselbe Fehlerpaar, die fehlende Sperrprüfung und der leere Loop, steckte auch im Enrich-Workflow. Dort blieb es nur verborgen, weil zufällig mindestens eine Beobachtung offen war.
Drei Verhaltensweisen von n8n bei leeren Läufen
| Node | Ohne Eingabe | Lösung |
|---|---|---|
| Postgres, null Zeilen | Gibt einen Eintrag { success: true } aus | Auf das benötigte Feld prüfen |
| Merge, Choose Branch | Wartet endlos auf den leeren Eingang | Einen Platzhaltereintrag senden |
| Split In Batches | Gibt auf keinem Ausgang etwas aus | Always Output Data, dann IF |
Stufe 4: Entscheidung pro Deal
Für jeden fälligen Termin lädt der Workflow Phase und letzte Aktivität des Deals aus dem CRM, dann entscheidet ein Code-Node. Die Entscheidung ist eine reine Funktion mit Tests, statt über mehrere IF-Nodes verteilter Logik:
// Gekürzt. Die vollständige Funktion berechnet auch `touched`, `newDueDates` und `overdueNote`.
export function decide(input: RouteInput): Outcome {
if (!input.subjectFound) return { kind: 'close', reason: 'subject_gone' };
const terminal = input.stage === null ? undefined : TERMINAL_STAGES[input.stage];
if (terminal) return { kind: 'close', reason: terminal };
if (input.stage !== null && !STAGES_IN_SCOPE.includes(input.stage)) {
return { kind: 'close', reason: 'out_of_scope' };
}
const clockStart = input.lastResetAt ?? input.registeredAt;
if (input.lastActivityAt && input.lastActivityAt.getTime() > clockStart.getTime()) {
// restliche Termine ab dem Aktivitätsdatum neu berechnen
return { kind: 'reset', newDueDates /* ... */ };
}
// 90 Tage unberührt: aufhören, aber in der Übersicht zeigen
if (input.now.getTime() - touched.getTime() > 90 * DAY_MS) {
return { kind: 'close', reason: 'exhausted' };
}
return { kind: 'fire', overdueNote };
}
Ein Switch-Node schickt close, reset und fire danach in eigene Zweige.
Drei Details zu den CRM-Abfragen:
- HTTP 404 gilt hier als erwarteter Fall eines gelöschten Deals. Bei „Fetch subject“ ist „Include Response Status“ an, Fehler gehen auf den Fehlerausgang. Ein IF-Node prüft auf 404 und reicht das weiter, damit die Beobachtung geschlossen wird. Jeder andere Fehler überspringt den Eintrag und markiert den Lauf als
partial. Eine pauschale Einstellung „nie Fehler“ würde aus einer 500 einen Erfolg mit leerer Phase machen, und die Maschine würde auf Basis von nichts handeln. - Nur, was stattgefunden hat, zählt. Die letzte Aktivität kommt vom
history-Endpunkt von EspoCRM, der gesendete E-Mails, geführte Anrufe und stattgefundene Besprechungen enthält. Ein für nächste Woche geplanter Anruf liegt in einer anderen Liste und setzt die Uhr zu Recht nicht zurück. - E-Mails und Anrufe speichern ihr Datum in verschiedenen Feldern. Anrufe und Termine haben
dateStart, E-MailsdateSent. Die erste Version las nurdateStart, also setzte eine E-Mail, die häufigste Art von Kontakt, nie etwas zurück. Jetzt liest siedateStart ?? dateSent.
Werktage
Die Fälligkeitstermine werden anhand der Arbeitstage in der Zeitzone Europe/Vienna berechnet, ohne die Tage aus der Tabelle holidays:
export function addBusinessDays(start: IsoDate, n: number, holidays: ReadonlySet<IsoDate>): IsoDate {
let t = new Date(`${start}T12:00:00Z`).getTime();
let remaining = n;
let iso = start;
while (remaining > 0) {
t += DAY_MS;
iso = new Date(t).toISOString().slice(0, 10);
if (isBusinessDay(iso, holidays)) remaining--;
}
return iso;
}
Jedes Datum wird um 12 Uhr UTC berechnet. So kann eine Zeitumstellung nie einen Kalendertag verschieben.
Hier laufen zwei getrennte Mechanismen: Der Zeitplan des Triggers beschränkt die Läufe über 0 7-19 * * 1-5 auf Montag bis Freitag, die Fälligkeitstermine entstehen dagegen aus der Werktagsrechnung samt Tabelle holidays. Ein Feiertag hält den Trigger nicht auf, er verschiebt nur die Termine.
Stufe 5: eine Aufgabe genau einmal anlegen
Der Zweig fire legt eine Aufgabe in ClickUp an. ClickUp hat keine Eindeutigkeitsregel, auf die sich die Maschine verlassen kann. Das ist also die eine Stelle, an der „zweimal ausführen entspricht einmal ausführen“ eine zusätzliche Prüfung braucht.
Der Fehler, den das verhindert: Die Aufgabe wird angelegt, dann scheitert der Postgres-Schreibvorgang, der sie festhält. Der nächste Lauf sieht den Termin als nicht ausgelöst und legt die Aufgabe noch einmal an. Und noch einmal, jede Stunde.
Deshalb durchsucht „ClickUp idempotency check“ vor jedem Anlegen die Liste nach einer offenen Aufgabe, deren Feld Deal die ID dieses Deals enthält. Gibt es eine Aufgabe mit dem erwarteten Namen, übernimmt „Adopt existing task“ ihre ID und überspringt das Anlegen. In beiden Fällen schreibt „Mark fired“ die Aufgaben-ID:
update watch_steps
set fired_at = now(), clickup_task_id = $1
where id = $2::bigint and fired_at is null
returning id;
Eine Falle in der Konfiguration: Das Feld
Dealmuss vom Typ Kurztext sein. Ein Feld vom Typ URL lehnt die CRM-ID mitFIELD_010ab, „Value is not a valid URL“, und jedes Anlegen scheitert mit 400. Fehlt das Feld ganz, bricht der Lauf absichtlich mit einer Fehlermeldung ab. Das ist ein Konfigurationsfehler, und ohne das Feld weiterzumachen würde Aufgaben erzeugen, die niemand einem Deal zuordnen kann.
Stufe 6: abschließen, und einen CRM-Ausfall erkennen
„Finish run“ aktualisiert den Eintrag in run_log: ok, wenn alle Einträge erfolgreich verarbeitet wurden, partial, wenn einer übersprungen wurde. Eine zweite Abfrage prüft die letzten drei Läufe:
select count(*) = 3 as streak
from (
select status from run_log
where workflow = 'wf2' and status in ('ok', 'partial', 'failed')
order by started_at desc
limit 3
) t
where status = 'partial';
Drei partial-Läufe hintereinander bedeuten fast immer, dass das CRM ausgefallen ist, und lösen eine Warnung aus. Ein einzelner partial-Lauf hat mehrere mögliche Ursachen: Ein Deal war nicht lesbar, oder eine ClickUp-Abfrage ist gescheitert. Der betroffene Eintrag kommt in der nächsten Stunde einfach wieder dran.
Was man aus diesem Teil mitnehmen kann
- Ein Postgres-Node ohne Ergebniszeilen gibt trotzdem einen Eintrag aus. Prüfen Sie auf den Wert, den Sie brauchen.
- Ein Merge-Node, der auf zwei Eingänge wartet, bleibt stehen, wenn ein Weg nichts liefert. Geben Sie dem leeren Fall einen eigenen Eintrag.
- Split In Batches ohne Eingabe gibt auf keinem Ausgang etwas aus. Always Output Data einschalten, dann verzweigen.
- Testen Sie den Lauf, in dem nichts passiert. Es ist der häufigste.
- Bevor Sie in einem System ohne Eindeutigkeitsregeln etwas anlegen, suchen Sie zuerst danach.
Weiter: Teil 5, Enrich.