Automationen im Detail

Wie die Ausführungs-Engine für Automationen arbeitet: der Step Walker, die Lifecycle-Zustandsmaschine, der Trigger-Fanout, die drei Schritt-Typen und der Resilienz-Cron.

Diese Seite ist die Entwicklerreferenz dafür, wie trigger-basierte Automationen intern ausgeführt werden. Die produktseitige Einführung zum Bauen von Automationen in der Oberfläche finden Sie unter Automationen. Alles, was hier beschrieben wird, liegt in apps/api/convex/automations/.

Eine Automation ist eine Zeile in der Tabelle automations: ein triggerType, eine optionale triggerConfig, ein status und eine geordnete Liste von automationSteps. Wenn ein Trigger für einen Kontakt auslöst, fügt der Trigger-Fanout eine Zeile in automationRuns ein und plant den Step Walker ein, der die Schritte einen nach dem anderen ausführt und jeden davon als Zeile in automationStepRuns protokolliert.

Die Ausführungs-Engine (Step Walker)

Der Walker ist apps/api/convex/automations/stepWalker.ts — ein 'use node'-Modul, das die eigentliche Ausführung einer Automation verantwortet. Es ist der einzige Einstiegspunkt zum Ausführen von Schritten; die Logik pro Schritt liegt in den Schritt-Modulen, an die er dispatcht.

Ein Durchlauf rückt pro executeStep-Aufruf um genau einen Schritt vor. Es gibt im Walker keine if (step.kind === ...)-Verzweigung — er ermittelt das Modul des Schritts über stepModuleFor(step.stepType) und ruft zuerst parseConfig, dann execute auf. Das Modul liefert ein StepOutcome von { status: 'completed', emailSendId?, nextStepIndex? } oder { status: 'failed', error } zurück.

Atomares Beanspruchen eines Schritts

Zwei unabhängige Scheduler können denselben ausstehenden Schritt ansteuern: das ursprüngliche ctx.scheduler.runAfter(...) aus dem Moment, in dem der Schritt eingeplant wurde, und der Cron process pending delays (der jeden ausstehenden Schritt erneut dispatcht, dessen Verzögerung abgelaufen ist). Damit eine verzögerungsgesteuerte E-Mail nicht zweimal hinausgeht, muss ein frischer Dispatch (retryCount === 0) den Schritt atomar beanspruchen, bevor er ausgeführt wird.

Der Claim ist markStepExecuting in apps/api/convex/automations/stepExecutorQueries.ts — ein einzelnes Compare-and-Set von pending → executing. Nur der erste Aufrufer gewinnt; ein zweiter Aufruf sieht executing (oder einen späteren Status), erhält { claimed: false } und bricht still ab. Retries (retryCount > 0) setzen auf einem Step Run auf, den dieselbe Kette bereits besitzt, und überspringen deshalb den Claim.

Retries

Ein Schritt, dessen Modul failed zurückgibt (oder eine Exception wirft), wird nach einem festen Backoff-Schema wiederholt, bevor der Durchlauf aufgegeben wird:

VersuchVerzögerung vor dem Retry
1. Retry1 s
2. Retry5 s
3. Retry30 s
Nach 3 RetriesSchritt als failed markiert, Durchlauf cancelled

MAX_RETRY_ATTEMPTS ist 3 und RETRY_DELAYS_MS ist [1000, 5000, 30000] (ms), beide in apps/api/convex/lib/constants.ts. Beim endgültigen Fehlschlag ruft der Walker markStepFailed und anschließend cancelAutomationRun auf — ein Schritt, der seine Retries aufbraucht, bricht den gesamten Durchlauf ab. Der Walker ruft außerdem internal.automations.lifecycle.recordRunFailure auf; nach 5 aufeinanderfolgenden fehlgeschlagenen Durchläufen (AUTOMATION_FAILURE_BREAKER_THRESHOLD) wird die Automation über den Circuit Breaker automatisch pausiert.

Schleifen-Obergrenze

Ein Bedingungsschritt kann auf jeden beliebigen Zielindex verzweigen, auch auf einen früheren Schritt (der Editor lässt das zu). Ohne Schutzmechanismus würde eine Rückwärtsverzweigung endlos schleifen und den E-Mail-Schritt bei jedem Durchgang erneut versenden. Der Walker erzwingt MAX_STEPS_PER_RUN = 100: Jeder erfolgreiche Claim erhöht automationRuns.stepsExecuted, und sobald dieser Zähler die Obergrenze überschreitet, wird der Durchlauf als fehlgeschlagen markiert und mit der Meldung Automation exceeded 100 step executions — cancelled to prevent a loop abgebrochen. 100 liegt weit über jeder legitimen linearen Automationslänge.

Schutz über den Aktiv-Status

executeStep liest die übergeordnete Automation bei jedem Schritt neu ein. Gilt automation.status !== 'active', wird der aktuelle Schritt als fehlgeschlagen markiert und der Durchlauf abgebrochen.

Pausieren bricht fällige Durchläufe ab

Das Pausieren einer Automation greift nicht rückwirkend in laufende Durchläufe ein, aber der nächste fällig werdende Schritt eines laufenden Durchlaufs findet die Automation als nicht aktiv vor und bricht diesen Durchlauf ab. In der Praxis läuft die laufende Kohorte einer pausierten Automation aus, während der nächste Schritt jedes Kontakts fällig wird — es ist kein Einfrieren mit späterem Fortsetzen.

Lifecycle-Zustandsmaschine der Automation

Die Zustandsmaschine für automations.status liegt in apps/api/convex/automations/lifecycle.ts — dem einzigen Schreiber von status und den zugehörigen Feldern activatedAt, pausedAt und updatedAt (ADR-0024). Drei Zustände mit drei produktseitig adressierbaren Kanten:

draft   → active            (activate; validates trigger config + ≥1 step)
active  → paused            (pause)
paused  → active            (resume; re-validates trigger config + ≥1 step)

active → draft wird als illegal_edge abgelehnt — Admins müssen zuerst pausieren. Der Graph der zulässigen Kanten ist eine einzige Konstante LEGAL_EDGES statt verstreuter if (status !== ...)-Prüfungen.

Die öffentlichen Mutations (activate, pause und resume in automations/automations.ts) sind dünne Auth-Hüllen: Sie prüfen die Berechtigung automations:manage und dispatchen an internal.automations.lifecycle.transition, das ein typisiertes Ergebnis zurückgibt, statt eine Exception zu werfen. Das reasonToMessage der Hülle bildet den typisierten reason auf einen für Nutzer sichtbaren Text ab.

Ergebnisse von Übergängen

reason (bei ok: false)BedeutungMeldung der Hülle
automation_not_foundZeile fehlt„Automation not found“
illegal_edgeKante nicht in LEGAL_EDGES„Automation is not in a state that allows this transition“
no_steps→ active ohne Schritte„Automation must have at least one step to be activated“
invalid_trigger_config→ active mit fehlender Trigger-Konfiguration„Automation trigger is missing required configuration“

Ein doppelter Versuch in denselben Zustand (from === to) ist idempotent: Er liefert { ok: true, applied: 'recorded' }, schreibt eine Audit-Log-Zeile mit der Markierung no_op: true und emittiert kein PostHog-Event und keinen Patch.

Effekte pro Übergang

Jeder echte Übergang schreibt eine Audit-Log-Zeile und ein PostHog-track_event; Selbstschleifen schreiben nur die Audit-Zeile.

ÜbergangAudit-AktionPostHog-Event
draft → activeautomation.activatedautomation_activated
active → pausedautomation.pausedautomation_paused
paused → activeautomation.resumedautomation_resumed
paused → draftautomation.reverted_to_draftautomation_reverted_to_draft

→ active prüft vor dem Patchen zwei Vorbedingungen: mindestens eine Zeile in automationSteps und eine gültige Trigger-Konfiguration für den Trigger-Typ. Beide laufen sowohl bei draft → active als auch bei paused → active, sodass ein Resume nicht stillschweigend in einen kaputten aktiven Zustand zurückführen kann.

Die Lifetime-Statistikzähler (statsEntered, statsActive, statsCompleted) werden vom Lifecycle nicht angefasst — sie gehören dem Trigger-Fanout sowie dem Abschluss bzw. Abbruch von Durchläufen in stepExecutorQueries.ts.

Trigger-Fanout

Trigger sind über Module pro Art verdrahtet, die in apps/api/convex/automations/triggers.ts registriert werden. Vier Trigger-Arten sind enthalten:

Trigger-ArtLöst aus, wenntriggerConfig
contact_createdEin Kontakt angelegt wirdkeine (trifft immer zu)
contact_updatedEine beobachtete Eigenschaft sich ändert{ propertyKey }
event_receivedEin benanntes Event für einen Kontakt gesendet wird{ eventName }
topic_subscribedEin Kontakt ein Thema abonniert{ topicId }

Der gemeinsame Walker fireTrigger führt die Fanout-Pipeline für eine Art aus:

Passende Automationen abrufen

Aktive Automationen mit diesem Trigger über den Index by_status_trigger abfragen (status = 'active', triggerType = kind).

Jede einzeln auswerten

Pro Automation: triggerConfig über das parseConfig des Moduls verengen, dann module.matches(input, config) auswerten. contact_created trifft immer zu; die anderen drei vergleichen die ausgelöste Eingabe mit der gespeicherten Konfiguration.

Duplikate und Leerläufe überspringen

Die Automation überspringen, wenn der Kontakt für sie bereits einen running-Durchlauf hat (der Index by_automation_and_contact, gefiltert auf status: 'running'), und überspringen, wenn die Automation keine Schritte hat.

Einfügen und einplanen

Eine Zeile in automationRuns mit currentStepIndex: 0 und status: 'running' einfügen, den statsEntered-Shard erhöhen (statsActive wird vom Rollup als entered − completed − cancelled abgeleitet und nicht hochgezählt), etwaige vom Modul erzeugte triggerData anhängen und internal.automations.stepWalker.startAutomationRun einplanen.

Die Wrapper-Mutations für Kontakte und Themen (fireContactCreatedTrigger, fireContactUpdatedTrigger, fireTopicSubscribedTrigger) sind intern und werden aus den jeweils zuständigen Schreibpfaden aufgerufen. Events kommen über sendEvent herein, dessen einziger öffentlicher Einstiegspunkt die per API-Key authentifizierte Route POST /api/v1/events ist; sie ruft den gemeinsamen fireTrigger-Kern direkt auf und liegt bewusst nicht auf der öffentlichen Convex-Client-API, damit ein anonymer Aufrufer keine Events fabrizieren kann.

Start eines Durchlaufs vs. Beanspruchen eines Schritts

startAutomationRun plant Schritt 0 ein (unter Berücksichtigung seiner Eintrittsverzögerung) und legt die erste Zeile in automationStepRuns an. Von da an ruft jeder abgeschlossene Schritt advanceToStep auf, das den nächsten Schritt einplant oder — wenn der Index über den letzten Schritt hinausläuft — den Durchlauf als completed markiert.

Schritt-Typen

Drei Schritt-Arten sind enthalten, registriert in apps/api/convex/automations/steps.ts und jeweils unter steps/<kind>/ liegend. Der Walker dispatcht einheitlich an sie; nur ihr parseConfig, ihr execute und das optionale entryDelay/enrichForQuery unterscheiden sich.

E-Mail

steps/email/index.ts. Die Konfiguration ist { emailTemplateId, subjectOverride? }. Bei der Ausführung lädt der Schritt die Vorlage, ermittelt den Standardabsender der Organisation, setzt Betreff und Inhalt für die Sendeart automation zusammen (kein Tracking, keine Fußzeile) und stellt über internal.delivery.enqueue.enqueueNonCampaignSend eine Zeile in transactionalSends in den transaktionalen Pool ein (wobei die ID des eingereihten Sendevorgangs als emailSendId zurückgegeben wird). Die Provider-Auflösung, der Versand und der Übergang im Send-Lifecycle geschehen allesamt asynchron auf dem Worker — completed bedeutet hier, dass der Sendevorgang eingereiht wurde, nicht dass er zugestellt ist.

E-Mail-Schritte brauchen eine E-Mail-Adresse

Kontakte, die über Telefon-, SMS-, WhatsApp- oder generische Kanäle hereinkamen, haben keine E-Mail-Adresse. Der E-Mail-Schritt scheitert für diese Kontakte explizit mit Contact has no email address, sodass im Protokoll des Durchlaufs festgehalten wird, warum der Versand übersprungen wurde.

Der E-Mail-Schritt ist der einzige Schritt, der eine emailSendId zurückgibt (die ID der eingereihten Send-Zeile, nicht die Message-ID eines Providers); der Walker speichert sie über markStepCompleted am Step Run. Der Schritt implementiert außerdem enrichForQuery, damit getWithRelations die referenzierte Vorlage für den Editor mitladen kann.

Verzögerung

steps/delay/index.ts. Die Konfiguration ist { duration, unit }, wobei unit einen der Werte minutes | hours | days | weeks annimmt. Der Verzögerungsschritt ist die einzige Art, die entryDelay implementiert: computeEntryDelay rechnet die Konfiguration über delayConfigToMs in Millisekunden um, und der Walker nutzt diesen Wert als Vorlaufverzögerung beim Einplanen des Schritts.

Die Verzögerung findet also vor dem Dispatch des Schritts statt, nicht während der Ausführung — wenn das execute des Verzögerungsschritts läuft, ist die Wartezeit bereits verstrichen, sodass execute eine No-Op ist, die sofort completed zurückgibt. Das delayUntil des ausstehenden Schritts wird auf der Zeile in automationStepRuns festgehalten, damit der Cron ihn wiederherstellen kann.

Bedingung

steps/condition/index.ts. Die Konfiguration ist { condition, yesBranchStepIndex, noBranchStepIndex }. Bei der Ausführung serialisiert der Schritt die kanonische Condition und wertet sie über internal.automations.steps.condition.queries.evaluateConditionForContact gegen den Kontakt aus (eine Action kann die Datenbank nicht direkt lesen, deshalb läuft die Auswertung in einer Query).

Ein wahres Ergebnis verzweigt zu yesBranchStepIndex, ein falsches zu noBranchStepIndex. Der gewählte Index wird als nextStepIndex des Ergebnisses zurückgegeben und überschreibt den standardmäßigen sequentiellen currentStepIndex + 1. Ein Verzweigungsziel von null bedeutet „zum nächsten sequentiellen Schritt durchfallen“. Weil eine Verzweigung rückwärts zeigen kann, ist der Bedingungsschritt genau der Grund, warum der Walker die Schleifen-Obergrenze MAX_STEPS_PER_RUN erzwingt.

Crons und Resilienz

Der Walker plant den jeweils nächsten Schritt direkt über ctx.scheduler.runAfter(...) ein, sodass im Normalbetrieb kein Cron nötig ist, um einen Durchlauf voranzubringen. Der einzige Sicherheitsnetz-Cron liegt in apps/api/convex/crons.ts:

crons.interval(
  'process pending delays',
  { minutes: 5 },
  internal.automations.stepWalker.processPendingDelays,
);

processPendingDelays fragt automationStepRuns über den Index by_status_and_delay_until nach Zeilen ab, die noch pending sind und ein in der Vergangenheit liegendes delayUntil haben, und dispatcht für jede erneut executeStep. Das fängt Verzögerungsschritte auf, deren ursprüngliches runAfter verloren ging — etwa weil das Deployment offline war, als die Verzögerung ablief. Weil ein erneuter Dispatch und die ursprüngliche Einplanung beide feuern könnten, ist genau das das Duplikat, gegen das der atomare Step Claim schützt: Nur einer von beiden beansprucht den Übergang pending → executing; der Verlierer bricht ab.

Statusräume von Durchläufen und Schritt-Durchläufen

automationRuns und automationStepRuns haben ihre eigenen Status-Enums, getrennt vom Lifecycle der übergeordneten Automation:

TabelleStatus
automationRunsrunning, completed, cancelled
automationStepRunspending, executing, completed, failed, skipped

Das sind nebengeordnete Zustandsräume — ein laufender running-Durchlauf ist kein Zustand der Zustandsmaschine automations.status der übergeordneten Automation. Der Abschluss eines Durchlaufs (completeAutomationRun) erhöht den statsCompleted-Shard; ein Abbruch (cancelAutomationRun) erhöht den statsCancelled-Shard. statsActive wird nie direkt geschrieben — es wird vom Rollup als entered − completed − cancelled abgeleitet.

Ein skipped-Step-Run ist terminal und wird von markStepsSkipped geschrieben: Wenn ein Bedingungsschritt vorwärts verzweigt (sein Ziel liegt jenseits des nächsten sequentiellen Index), schreibt der Walker für jeden übersprungenen Schritt einen skipped-Step-Run, bevor er das Ziel einplant. Das ist der einzige Erzeuger von skipped — eine Rückwärtsverzweigung oder ein normales Vorrücken um +1 überspringt nichts. Der Funnel (getStepAnalytics / getAutomationStats) summiert den Zähler statSkipped jedes Schritts, damit die Analytics die von einem Kontakt übersprungenen Schritte abbilden.

Verwandte Themen