Zum Inhalt springen

This page is available in English. Switch to English

Arbeit, die jede Woche wiederkommt? Muss kein Mensch machen. Mehr zu KI und Automatisierung

Automatisierung

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

Zwei gefräste Metallblöcke in einem schmalen Kanal, der zweite an einer Querschiene hinter dem ersten zurückgehalten.

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:

  1. Sperre setzen, damit sich zwei Läufe nie überschneiden.
  2. Abgleichen mit dem CRM: Jeder relevante Deal ohne Beobachtung wird registriert.
  3. Auswählen, welche Termine heute fällig sind.
  4. Entscheiden pro Deal: Beobachtung schließen, Uhr zurücksetzen oder Aufgabe anlegen.
  5. Aufgabe anlegen oder übernehmen, genau einmal pro fälligem Termin.
  6. 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.

Auch ohne Änderungen braucht der Merge eine Eingabe.D2 / FOLLOW-UP MACHINEAuch ohne Änderungen braucht der Merge eine Eingabe.Ein Lauf ohne Änderungen: vorher und mit Platzhalter. Node-Namen bleiben unverändert. Watchful Loop: Evaluate Espo: list in scopeOpen watchesMerge espo+watchesInject reconcile inputsEspo partial?nein → weiter untenDie folgenden Fälle zeigen den Zweig nach erfolgreicher CRM-Abfrage.VORHER / 0 EinträgeNACHHER / ein PlatzhalterReconcile diff0 EinträgeDrop close itemsApply: createkeine DatenWait for apply1Open watches2BLOCKIERT · Sperre bleibt runningReconcile diffDrop close itemsApply: createNo changes signalop: 'none'ohne Änderungen1Wait for applyOpen watches2Expedite out-of-scopeSelect due stepsGenau ein Weg liefert an Eingang 1: Apply: create ODER No changes signal.Merge wartet auf jeden Eingang, nicht auf jede Verbindung. Eine hängende Sperre gilt bis zu 55 Minuten.
Gibt es nichts anzulegen, erhält Eingang 1 keinen Eintrag. Der Platzhalter versorgt diesen Eingang bei Läufen ohne Änderungen, damit der Merge weiterläuft.

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:

  1. Always Output Data AN bei „Select due steps“, damit ein leeres Ergebnis trotzdem einen leeren Eintrag ausgibt.
  2. Ein IF-Node „Any due steps?“ prüft {{ $json.step_id ? 'due' : 'none' }}. due geht in den Loop, none direkt zu „Finish run“.
Ein leerer Lauf braucht einen eigenen Weg zum Abschluss.D3 / FOLLOW-UP MACHINEEin leerer Lauf braucht einen eigenen Weg zum Abschluss.Der markierte Weg zeigt den leeren Lauf. Er erreicht trotzdem Finish run. Watchful Loop: Evaluate Select due stepsAlways Output Data: ONein leerer EintragAny due steps?step_id ? 'due' : 'none'noneFinish runErgebnis erfassen · Sperre lösendueLoopSplit In Batches · 10loopFetch subjectfällige Einträge verarbeitenBatch item donenächster StapeldoneNACH DEM LAUFPartial-streak queryStreak?Streak dry-run?Send partial alertjaneinOhne den none-Zweig löst eine leere Eingabe weder loop noch done aus. Die Sperre bleibt running.Die Verarbeitung pro Deal ist zusammengefasst; Warnzweige ohne Versand sind ausgeblendet.
Ein eigener none-Zweig führt einen leeren Lauf direkt zu Finish run. Protokollierung und Freigabe der Sperre hängen damit nicht davon ab, ob der Loop Arbeit erhält.

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

NodeOhne EingabeLösung
Postgres, null ZeilenGibt einen Eintrag { success: true } ausAuf das benötigte Feld prüfen
Merge, Choose BranchWartet endlos auf den leeren EingangEinen Platzhaltereintrag senden
Split In BatchesGibt auf keinem Ausgang etwas ausAlways 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-Mails dateSent. Die erste Version las nur dateStart, also setzte eine E-Mail, die häufigste Art von Kontakt, nie etwas zurück. Jetzt liest sie dateStart ?? 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 Deal muss vom Typ Kurztext sein. Ein Feld vom Typ URL lehnt die CRM-ID mit FIELD_010 ab, „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.