Langlaufende Prozesse, die
wirklich fertig werden.

Das, was niemand bauen will: ein Job-Runner, der Deployments übersteht, den richtigen Schritt genau so oft wiederholt wie nötig, bei einem Redrive nicht doppelt abrechnet und einen Workflow eine Woche schlafen lässt, ohne einen Prozess offen zu halten. Voltro liefert das mit — gebaut auf @effect/workflow + @effect/cluster, denselben Primitiven, die Effect-TS-Apps heute in Produktion nutzen.

onboarding.workflow.tsx
TypeScript
// onboarding.workflow.tsx — DESCRIPTOR (browser-safe)
import { workflow } from '@voltro/workflow/define'
import { Schema } from 'effect'

export const Onboarding = workflow({
  name:    'user.onboarding',
  payload: { userId: Schema.String },
  success: Schema.Struct({ ok: Schema.Boolean }),
})

// onboarding.workflow.server.tsx — EXECUTOR (server-only)
import { step, sleep } from '@voltro/workflow'
import { Effect, Schema } from 'effect'
import { generateText } from '@voltro/ai'
import type { AppContext } from '@voltro/runtime'

export default (ctx: AppContext) => ({ userId }) =>
  Effect.gen(function* () {
    const user = yield* step({
      name:    'load-user',
      success: Schema.Struct({ email: Schema.String, firstName: Schema.String }),
      execute: Effect.tryPromise(() =>
        ctx.store.select('users').where('id', userId).one()),
    })

    yield* step({
      name:    'send-welcome',
      execute: Effect.tryPromise(() =>
        ctx.store.insert('outbox', { to: user.email, template: 'welcome' })),
    })

    // Crash here? Resume replays from the journal —
    // the welcome won't be re-sent. Durable, not Effect.sleep.
    yield* sleep({ name: 'nudge-delay', duration: '1 day' })

    const nudge = yield* generateText({
      prompt: `Write a 1-sentence nudge for ${user.firstName}.`,
    })
    yield* step({
      name:    'send-nudge',
      execute: Effect.tryPromise(() =>
        ctx.store.insert('outbox', { to: user.email, body: nudge.text })),
    })

    return { ok: true }
  })

Sechs Dinge, die du nicht schreiben musst.

Eine Workflow-Datei deklariert ihr Payload-Schema, ihr Success-Schema und einen Executor. Alles Weitere unten ist bereits vorhanden — kein Hilfscode, kein Third-Party-SDK, kein separater Queue-Prozess.

Fortsetzbare Steps

Jeder in step() gewrappte Aufruf zeichnet sein Ergebnis auf. Bei Crash, Deploy oder Replica-Failover spielt der nächste Lauf das Journal erneut ab — abgeschlossene Steps liefern ihren gecachten Wert, nur der fehlgeschlagene Step wird erneut ausgeführt.

Idempotenz-Keys

Übergib einem Workflow einen idempotencyKey (payload → string). Gleichzeitige Starts mit demselben Key kollabieren zu einer einzigen Ausführung — keine doppelten Sends, keine Race-Condition-Doppelbuchungen.

Sleep + verzögerte Fortsetzung

Effect.sleep("1 day") in einem Workflow suspendiert dauerhaft. Die Runtime parkt die Ausführung, persistiert den Weckzeitpunkt und nimmt sie Tage später auf einer anderen Replica wieder auf, falls deine Flotte rotiert ist.

Externe Signale

awaitSignal(ctx, name) parkt den Workflow, bis ein Mensch (oder ein anderer Service) ein passendes Signal POSTet. Das Warten ist dauerhaft; Neustarts verlieren keine ausstehenden Freigaben.

Cron + geplante Läufe

*.cron.tsx-Dateien deklarieren uhrzeitgesteuerte Handler mit verpflichtender IANA-Zeitzone. Multi-Instance-Koordination ist eingebaut: standardmäßig Postgres advisoryLock oder Cluster-Shard-Ownership nach demselben Modell, das die Workflow-Engine nutzt.

Cluster-fähig

Gebaut auf @effect/cluster. Läuft auf einer einzelnen Maschine (Single Coordinator) oder einer Flotte beliebiger Größe (Shard-verteilt). Workflows migrieren beim Ausfall zwischen Runnern. Einzige Voraussetzung ist SQL-gestützter State.

Die Vier-System-Integration, die du nicht verdrahten musst.

Ohne ein Durable-Workflow-Primitiv erzwingt jede langlaufende Aufgabe eine Vier-Wege-Entscheidung: eine Queue wählen (Bull, Sidekiq, SQS), einen Scheduler wählen (k8s CronJob, EventBridge), eine Koordinationsstrategie wählen (Advisory Lock, Leader Election) und einen State-Store für die Wiederaufnahme wählen (Postgres, Redis, etc). Jede Schicht hat ihre eigene Auth, ihre eigene Observability-Lücke und ihre eigene On-Call-Seite. Workflow-Primitive kollabieren das zu einer einzigen Deklaration.

Wann man zu einem Workflow greift:

  • Mehrstufige Geschäftslogik, die einen Crash überstehen muss (Onboarding, Checkout-Fulfilment, Refund-Flow).
  • Externe API-Aufrufe, die man bei einem Retry nicht gefahrlos wiederholen kann (Stripe-Charge, E-Mail-Versand, AI-Aufruf, den du pro Token bezahlst).
  • Alles, was warten muss (verzögerte Fortsetzung, geplantes Follow-up, menschliche Freigabe).
  • Periodische Arbeit (nächtliche Rechnungserstellung, stündliches Metrics-Roll-up, wöchentlicher Digest).

Derselbe mehrstufige Job, beide Wege.

Eine Queue, ein Worker und eigene Retry-Buchhaltung
Zusammengesetzt
// Enqueue it and hope.
await queue.add('fulfil', { orderId }, {
  attempts: 3,
  backoff: { type: 'exponential', delay: 1000 },
})

// worker.ts — a SECOND deployment to run and scale
new Worker('fulfil', async (job) => {
  await charge(job.data.orderId)      // if the process dies HERE…
  await reserveStock(job.data.orderId)
  await sendEmail(job.data.orderId)   // …the retry charges again
})
Schritte, die den Prozess überleben
Eingebaut
export default defineWorkflow({
  name:  'orders.fulfil',
  input: Schema.Struct({ orderId: OrderId }),

  run: function* (ctx, { orderId }) {
    // Each step is journalled as it completes. A restart resumes
    // from the next one — it does not replay the ones behind it.
    yield* ctx.step('charge',  () => charge(orderId))
    yield* ctx.step('reserve', () => reserveStock(orderId))
    yield* ctx.step('notify',  () => sendEmail(orderId))
  },
})

Der linken Spalte fehlt der schwere Teil mit Absicht, weil er den meisten Fassungen davon fehlt: was passiert, wenn der Prozess zwischen Abbuchung und E-Mail stirbt. Die Antwort darauf ist ein Journal, ein Idempotenzschlüssel pro Schritt und ein Resume-Pfad — genau das ist die rechte Spalte, einmal für alle Workflows geschrieben statt einmal pro Job.

Was ein Absturz tatsächlich kostet.

  1. 1

    Jeder Schritt committet sein Ergebnis

    Das Ergebnis eines Schritts wird beim Abschluss in deine Datenbank journalisiert — in derselben Transaktion wie die Arbeit, wenn die Arbeit ein Datenbank-Write ist.

  2. 2

    Der Prozess stirbt

    Ein Deploy, ein Out-of-Memory-Kill, eine zurückgeholte Spot-Instanz. Hier passiert nichts Besonderes — das ist der Punkt.

  3. 3

    Eine Replica nimmt den Lauf auf

    Der Lauf wird von dem Prozess beansprucht, der gesund ist. Das Beanspruchen ist ein Lease, zwei Replicas können denselben Lauf also nicht gemeinsam fortsetzen.

  4. 4

    Er setzt fort, er wiederholt nicht

    Abgeschlossene Schritte werden aus dem Journal gelesen statt erneut ausgeführt. Der Kunde wird einmal belastet, und die bereits verschickte E-Mail geht nicht zweimal raus.

Durable Execution im Detail.

Was macht einen Workflow durable?

Das Ergebnis jedes Schritts wird journalisiert, bevor der nächste läuft. Stirbt der Prozess mittendrin — ein Deploy, ein OOM-Kill, ein verschwindender Knoten — setzt der Lauf auf einer anderen Instanz beim letzten abgeschlossenen Schritt fort statt von vorn. Bereits Getanes wird nicht wiederholt, noch nicht Getanes nicht verloren.

Diese Eigenschaft macht aus einem mehrstufigen Geschäftsprozess etwas, worüber man nachdenken kann. Karte belasten, Konto anlegen, Willkommensmail senden: ohne Durability hinterlässt ein Absturz zwischen Schritt zwei und drei einen Kunden, der bezahlt hat und nichts hat — und die Wiederherstellung ist ein Mensch, der Logs liest.

Weil Fortsetzung über Replay funktioniert, muss ein Schritt die Einheit des Nichtdeterminismus sein: alles, was mit der Außenwelt spricht, einen Zufallswert erzeugt oder die Uhr liest, gehört in einen Schritt — damit sein Ergebnis journalisiert und nicht beim zweiten Durchlauf anders berechnet wird.

Wie warten Workflows auf einen Menschen?

Mit einem Signal. Ein Lauf kann auf ein externes Ereignis warten — eine Freigabe, einen Webhook, einen Dokument-Upload — und das Framework bietet zwei Formen: eine, die wartet und dabei ihren Worker-Slot hält, und eine, die den Lauf vollständig SUSPENDIERT und den Slot freigibt, bis das Signal kommt.

Der Unterschied zählt auf menschlichen Zeitskalen. Eine dreitägige Freigabe, die einen Worker hält, ist ein Slot, den du drei Tage nicht nutzen kannst; multipliziert mit einer Schlange offener Freigaben ist die Flotte leerlaufend-und-voll. Das Framework warnt einmal pro Workflow, wenn ein deklariertes Timeout lang genug ist, dass vermutlich die suspendierende Variante gemeint war.

Es warnt, statt die Variante für dich zu tauschen, denn beide journalisieren unterschiedlich — das mitten in einer Historie still zu ändern ist eine Replay-Falle. Ein Hinweis, auf den du reagieren kannst, schlägt einen Umbau, um den du nicht gebeten hast.

Was passiert, wenn ein Schritt scheitert?

Retries sind deklarativ: eine Policy am Schritt, mit Backoff, statt einer Schleife, die du schreibst. Ein dauerhaft scheiternder Schritt schickt den Lauf schließlich in einen Dead-Letter-Zustand, wo er inspiziert und erneut gefahren werden kann — statt zu verschwinden oder ewig zu kreisen.

Kompensation — das Rückgängigmachen früherer Schritte, wenn ein späterer scheitert — ist gewöhnlicher Code in einem Schritt und wird wie alles andere journalisiert. Es gibt kein separates Saga-DSL zu lernen und kein zweites Ausführungsmodell mit eigener Fehlersemantik.

Auch die Flusskontrolle ist deklarativ. Ein Workflow kann Nebenläufigkeitsgrenzen, Throttling, Debouncing oder das Ersetzen eines laufenden Runs deklarieren, und die Engine lässt Starts entsprechend zu oder verschiebt sie — jene Koordination, die sonst zu einer von Hand gepflegten Lock-Tabelle wird.

Braucht das zusätzliche Infrastruktur?

Nein. Das Journal liegt in deiner Datenbank, in framework-eigenen Tabellen, die mit dem Rest deines Schemas migrieren. Es gibt keinen separaten Workflow-Dienst zu deployen, keinen Queue-Broker zu betreiben und keinen zweiten Datenspeicher, dessen Konsistenz du gegen deine eigene abwägen musst.

Rausskalieren ist derselbe Code. Mit mehreren Instanzen koordiniert die Engine so, dass ein Lauf einmal ausführt, Arbeit von der gerade freien Instanz übernommen wird und die Läufe einer sterbenden Replica anderswo fortsetzen. Am Workflow ändert sich zwischen einem Container und zwanzig nichts.

Auf Deployments, in denen entfernte Änderungen den Change-Stream erreichen, weckt ein Signal oder Übergang auf einer Replica die anderen sofort, statt auf den nächsten Storage-Poll zu warten — die Latenz zwischen Replicas ist damit nicht durch ein Poll-Intervall begrenzt, während der Poll als Sicherheitsnetz bleibt.

Was die Workflow-Engine für dich übernimmt.

Fähigkeiten durabler Workflows und wie sie ausgedrückt werden
FähigkeitWie es funktioniert
Journalisierte SchritteDas Ergebnis jedes Schritts wird vor dem nächsten persistiert; ein Absturz setzt beim letzten fertigen Schritt fort.
SignaleAuf ein externes Ereignis warten — mit gehaltenem Worker oder mit suspendiertem Lauf für menschenlange Wartezeiten.
RetriesDeklarative Policy mit Backoff; erschöpfte Läufe landen im Dead-Letter-Zustand zur Prüfung und Wiederholung.
KompensationGewöhnlicher Code in einem Schritt, journalisiert wie jeder andere — kein separates Saga-DSL.
FlusskontrolleDeklarierte Nebenläufigkeit, Throttle, Debounce oder Ersetzen laufender Runs — von der Engine zugelassen statt handgebautem Lock.
SpeicherungDeine Datenbank. Kein Workflow-Dienst, kein Broker, kein zweiter Datenspeicher zum Abgleichen.

Häufige Fragen

Worin unterscheidet sich das von einer Job-Queue?

Eine Queue stellt eine Nachricht zu und überlässt dir den Ablauf: stirbt der Handler auf halbem Weg, läuft der ganze Job erneut — jeder Schritt muss also idempotent sein. Ein durabler Workflow journalisiert jeden Schritt, Fortsetzung heißt also fortsetzen statt neu starten. Genau das macht mehrstufige Prozesse mit externen Seiteneffekten beherrschbar.

Brauche ich Temporal oder einen separaten Worker-Dienst?

Nein. Die Engine läuft in deiner App und journalisiert in deine Datenbank. Es gibt keinen separaten Dienst zu deployen oder zu betreiben und keinen zweiten Datenspeicher, dessen Konsistenz du gegen deine eigene abwägen musst.

Was muss in einen Schritt?

Alles Nichtdeterministische oder nach außen Sichtbare: Netzwerkaufrufe, Datenbankschreibvorgänge, Zufallswerte, die aktuelle Zeit. Fortsetzung funktioniert über Replay — ein außerhalb eines Schritts berechneter Wert kann beim zweiten Durchlauf abweichen; innerhalb wird er journalisiert und identisch wiedergegeben.

Kann ein Workflow tagelang auf eine Freigabe warten?

Ja, und dafür willst du die suspendierende Variante, die den Worker-Slot währenddessen freigibt. Das Framework weist einmal pro Workflow darauf hin, wenn ein deklariertes Timeout lang genug ist, dass Suspendieren vermutlich gemeint war — statt die Variante unter einer laufenden Historie zu tauschen.

Wie debugge ich einen schiefgelaufenen Lauf?

Das Journal ist der Debugger: jeder Schritt, sein Ergebnis und sein Timing sind festgehalten, und die CLI listet Läufe, filtert Dead-Letter-Läufe und fährt sie erneut. Du liest, was tatsächlich passiert ist, statt es aus Logzeilen zu rekonstruieren.

Workflows komponieren mit dem Rest der Runtime.

Öffne das Framework. Schau es dir selbst an.

Jede Primitive auf dieser Seite ist heute im Framework. Klone den Starter, lass `voltro dev` laufen, in zwei Minuten ist es auf dem Bildschirm.