Zum Inhalt springen
Durable Workflows in Postgres: Ein Feldtest von pg_durable
postgresdurable-execution

Durable Workflows in Postgres: Ein Feldtest von pg_durable

Ein Feldtest von Microsofts pg_durable: Durable Workflows direkt in Postgres, ohne Zusatzdienste. Wo die Wette aufgeht und wo sie wehtut.

Ich habe generative KI-Produkte auf so ziemlich jeder Art von Scheduler gebaut und betrieben. Selbstgebaute Job-Runner. Trigger.dev. Airflow. AWS Batch. Durable Engines im Stil von Temporal. Manche dieser Entscheidungen waren gut. Eine davon hat mich etwas gelehrt, das ich heute als Regel formuliere.

Einer der teuersten Fehler, die ein Startup machen kann, ist der Bau eines eigenen Schedulers.

Der Fehler passiert leicht, denn die erste Version ist wirklich klein. Eine jobs-Tabelle, eine Statusspalte, ein Worker, der nach offenen Zeilen pollt, ein Cron-Eintrag, der das Ganze anstößt. Es läuft. Es übersteht die Demo. Es übersteht die ersten Monate. Die Kosten kommen später, und sie kommen immer, denn ein Scheduler ist nicht der Teil, den Sie geschrieben haben. Er ist die Summe der Teile, die Sie nicht geschrieben haben: Retries mit Backoff, Idempotenz, Exactly-once gegen At-least-once, Crash Recovery mitten in einem Job, Races zwischen zwei Workern, die dieselbe Zeile beanspruchen, Poison Jobs, die für immer scheitern und die Queue verstopfen, Transparenz darüber, was hängt und warum, Backfills für den Tag, an dem still und leise etwas kaputt war. Jeder einzelne dieser Punkte ist in einem ausgereiften System ein gelöstes Problem und in einer Eigenbaulösung ein Incident um zwei Uhr nachts. Jede Stunde, die in die Härtung der eigenen Orchestrierungsschicht fließt, fehlt dem Produkt, für das es das Unternehmen überhaupt gibt.

Die ausgereiften Systeme haben andere Kosten, und die sind ebenfalls real. Sie funktionieren alle. Gemeinsam ist ihnen, dass die Orchestrierung woanders liegt als die Daten, mit denen sie arbeitet. Der Job-State steckt in einem System, die Geschäftsdaten in einem anderen, und ein spürbarer Teil jedes Projekts wird zu dem Klebstoff, der beide Seiten konsistent hält: Statusspalten, die über die Grenze hinweg gespiegelt werden, Polling, das die beiden Hälften abgleicht, Jobs, deren einziger Zweck es ist, zu bemerken, wenn beide auseinandergelaufen sind.

Sie haben also die Wahl zwischen zwei Arten von Overhead. Selbst bauen und sich mit jedem Fallstrick von Durable Execution allein herumschlagen. Oder eine ausgereifte Engine einsetzen und ein zweites System betreiben, das einen Netzwerk-Hop von Ihren Daten entfernt liegt.

Vor diesem Hintergrund ist pg_durable interessant. Microsoft hat es als PostgreSQL-Extension veröffentlicht, die Durable Workflows innerhalb der Datenbank selbst ausführt. Nicht als weiteren Dienst, den Sie betreiben, sondern als etwas, das Ihr vorhandenes Postgres einfach tut. Bei einer großen Klasse von Jobs liegt der State ohnehin schon in Postgres, der Vorschlag lautet also, die Orchestrierung ebenfalls dort anzusiedeln. Kein separater Scheduler. Kein Queue-Dienst. Kein zweiter Account. Etwas, das vollständig auf einem Laptop läuft und sich in einen Supabase-Stack einfügt, ohne dem Architekturdiagramm einen einzigen Kasten hinzuzufügen.

Ich habe einen Tag damit verbracht, ein lokales Test-Harness zu bauen und es gegen echte Workflow-Formen laufen zu lassen. Und ich habe versucht, es zu zerbrechen. Dieser Beitrag ist das Ergebnis. Kurz gesagt: Die Idee trägt, wenn Ihr Workflow-State ohnehin in Postgres gehört. Der Preis dafür ist, dass Sie ab dann eine echte Postgres-Extension betreiben, mit allem, was das fürs Schreiben der Workflows und fürs Deployment bedeutet.

#Der ungeplante Scheduler

Kaum jemand nimmt sich vor, einen Scheduler zu bauen. Sie landen einfach irgendwann bei einem.

Angenommen, Sie wollen eingehende E-Mails klassifizieren. Nichts Exotisches: ein Modellaufruf, eine Kategorie, die zurück in eine Zeile geschrieben wird. Das direkt im Request-Pfad zu erledigen, ist der naheliegende erste Schritt, bis die Latenz oder die Kosten eines synchronen Modellaufrufs das unhaltbar machen. Also verlagern Sie es weg vom heißen Pfad. Jetzt brauchen Sie einen Ort für die Arbeit, etwas, das sie ausführt, eine Möglichkeit, fehlgeschlagene Aufrufe zu wiederholen, und eine Aufzeichnung dessen, was bereits gelaufen ist. Das ist ein Scheduler. Sie haben ihn nicht geplant. Sie sind unter Zeitdruck Schritt für Schritt hineingeraten, und genau dann sieht die billige Eigenbauvariante gut genug aus.

Hier kommt der agentische Umbruch ins Spiel. Nicht weil plötzlich alle mit Absicht Orchestrierung bauen, sondern weil immer mehr Features diese Form haben. Ein Agent, der ein Tool aufruft, auf ein Modell wartet, je nach Ergebnis verzweigt und nach einem Fehler weiterläuft, ist dasselbe Problem der Hintergrundverarbeitung, nur im anderen Gewand. Je mehr Teile Ihres Produkts auf Modellaufrufen beruhen, die Sie nicht synchron machen wollen, desto öfter stehen Sie an dieser Weggabelung: die kleine Sache bauen, die Sie später noch verfolgen wird, oder eine echte Engine einbinden und neben Ihren Daten betreiben.

pg_durable ist eine Antwort auf diese Weggabelung, und zwar eine ungewöhnliche. Sie lautet: Die Orchestrierung gehört in Ihre Datenbank. Genau deshalb lohnt sich gerade jetzt ein ernsthafter Blick darauf, gemessen an der am wenigsten glamourösen und zugleich wichtigsten Achse, die es gibt: Was passiert, wenn ein Schritt fehlschlägt?

#Was pg_durable tatsächlich ist

pg_durable ist eine PostgreSQL-Extension, aktuell als Preview in Version 0.2.2, die Durable Execution in die Datenbank bringt. Sie beschreiben einen Workflow als Graph aus SQL-Schritten, mit einer kleinen domänenspezifischen Sprache (DSL), einer Mini-Syntax für genau eine Aufgabe, unterhalb des Schemas df.*, starten ihn mit df.start(), und ein Hintergrund-Worker führt den Graphen asynchron aus und schreibt für jeden Schritt einen Checkpoint in ganz normale Tabellen. Stürzt die Datenbank mitten im Workflow ab oder startet neu, wird die Ausführung am letzten Checkpoint fortgesetzt, statt von vorn zu beginnen.

Unter der Haube ist es mit pgrx gebaut und sitzt auf zwei Rust-Bibliotheken: duroxide liefert die Orchestrierungs-Runtime (deterministisches Replay, Checkpoints, Timer), und duroxide-pg persistiert den Runtime-State in ein eigenes Schema duroxide.*. Ihre DSL-Graphen liegen in df.*. Der Worker wird über shared_preload_libraries geladen, das heißt, die Installation ist eine echte Datenbankoperation: die Extension, ein Preload-Eintrag, ein Neustart und eine Rechtevergabe, kein pip install.

Der Perspektivwechsel ist der eigentliche Punkt. Retry-State, Fortschrittsverfolgung und Checkpointing sind kein maßgeschneiderter Anwendungscode mehr, sondern Zeilen in Ihrer Datenbank: abfragbar mit demselben SQL, abgesichert mit demselben Auth-Modell, gesichert mit derselben Backup-Strategie wie alles andere, was dort ohnehin schon liegt.

#Das lokale Harness

Ich wollte reproduzierbare Belege, kein Bauchgefühl. Das Harness baut die Extension aus einer fest gepinnten Upstream-Revision, führt sie in Docker aus, legt eine Rolle poc_runner ohne Superuser-Rechte an, gibt ihr Nutzungsrechte für pg_durable plus HTTP-Zugriff, spielt Beispieldaten ein, führt jedes SQL-Szenario aus, startet den Container für die Durability-Probe neu und schreibt die Artefakte jedes Laufs in ein Verzeichnis mit Zeitstempel. Das Ganze ist ein einziges Skript, und das am Ende verlinkte öffentliche Repository reproduziert alles davon.

Die erste konkrete Erkenntnis kam, bevor überhaupt ein Workflow lief. Der saubere Build aus den Quellen dauerte mehrere Minuten, weil er erst cargo-pgrx kompiliert, dann die Extension im Release-Modus und dann ein pgrx-Binary, das das SQL generiert. Danach ist das gecachte Image angenehm, aber der erste Lauf ist keine Sache von dreißig Sekunden, und dieser erste Eindruck zählt, wenn Sie bewerten, ob ein Werkzeug einen Platz im Stack verdient.

Die HTTP-Fähigkeit ist der zweite Punkt, den man vorher kennen sollte, denn sie ist ein Feature-Flag zur Build-Zeit und keine Laufzeiteinstellung. Die Extension lässt sich ganz ohne HTTP bauen, mit einer Allowlist für Azure-Domains, mit einer Allowlist für Test-Domains oder für die lokale Entwicklung ganz ohne diese Schutzmechanismen. Das Docker-Image von Upstream baut mit der Test-Domains-Option, deshalb können meine Szenarien api.github.com ohne Secrets aufrufen. Die Folge: Lokale, Test- und Produktions-Builds können sich bei ausgehendem HTTP unterschiedlich verhalten. Parität müssen Sie also bewusst herstellen, statt sie vorauszusetzen.

#Die DSL an Beispielen

Das README zeigt Ihnen den Happy Path. Die DSL ist ausdrucksstärker, als dieser erste Blick vermuten lässt, und bei den Operatoren macht es klick. Ein paar, die zählen:

  • ~> führt Schritte nacheinander aus.
  • & fächert in parallele Branches auf und wartet auf alle.
  • | lässt Branches gegeneinander laufen und nimmt den, der zuerst fertig ist.
  • |=> speichert das Ergebnis eines Schritts unter einem Namen, den spätere Schritte mit $name lesen.
  • df.loop(body, condition) wiederholt einen Schritt, bis ein SQL-Prädikat nicht mehr wahr ist.
  • df.setvar() setzt eine Variable vor dem Start, referenziert als {name}.

Der kleinstmögliche Workflow gibt einen Wert zurück und ist fertig:

SELECT df.start($$SELECT 'hello pg_durable'$$, 'poc-readiness');

Zwei Details sind erwähnenswert. Jeder SQL-Schritt ist ein String-Literal, und ich schreibe diese Literale mit dem Dollar-Quoting von Postgres ($$...$$), damit sich das SQL darin normal liest, statt jedes Anführungszeichen verdoppeln zu müssen. Gleich mehr dazu, wie sich die Syntax zähmen lässt, inklusive der Frage, wie man SQL gar nicht erst inline schreibt. Das zweite Argument ist ein sprechendes Label, für das Sie dankbar sein werden, wenn Sie später df.instances lesen.

Die erste Form, die zählt, ist die Fan-out-Aggregation. Drei unabhängige Abfragen, die keinen Grund haben, nacheinander zu laufen, in einem einzigen Schreibvorgang wieder zusammengeführt:

SELECT df.start(
    (
        ($$SELECT count(*)::int AS user_count
            FROM events WHERE kind = 'signup'$$ |=> 'users')
      & ($$SELECT count(*)::int AS order_count
            FROM events WHERE kind = 'paid_order'$$ |=> 'orders')
      & ($$SELECT coalesce(sum(amount), 0)::numeric(12,2) AS revenue
            FROM events WHERE kind = 'paid_order'$$ |=> 'revenue')
    )
    ~> $$INSERT INTO metric_snapshots(user_count, order_count, revenue)
        VALUES ($users.user_count, $orders.order_count, $revenue.revenue)$$,
    'poc-async-aggregation'
);

Der Operator & führt die drei Branches parallel aus, jeder speichert sein Ergebnis, und der abschließende sequenzielle Schritt liest alle drei über ihren Namen. In meinem Lauf waren drei Branches, die jeweils zwei Sekunden geschlafen haben, insgesamt nach rund 5,8 Sekunden fertig, nicht nach den gut sechs Sekunden, die eine serielle Ausführung gekostet hätte. Das Fan-out ist echt, und es wird in SQL ausgedrückt, ohne Anwendungscode, der das Zusammenführen koordiniert.

Die zweite Form ist eine Work Queue, die per Durable Loop abgearbeitet wird. Immer den nächsten offenen Job dispatchen, bis keiner mehr übrig ist:

SELECT df.start(
    df.loop(
        $$SELECT * FROM dispatch_next_job()$$,
        $$SELECT EXISTS (SELECT 1 FROM jobs WHERE status = 'pending')$$
    ),
    'poc-dispatch-jobs'
);

Das ist das Muster, das Sie normalerweise einen pg_cron-Eintrag, eine jobs-Tabelle, eine Statusspalte, einen Retry-Zähler und einen pollenden Worker kostet. Und es ist, nicht zufällig, genau das Muster, zu dem Teams greifen, wenn sie anfangen, sich einen Scheduler selbst zu stricken. Hier ist es eine einzige Durable Function. In meinem Lauf hat sie die drei offenen Jobs verarbeitet und die sechs Audit-Zeilen geschrieben, die ich erwartet hatte, wobei die Loop-Bedingung entschieden hat, wann Schluss war.

Die dritte Form ist die, an die Leute denken, wenn sie „KI-Pipeline“ hören: einen externen Endpunkt als Schritt aufrufen. pg_durable bringt einen HTTP-Node mit, df.http(), den ich stellvertretend für einen Transformations- oder Anreicherungsaufruf verwendet habe:

SELECT df.setvar('transform_url', 'https://api.github.com/rate_limit');

SELECT df.start(
    $$SELECT id, input_text FROM api_transforms
        WHERE status = 'queued' ORDER BY id LIMIT 1$$ |=> 'item'
    ~> $$UPDATE api_transforms SET status = 'processing' WHERE id = $item.id$$
    ~> (
        df.http(
            '{transform_url}',
            'GET',
            NULL,
            '{"Accept": "application/vnd.github+json", "User-Agent": "pg-durable"}'::jsonb,
            30
        ) |=> 'api_response'
    )
    ~> $$UPDATE api_transforms
        SET status = 'completed',
            response = $api_response::jsonb,
            transformed_text = upper(input_text)
        WHERE id = $item.id$$,
    'poc-http-ai-simulation'
);

Der Aufruf lieferte HTTP 200 zurück, und der Workflow persistierte die Antwort als JSON-Spalte. Eines sollte man früh verinnerlichen: df.http() lässt einen Workflow nicht scheitern, nur weil der Endpunkt einen Status außerhalb von 2xx zurückgibt. Während der Entwicklung lieferte ein Test-Endpunkt 402, und pg_durable speicherte das als abgeschlossenen HTTP-Node mit ok=false. Der Workflow muss den Status selbst prüfen und entscheiden, was zu tun ist. Das ist vertretbar, aber es ist die Art von Detail, die man lieber aus einem Blogbeitrag lernt als aus einem stillen Erfolg in Produktion.

#Wie schwierigere Workflows aussehen

Spielzeugbeispiele zeigen, dass die Syntax funktioniert. Die interessante Frage ist, was passiert, wenn man genau die Stellen belastet, die Durable Engines überhaupt erst nützlich machen.

Trigger als interne Ereignisquelle. Ein ganz normaler Postgres-Row-Trigger kann einen Workflow starten. Ich habe einen BEFORE INSERT OR UPDATE OF status-Trigger verdrahtet, der df.start() aufruft und die neue Instanz-ID zurück in die Zeile schreibt:

CREATE OR REPLACE FUNCTION trigger_item_start_workflow()
RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE inst_id text;
BEGIN
    IF NEW.status = 'ready' AND NEW.workflow_instance_id IS NULL THEN
        inst_id := df.start(
            format($q$SELECT * FROM process_trigger_item(%s, '{sys_instance_id}')$q$, NEW.id),
            format('trigger-item-%s', NEW.id)
        );
        NEW.workflow_instance_id := inst_id;
    END IF;
    RETURN NEW;
END;
$$;

Eine Zeile einfügen, eine Zeile aktualisieren, zwanzig Zeilen per Bulk-Update ändern: Jeder Übergang hat einen Workflow gestartet. Insgesamt liefen 22 Workflows über die Insert-, Update- und Bulk-Update-Pfade an, und ein zurückgerollter Trigger-Start hat nichts hinterlassen. Damit wird ein Postgres-natives Pub-Sub auf Basis von Zeilenzuständen plausibel: Die Datenbank bemerkt die Zustandsänderung und orchestriert die Reaktion, ohne Message Bus dazwischen. Beachten Sie {sys_instance_id}, eine eingebaute Substitution, die dem Workflow seine eigene ID übergibt. Für ein agentisches System ist das ein unauffällig mächtiges Primitiv. Eine Zeile, die in einer Tabelle landet, kann einen mehrstufigen Agent-Run mit Durable Execution anstoßen, und die Verbindung zwischen Zeile und Lauf ist einfach ein Foreign Key.

Einen Neustart überleben. Das ist der Test, der mit externen Systemen üblicherweise wehtut. Ich habe einen Workflow gestartet, der einen Marker schreibt, acht Sekunden schläft und dann einen zweiten Marker schreibt, und ich habe den kompletten PostgreSQL-Container neu gestartet, während er schlief:

SELECT df.start(
    $$INSERT INTO restart_probe(marker) VALUES ('before_sleep')$$
    ~> df.sleep(8)
    ~> $$INSERT INTO restart_probe(marker) VALUES ('after_sleep')$$,
    'poc-restart-resilience'
);

Nachdem der Container wieder da war, enthielt die Tabelle genau ein before_sleep und ein after_sleep. Der abgeschlossene Schritt wurde nicht erneut ausgeführt, der ausstehende Schritt lief trotzdem. Durability hat funktioniert, und ich konnte das auf einem Laptop in unter einer Minute testen. Einen Crash-Test dieser Güte auf einer geclusterten externen Engine nachzustellen, ist ein deutlich größeres Unterfangen.

Freigabe durch Menschen per Signal. Ein Workflow kann pausieren, bis ein externes Signal eintrifft, und dann je nach Payload verzweigen:

SELECT df.start(
    df.wait_for_signal('approval', 60) |=> 'decision'
    ~> df.if(
        $$SELECT COALESCE(($decision::jsonb->'data'->>'approved')::boolean, false)$$,
        $$UPDATE approvals SET status = 'approved' WHERE status = 'needs_review'$$,
        $$UPDATE approvals SET status = 'rejected' WHERE status = 'needs_review'$$
    ),
    'poc-signal-approval'
);

Das Signal kommt aus einer anderen Session mit df.signal(inst_id, 'approval', '{"approved": true}'). Die Form „erst warten, dann handeln“ funktionierte sauber: Der Workflow parkte, bis das Signal eintraf, und verzweigte dann je nach Payload. Genau diese Human-in-the-Loop-Pause brauchen agentische Workflows ständig, wenn ein Lauf auf die Freigabe durch einen Menschen wartet und danach den einen oder den anderen Weg nimmt.

Moderate Last. Das war kein Benchmark, aber ich wollte sichergehen, dass es auf einem Laptop nicht umkippt. Eine einzelne Session, die 200 winzige Workflows einreicht, war nach rund 8,8 Sekunden fertig. Acht parallele psql-Sessions, die zusammen 160 Workflows einreichten, brauchten rund 10,1 Sekunden, ohne hängende Instanzen. Für lokale Entwicklung und die Job-Mengen, die in den meisten internen Pipelines tatsächlich anfallen, reicht das bequem.

Observability, die Sie schon kennen. Der gesamte Lauf war aus SQL heraus inspizierbar. df.list_instances(), df.instance_info(), df.instance_nodes(), df.instance_executions(), df.result() und df.metrics() lieferten mir Status, Historie pro Node, gespeicherte Ergebnisse und aggregierte Zählungen, ohne dass ich ein separates Dashboard lernen musste. Der abschließende Snapshot zeigte 14 Instanzen: 12 abgeschlossen, 2 absichtlich fehlgeschlagene Sicherheitstests und 0 noch laufende. Fehlgeschlagene HTTP-Nodes haben das JSON-Fehlerdetail aufbewahrt. Beim Debuggen um zwei Uhr nachts ist es ein echter Komfort, den Workflow-State mit demselben Werkzeug abzufragen, das Sie ohnehin für Ihre Daten benutzen.

#Die DSL in den Griff bekommen

Der Einwand, den ich für tödlich hielt, erwies sich als größtenteils vermeidbar. Geschäftslogik als escapte Strings innerhalb von SQL zu schreiben, ist wirklich unangenehm, und wenn das die einzige Option wäre, würde ich das Werkzeug für alles, was über Triviales hinausgeht, nicht empfehlen. Es ist nicht die einzige Option. Drei Techniken nehmen dem Ganzen den größten Teil des Schmerzes, und ich habe jedes Beispiel in diesem Beitrag in dieser Form noch einmal gegen den gepinnten Build laufen lassen, um zu bestätigen, dass es ausführbar ist.

Nodes mit Dollar-Quoting schreiben. Jeder Node ist ein String-Literal, und Postgres erlaubt Literale in der Form $$...$$ statt '...'. Das SQL darin liest sich dann normal, mit echten einfachen Anführungszeichen, statt dass jedes einzelne verdoppelt werden muss. Alle Beispiele oben nutzen diese Form, und Ergebnis-Substitutionen wie $item.id funktionieren darin problemlos. So schreibt auch der offizielle User Guide seine Beispiele, obwohl der mitgelieferte Agent Skill kurioserweise noch den älteren Stil mit verdoppelten Anführungszeichen lehrt.

Keine Logik mehr inline. Rufen Sie echte Funktionen auf. Ein Node kann beliebiges SQL sein, auch der Aufruf einer Funktion, die Sie ganz gewöhnlich definiert haben. Für alles jenseits eines trivialen Schritts halten Sie die Logik also in normalen PL/pgSQL- oder SQL-Funktionen, mit voller Editor-Unterstützung, Syntax-Highlighting und eigenen Tests, und der Node bleibt ein dünner Aufruf:

-- the real logic lives here, edited and tested like any other function
CREATE FUNCTION mark_processing(p_id bigint) RETURNS void
LANGUAGE sql AS $$
    UPDATE api_transforms SET status = 'processing' WHERE id = p_id;
$$;

-- the workflow shrinks to topology plus thin calls
SELECT df.start(
    $$SELECT id FROM api_transforms WHERE status = 'queued' LIMIT 1$$ |=> 'item'
    ~> $$SELECT mark_processing($item.id)$$
    ~> $$SELECT finalize($item.id)$$
);

Konsequent umgesetzt, steckt keine Zeile Ihrer Geschäftslogik mehr in einem String. Das Harness hat das für den Job-Dispatcher und den Trigger-Handler bereits so gemacht, und es ist der mit Abstand größte Gewinn an Lesbarkeit.

Nutzen Sie die Funktionsform der DSL. Wenn die Operatorensuppe (& | ?> !> @>) das Abschreckende ist: Jeder Operator hat ein benanntes Äquivalent, df.join(), df.race(), df.if(), df.if_rows(), df.loop(), df.seq(). Die Verdrahtung liest sich dann wie ganz normale Funktionsaufrufe.

Was das alles nicht behebt, sollte klar gesagt werden, denn es ist die faire Fassung der Kritik. Es gibt kein Parameter-Binding: Eine Form df.sql(query, args) mit echten gebundenen Parametern wurde vorgeschlagen, gebaut und wieder zurückgenommen, Werte werden also weiterhin als Text in das SQL substituiert, was eine Angriffsfläche für SQL-Injection ist, die Sie ernst nehmen müssen. Es gibt keinen wiederverwendbaren benannten Workflow: df.start() registriert eine Instanz pro Aufruf, „einmal definieren, per Name mit Parametern aufrufen“ bauen Sie sich also selbst, indem Sie es in eine Funktion kapseln. Und es gibt kein typisiertes SDK, um in einer anderen Sprache zu schreiben. Die zugrunde liegenden duroxide-Bibliotheken lassen sich aus Rust, Python oder Node ansteuern, aber das ist ein anderes Produkt. pg_durable ist die SQL-Autorenschicht, und der Graph bleibt SQL.

Die ehrliche Fassung des Einwands lautet also nicht „man schreibt alles als nackte Strings“. Die String-Hässlichkeit, vor der Leute zurückschrecken, lässt sich beheben. Die eigentliche Grenze ist, dass Orchestrierung als SQL-Ausdruck mit Textsubstitution und ohne Typprüfung geschrieben wird.

#Wo es hakt

Ein ausgewogener Feldtest muss auch die Stellen benennen, an denen ich mir die Finger verbrannt habe. Nichts davon ist ein Ausschlusskriterium. Aber all das wissen Sie lieber vorher.

SQL-Exceptions wurden nicht automatisch wiederholt. Das hat mich am meisten überrascht. Ich habe eine absichtlich unzuverlässige SQL-Funktion gebaut, die beim ersten Versuch scheitert und beim zweiten gelingt. Der Workflow scheiterte beim ersten Versuch und blieb gescheitert. Es gab keinen automatischen Retry. Ein manuell erneut eingereichter Workflow lief durch, weil die Funktion idempotent genug war, um beim nächsten Versuch zu bestehen. Die Dokumentation spricht sehr allgemein über Retries und Fortsetzen, deshalb habe ich das mit einer aktuellen Reproduktion beim Projekt gemeldet (Issue #155). Solange keine echte Retry-Policy gezeigt wird, gehen Sie davon aus, dass Sie für SQL-Fehler auf Anwendungsebene explizite Idempotenz und einen eigenen Wiederherstellungspfad brauchen. Für Agentenschritte, die unzuverlässige externe Modelle aufrufen, ist das keine Fußnote, sondern Kern des Designs.

Laufende Workflows führen die aktuelle Version Ihres Codes aus, nicht die, mit der sie gestartet sind. An diesen Befund würde ich ein Warnschild hängen. Ich habe einen Workflow mit einem Sleep gestartet, während des Schlafens eine referenzierte SQL-Funktion per CREATE OR REPLACE ersetzt, und der spätere Schritt hat den neuen v2-Rumpf ausgeführt. Schlimmer noch: Die referenzierte Funktion zu droppen, bevor der spätere Schritt lief, hat den Workflow komplett scheitern lassen. Die Folgerung ist direkt: Eine Routine-Migration kann Workflows verändern oder kaputtmachen, die bereits laufen. Workflow-Logik ist lebender Datenbankcode, und Deployments müssen berücksichtigen, was gerade unterwegs ist.

Die Transaktionsgrenze ist eine Falle. Mein erstes Harness für den Lasttest hat viele Workflows eingereicht und dann in derselben, nicht committeten Transaktion auf sie gewartet. Der Worker kann Instanzen nicht sehen, die nicht committet sind, also hat sich das Ganze selbst blockiert. Die Regel ist einfach, sobald man sie kennt: einreichen, committen, dann warten oder pollen. Zwischen df.start() und df.wait_for_completion() muss ein Commit liegen.

Die HTTP-Allowlist deckt nicht alles ab, was HTTP-Aufrufe machen kann. Das native df.http() von pg_durable hat ein ernstzunehmendes Sicherheitskonzept: eine Domain-Allowlist zur Build-Zeit plus eine explizite EXECUTE-Berechtigung, und es hat in meinen Tests nackte IP-Adressen und nicht gelistete Domains korrekt blockiert. Aber die Allowlist regelt nur df.http(). Als ich die separate Extension postgres-http installiert und sie aus einem SQL-Node eines Workflows heraus aufgerufen habe, erreichte sie https://example.com/ ohne Weiteres, denn pg_durable führt gewöhnliches SQL mit der einreichenden Rolle aus und kann nicht jede andere HTTP-fähige Extension in der Datenbank kontrollieren. Die nativen Kontrollen sind gut, aber sie sind keine datenbankweite Outbound-Policy. Das habe ich als Klarstellung für die Doku gemeldet (Issue #214).

Metriken können Fehler zu hoch ausweisen. df.metrics() zählt fehlgeschlagene Zeilen aus der tiefer liegenden Tabelle duroxide.executions, und darin können verwaiste Zeilen stecken, die zurückgerollte Starts hinterlassen haben. Diese Zeilen haben keinen passenden Eintrag in df.instances, aggregierte Fehlerzahlen können also von dem abweichen, was df.list_instances('failed') meldet. Wenn Sie Alarme auf failed_instances schalten, prüfen Sie den Wert vorher (Issue #213).

Das Autorenmodell stößt an eine harte Grenze, nicht nur an Kleinigkeiten. Die Abhilfen und die Grenzen habe ich oben beschrieben: Dollar-Quoting und echte Funktionen nehmen den Escaping-Schmerz, aber es gibt kein Parameter-Binding und kein typisiertes SDK, und Werte werden als Text substituiert. Ein Rest, den die Abhilfen nicht beseitigen: Ein fehlerhafter Graph-Ausdruck kann weiterhin im PostgreSQL-Parser scheitern, bevor pg_durable Ihnen einen workflowspezifischen Fehler geben kann. Genau deshalb zahlen sich Tests auf SQL-Ebene aus, und die sind hier einfach. Ich habe schlichte Assertion-Helfer geschrieben, die prüfen, dass der Graphaufbau bis df.start() inert bleibt, dass identische Ausdrücke stabiles Graph-JSON erzeugen, dass gespeicherte Ergebnisse dort landen, wo sie sollen, und dass ungültiges rohes Graph-JSON mit einer klaren Meldung abgelehnt wird. Dass diese Tests selbst einfach SQL sind, ist eine echte Stärke.

#Umgang mit Secrets

Die erste Frage, die ein echter agentischer Workflow aufwirft, ist, wo der API-Key landet, und hier zeigt sich der Preview-Status. Ich habe dafür eine Probe gebaut (45_secret_handling.sql im Begleit-Repository): ein Workflow, der einen authentifizierten Endpunkt mit Authorization: Bearer {agent_key} aufruft, wobei {agent_key} per df.setvar auf einen eindeutigen Sentinel-Wert gesetzt wird, gefolgt von einem Scan aller Text- und JSON-Spalten in den pg_durable-eigenen Schemas df und duroxide nach genau diesem Wert.

Fangen wir mit dem an, was nicht da ist. Das Sicherheitsmodell entwirft einen administrativ verwalteten Speicher df.secrets, dessen aufgelöste Werte niemals Ergebnisse, Fehler oder den Graphen berühren würden. Er ist als nicht implementiert markiert, und der Build bestätigt das: df.secrets und df.setsecret existieren nicht. Der einzige dauerhafte Mechanismus innerhalb der Datenbank ist damit df.setvar, den der User Guide gleichzeitig für „credentials“ empfiehlt und mit „avoid storing secrets in plain text“ wieder einschränkt.

Die Probe zeigt, warum diese Warnung zählt. Die Substitution funktioniert: Der aufgelöste Key geht über die Leitung. Die Graph-Definition verhält sich gut, df.nodes speichert nur den Platzhalter {agent_key}, die Auflösung passiert also tatsächlich zur Ausführungszeit. Aber der aufgelöste Key landet danach an zwei dauerhaften Stellen, keine davon verschlüsselt und beide in Ihren Backups: df.vars (Klartext, wie die Doku einräumt) und duroxide.history.event_data, die Runtime-Historie des Workers, die den materialisierten Request festhält. In einem Lauf lieferte der Endpunkt ein leeres 402 zurück, das heißt, der aufgelöste Key in der Historie kann nur aus dem Request stammen, den der Worker gesendet hat, nicht aus einer zurückgespiegelten Antwort. Das Secret wird dauerhaft persistiert, als Nebeneffekt davon, wie Durable Execution ihre Schritte aufzeichnet.

Der bequeme Weg aus dem User Guide schreibt Ihren Key also in zwei Tabellen. Die Abhilfen, der Reihe nach:

Halten Sie den Key vollständig aus der Datenbank heraus. Legen Sie den Provider-Key hinter einen internen Endpunkt, den der Workflow aufruft, dann hält pg_durable ihn nie und er gelangt nie in die Runtime-Historie. Genau das empfiehlt auch die projekteigene Azure-Design-Notiz („keep API keys secure, not in database“), und dieser Endpunkt ist der natürliche Ort für den Idempotency-Key und die Retry-Policy, die die Engine nicht mitbringt. Das ist die einzige Option, die beide Lecks schließt.

Wenn der Key in der Datenbank liegen muss, machen Sie von Hand, was das ungebaute df.secrets laut Spezifikation tun soll: verschlüsselt ablegen (Supabase Vault, pgcrypto), Workflow-Rollen das SELECT darauf entziehen und ihn im Node zur Ausführungszeit über eine SECURITY DEFINER-Funktion auflösen, damit er nie in df.vars landet. Seien Sie sich der Grenze bewusst: Der aufgelöste Wert wird trotzdem in den Request materialisiert und taucht daher weiterhin in duroxide.history.event_data auf. Verschlüsselung der ruhenden Daten schützt df.vars, nicht die Historie. Kombinieren Sie das mit kurzlebigen Keys, die Sie rotieren können.

Legen Sie keinen echten Key in df.setvar ab. Für einen lokalen Test mit einem Wegwerf-Token ist das in Ordnung. Für alles, was Sie ungern in einem Backup sehen würden, nicht.

Das ist der deutlichste Fall dafür, dass das Preview-Label etwas Konkretes bedeutet. Das Feature, das Secrets sicher machen würde, ist entworfen, aber nicht gebaut. Bis es ausgeliefert ist, liegt die Secret-Hygiene vollständig bei Ihnen.

#Der Postgres-native Trade-off

Der wichtigste Punkt ist kein Bug. Er folgt direkt aus der Grundidee, und über ihn sollten Sie vor einer Einführung am längsten nachdenken.

Wenn Ihr Workflow in SQL lebt, wird Ihr Workflow zu Datenbankcode. Änderungen laufen über Migrationen, Extension-Upgrades, Grants und Rollout-Prozesse der Datenbank statt über ein gewöhnliches Anwendungs-Deployment. Wenn Ihr Team ohnehin Postgres-first entwickelt, ist das ein Vorteil: ein Deployment-Pfad, ein Auth-Modell, ein Backup. Wenn in Ihrem Team Leute sitzen, die lieber in Anwendungscode arbeiten, zieht pg_durable sie in eine Welt, in der die Orchestrierungslogik, also ausgerechnet das, was am ehesten zur Unzeit einen schnellen Fix braucht, hinter einer Migration liegt.

Nehmen Sie den gewöhnlichsten Fall, wenn Sie mit KI arbeiten: einen Prompt ändern. Wenn Sie E-Mails mit einem Modell klassifizieren, werden Sie diesen Prompt ständig anpassen, oft mehrmals am Tag. Steht der Prompt in der Workflow-Definition oder in der SQL-Funktion, die ein Node aufruft, ist jede dieser Anpassungen eine Datenbankmigration, geprüft und ausgeliefert wie eine Schemaänderung, nicht wie eine Konfigurationsänderung. Und weil laufende Workflows ihr SQL zur Ausführungszeit auflösen, kann eine Prompt-Änderung auf Läufe treffen, die bereits unterwegs sind. Der Ausweg ist, den Prompt als Daten zu behandeln: in einer Tabelle ablegen und den Node die aktuelle Zeile lesen lassen, womit eine Prompt-Änderung wieder zu einem gewöhnlichen UPDATE wird. Dann ist der Prompt allerdings nicht mehr gemeinsam mit dem Workflow versioniert, der ihn nutzt, und Sie substituieren wieder Text in einen Modellaufruf. Keine der beiden Optionen ist falsch. Sie müssen sich nur bewusst für eine entscheiden, denn der Standardweg, den Prompt inline zu schreiben, schiebt Ihren meistbearbeiteten String stillschweigend hinter Ihren Migrationsprozess.

Das ist dieselbe Spannung, die manchen Teams bei Architekturen im Supabase-Stil Unbehagen bereitet, bei denen ein wachsender Anteil des Wesentlichen in Postgres-Migrationen und Edge Functions liegt statt in einer vertrauten Anwendungs-Codebasis. pg_durable verstärkt das. Der Gewinn: Ihre Jobs sind kein Problem verteilter Systeme mehr, sondern ein Datenbankproblem. Der Preis: Sie erben die Deployment-Disziplin Ihrer Datenbank, und zusammen mit dem oben beschriebenen Verhalten bei laufenden Workflows muss diese Disziplin echt sein, nicht bloß erwünscht.

#Wo das Modell an seine Grenzen stößt

Es gibt eine härtere Grenze als die Developer Experience, und sie entscheidet, ob pg_durable in der Nähe einer bestimmten Last überhaupt etwas zu suchen hat.

pg_durable orchestriert zwei Dinge gut: SQL-Schritte und HTTP-Aufrufe. Das ist die Form des Modells. Es ist exzellent für IO-gebundene, datennahe Arbeit, und es ist das falsche Werkzeug, sobald ein Schritt etwas braucht, das Postgres nicht beherbergen kann oder sollte. Eine GPU für Inferenz oder Fine-Tuning. Einen lang laufenden Rechenjob, der ein Datenbank-Backend minutenlang belegen würde. Großen In-Memory-State. Ein SDK ohne HTTP. Alles, was CPU-lastig genug ist, dass es schon für sich genommen eine schlechte Idee ist, es innerhalb einer Datenbankverbindung auszuführen.

Sie können das umgehen, und für viele KI-Pipelines ist der Umweg der richtige Weg. Packen Sie die schwere Arbeit hinter einen HTTP-Endpunkt und rufen Sie ihn aus einem Workflow mit df.http() auf. Die Orchestrierung mit Durable Execution bleibt in Postgres, und ein separater Inferenzdienst erledigt die GPU-Arbeit. Das ist eine legitime Architektur. Aber machen Sie sich klar, was gerade passiert ist. Die schwere Rechenarbeit steckt wieder in einem Dienst, mit eigenem Deployment, eigener Skalierung und eigenem Account. pg_durable ist zum Orchestrator geworden, nicht zum Ausführenden. Das ist in Ordnung und oft richtig, aber es ist nicht mehr die Geschichte von „null Infrastruktur“, und Sie sollten sich bewusst dafür entscheiden, statt es auf halbem Weg festzustellen.

Es gibt eine zweite Einschränkung, die in einem lokalen PoC leicht untergeht. Workflows, die in Ihrer primären Datenbank laufen, konkurrieren mit Ihrer Anwendung um Verbindungen, CPU und IO. Eine Welle von Workflows ist eine Welle von Last auf demselben Postgres, auf das auch Ihre Nutzer treffen. Für einen Stack mit einer einzigen Datenbank, etwa ein Supabase-Deployment, ist dieser Blast Radius eine echte Designfrage. Die ehrliche Gegenmaßnahme ist oft ein dediziertes Postgres für Durable Execution, was wiederum Infrastruktur ist. Die Lehre ist nicht, dass pg_durable hier falsch liegt. Sie lautet, dass „alles läuft in Postgres“ gleichzeitig ein Vorteil und eine Kopplung ist, und schwere oder stoßweise Lasten machen aus der Kopplung genau das, was Sie managen müssen.

Das ist die Grenze des Modells. Für datenförmige Orchestrierung entfernt „alles in Postgres“ ein System. Für rechenlastige oder hardwaregebundene Arbeit kann es das System nicht entfernen, das die schwere Arbeit macht, und so wird aus dem Versprechen „keine Infrastruktur“ still und leise wieder Infrastruktur.

#Wo es passt

Ich würde nicht zu pg_durable als universellem Ersatz für Airflow, Temporal, Trigger.dev oder Batch greifen. Das will es auch gar nicht sein, und der Preview-Reifegrad, das fehlende automatische Retry, der fehlende Secret Store und das Autorenmodell mit Textsubstitution (kein Parameter-Binding, kein typisiertes SDK) sprechen alle dagegen, heute eine heterogene, hochvolumige, hardwaregebundene Orchestrierungsschicht darauf zu setzen.

Wirklich stark wirkt es in dem Fall, für den es gebaut wurde: Workflows, deren State ohnehin nach Postgres gehört.

  • Asynchrone Aggregation und Reporting.
  • Queues abarbeiten, wenn die Jobs Zeilen sind.
  • Datenbanklokale ETL- und Ingest-Pipelines.
  • Geplante Wartung mit Bedingungs- und Freigabeschritten.
  • Embedding- und Anreicherungs-Pipelines, die chunken, eine API aufrufen und upserten.
  • Eng umrissene externe API-Aufrufe mit expliziter Statusbehandlung.
  • Agentische Workflows, deren Schritte überwiegend SQL und HTTP sind und bei denen Workflow-State und Geschäftsdaten zusammenliegen wollen.

Dort ist der Nutzen real und ungewöhnlich. Ein Durable Scheduler ohne separaten Dienst, vollständige SQL-native Observability, Neustart-Resilienz, die Sie auf einem Laptop testen können, und die Möglichkeit, einen Workflow direkt aus einem Row-Trigger heraus zu starten. Und damit landet es genau bei der Lehre, mit der ich angefangen habe. Der eigene Scheduler ist deshalb ein Fehler, weil Durable Execution schwerer ist, als sie aussieht, und weil sie nicht Ihr Produkt ist. Genau dort ist pg_durable am wertvollsten: Für den Postgres-förmigen Teil Ihrer Workflows ist es das, was Ihnen den Eigenbau erspart. Für einen Postgres-zentrierten Stack, und besonders für alles, was in einem Supabase-Deployment laufen soll, ohne weitere Kästen hinzuzufügen, ist es der interessanteste Neuzugang, den ich in diesem Feld seit Längerem ausprobiert habe.

Das vollständige Harness, jedes Experiment, die Negativtests und die Rohbelege liegen im öffentlichen Begleit-Repository: pg-durable-evaluation. Es baut die Extension aus einer fest gepinnten Quell-Revision und führt alles in Docker aus, damit Sie die Ergebnisse reproduzieren können, statt mir glauben zu müssen.

Zum Schluss eine ehrliche Frage, denn ihre Antwort entscheidet, ob dieses Werkzeug etwas für Sie ist. Wenn das nächste Feature still und leise Hintergrundarbeit braucht, einen Modellaufruf, den Sie lieber nicht im Request-Pfad machen, eine Queue, die Sie nicht eingeplant hatten, stehen Sie wieder vor der Gabelung: selbst bauen oder eine fertige Engine einsetzen. Wenn Ihr Workflow-State ohnehin in Postgres liegt: Wo verläuft Ihre Grenze zwischen einer jobs-Tabelle mit Cron-Eintrag und einer echten Durable Engine in der Datenbank? Genau an dieser Grenze verdient sich pg_durable seinen Platz, oder eben nicht.

Dr. Oliver Borchers
Dr. Oliver Borchers
AI & Automation Advisor

Stehen Sie vor einem komplexen Problem? Vor jeder Zeile Code: ein Gespräch. Kein Pitch, keine Verpflichtung.

Gespräch buchen