Die meisten Workflow Engines verlangen eigene Infrastruktur. Temporal braucht einen Cluster. AWS Step Functions lockt dich an einen Cloud Provider. Selbst leichtere Alternativen wollen einen separaten Server, ein proprietäres SDK und noch ein System, das du monitoren musst.
Aber du hast schon Postgres. flowly macht daraus eine Durable Workflow Engine.
Die Idee
Du definierst Workflows als normale async Functions. Jeder Step speichert sein Ergebnis in Postgres. Wenn der Prozess crashed, setzt der Workflow beim letzten abgeschlossenen Step fort. Kein neues System, keine neue Sprache, nichts zwischen dir und node index.js.
import { defineWorkflow, DurableEngine } from "flowly";
import pg from "pg";
const orderWorkflow = defineWorkflow(
"process-order",
async (ctx, input: { orderId: string; amount: number }) => {
const reserved = await ctx.step("reserve-inventory", {
run: () => inventoryService.reserve(input.orderId),
compensate: (result) => inventoryService.release(result.reservationId),
retry: { maxAttempts: 3, backoff: "exponential", initialDelayMs: 500 },
});
await ctx.sleep("cooling-period", { seconds: 30 });
const charged = await ctx.step("charge-payment", {
run: () => paymentService.charge(input.amount),
compensate: (result) => paymentService.refund(result.chargeId),
});
return { reserved, charged };
}
);
Das ist kein Pseudocode. Das ist der tatsächliche Workflow. ctx.step() speichert das Ergebnis. ctx.sleep() persistiert einen Timer. Compensate-Functions laufen in umgekehrter Reihenfolge, wenn ein späterer Step fehlschlägt.
Wie Step Persistence funktioniert
Beim ersten Durchlauf führt flowly jeden Step aus und speichert das Ergebnis in Postgres. Beim zweiten Durchlauf (nach einem Crash, einem Restart, einem Deployment) liest es die gespeicherten Ergebnisse und überspringt die Ausführung. Der Workflow sieht den gleichen Return Value wie beim ersten Mal.
Das bedeutet: Steps müssen nicht idempotent sein. Wenn reserve-inventory beim ersten Durchlauf läuft und das Ergebnis gespeichert wird, läuft es beim Replay nicht nochmal. Das ist der fundamentale Unterschied zu “einfach nochmal starten”.
Saga Compensation
Wenn charge-payment fehlschlägt, muss reserve-inventory rückgängig gemacht werden. flowly ruft die Compensate-Functions in umgekehrter Reihenfolge auf. Wenn auch die Compensation fehlschlägt, landet der Workflow in einer Dead Letter Queue zur manuellen Untersuchung.
Das ist das Saga Pattern, aber ohne die Boilerplate. Du deklarierst die Rollback-Logik direkt am Step, und flowly orchestriert den Rest.
Durable Sleep
ctx.sleep("wait", { hours: 24 }) persistiert einen Timer in Postgres. Der Prozess kann in der Zwischenzeit neu starten, deployen, crashen. Wenn die Engine das nächste Mal pollt und der Timer abgelaufen ist, geht der Workflow weiter.
Kein setTimeout. Kein Redis. Kein separater Scheduler. Postgres ist der Scheduler.
Concurrency
Mehrere Worker Prozesse können gleichzeitig Workflows abarbeiten. flowly nutzt FOR UPDATE SKIP LOCKED, um Arbeit zu verteilen. Kein Advisory Locking, keine Contention. Jeder Worker greift sich den nächsten verfügbaren Workflow Run und arbeitet ihn ab.
const engine = new DurableEngine({
db: new pg.Pool({ connectionString: process.env.DATABASE_URL }),
workflows: [orderWorkflow],
pollIntervalMs: 1000,
maxConcurrency: 5,
});
await engine.start();
Was ich gelernt hab
Step Replay klingt einfach: speichere das Ergebnis, gib es beim nächsten Mal zurück. Die Schwierigkeit liegt in den Randfällen. Was passiert, wenn ein Step läuft, das Ergebnis in Postgres landet, aber der Prozess crashed, bevor der Workflow-State aktualisiert wird? Was wenn ein Durable Sleep Timer abläuft, während gerade ein anderer Worker den gleichen Workflow verarbeitet?
Die Antwort auf die meisten dieser Fragen ist: Postgres Transactions und FOR UPDATE SKIP LOCKED. Postgres hat die Concurrency Primitives schon. Du musst sie nur richtig nutzen.