Zum Inhalt springen
tecminds

Postgres-Job-Row-Durability: Ein Backstop-Sweep, der die Writes nicht verdoppelt

Eine Analyse-Pipeline, die ihre finale Zeile nur aus dem Browser schrieb, verlor neun fertige Läufe und fünfhundertfünfundsechzig geurteilte Zitate an geschlossene Tabs. Auch den Worker schreiben zu lassen, hat auf dem Common Path jeden grossen Row-Write verdoppelt. Der Fix war ein Backstop-Sweep — ein Writer auf dem Happy Path, SKIP LOCKED für die Ausnahmen — und drei subtilere Entscheidungen, die ihn über ein Coolify-Rolling-Deploy hinweg ehrlich halten.

TTobias LüscherCo‑Founder · TecMinds2026-08-03 · 9 Min Lesezeit

Postgres-Job-Row-Durability: Ein Backstop-Sweep, der die Writes nicht verdoppelt

Die teuerste Kategorie von Produktions-Bug ist die, bei der nichts kaputt aussieht. Eine Analyse-Pipeline in unserem Zitations-Verifikations-Produkt hatte monatelang zufrieden Läufe fertiggestellt, Verdikte geschrieben und Reports gerendert — bis auf die Woche, in der eine Kundin nachfragte, wo der Report geblieben sei, den sie tags zuvor geöffnet hatte. Er stand nicht im Sidepanel. Er stand nicht im Admin-View. Die job_citations-Zeile war da, Verdikte und alles; die analysis_runs-Zeile, aus der die UI liest, war es nicht. Das ist die Aufzeichnung des Postgres-Backstop-Sweep-Musters, das es behoben hat — und der beiden Folge-Commits, die landen mussten, bevor der Sweep sicher neben sich selbst laufen konnte, während ein Coolify-Rolling-Deploy alt und neu überlagert.

Bug und Fix leben beide in Acurio, unserem Zitations-Verifikations-Produkt für akademische Abschlussarbeiten (der Repo-Name ist zoterohero). Die relevanten Tabellen sind analysis_jobs — die Work Queue, eine Zeile pro hochgeladener Arbeit, mit dem extrahierten Dokument-Body und den Verdikten pro Zitat — und analysis_runs — das nutzerseitige Artefakt, aus dem die UI den fertigen Report rendert. Jeder fertige Job hat einen Lauf. So die Theorie.

Das Ein-Writer-Design, das neun Läufe verlor

Das Original-Design war klein und elegant. analysis_runs hatte in Produktion genau einen Writer: POST /api/analysis-runs, ausgelöst vom Browser-Tab, sobald sein Poll den Job auf complete umschlagen sah. Der Client formte den Payload, schickte ihn hoch, die Zeile erschien, das Sidepanel füllte sich. Ein Writer. Kein Koordinationsproblem. Simpel.

Der Failure Mode war subtil genug, dass er sich monatelang versteckt hat. Der Write passiert nur, wenn ein Tab noch zuschaut, wenn der Job fertig wird. Ein Tab, der geschlossen, neu geladen, auf einen anderen Lauf navigiert oder über die Exponential-Backoff-Deckelung des Polls hinaus in den Hintergrund geraten ist, schickt dieses POST nicht. Der Job ist trotzdem vollständig fertig — seine Verdikte sind auf Platte, sein Dokument-Body ist auf Platte — aber die Artefakt-Zeile, die die UI liest, erscheint nie. Ein Snapshot aus Produktion fand neun fertige Läufe mit fünfhundertfünfundsechzig geurteilten Zitaten in job_citations, unsichtbar im Sidepanel jenseits eines 24-Stunden-Trims auf doc_html, und unsichtbar im internen /admin/runs-Dashboard jenseits seines 60-Minuten-In-Flight-Fensters. Aus Sicht der Nutzerin hatte der Report seinerzeit sauber gerendert — und dann still aufgehört zu existieren.

Der offensichtliche Fix ist, einen zweiten Writer hinzuzufügen. Der Worker hat den Job gerade finalisiert; er hat den geformten Payload bereits im Speicher; er kann die Zeile selbst schreiben. Das haben wir ausgeliefert. Die neun Geisterläufe hörten auf. Was stattdessen anfing zu passieren, war ein anderes Problem, eines, das erst sichtbar wird, wenn man ohnehin schon in Produktions-Grösse ist: jeder gewöhnliche Lauf schrieb diese Zeile jetzt zweimal, der Write des Workers und das Save-POST des Clients Millisekunden später, in einer Tabelle, deren Zeile doc_html trägt — den extrahierten Body einer Arbeit, mehrere Megabyte für eine lange Dissertation. Postgres lagert übergrosse Spalten in TOAST aus, also fächert jeder dieser Writes in TOAST-Chunk-Inserts auf, der Primary-Key-Index bekommt ein frisches Tupel, und das vorherige Tupel wird zu Müll, den Autovacuum später wegräumen muss. Alles, um einen Fall abzudecken, der nur passiert, wenn niemand hinschaut.

Das Backstop-Sweep-Muster

Das Muster, das es aufgelöst hat, verwenden wir in derselben Datei bereits für Claim-Protokolle, und es generalisiert sauber. Anstatt eifrig zu schreiben und die Kosten auf dem Common Path zu bezahlen, verzögere den Write und lass einen periodischen Sweep nur die Zeilen aufheben, die gerettet werden müssen.

Konkret: der Worker schreibt die Zeile bei Finalize nicht mehr. Er schreibt die Verdikte in job_citations, markiert den Job als terminal und hört auf. Eine neue sweepUnpersistedRuns-Funktion läuft auf dem bestehenden 30-Sekunden-Resume-Sweeper-Tick und sucht terminale Jobs, die noch ihr Dokument und ihre Verdikte halten, deren analysis_runs-Zeile fehlt und die seit zwei Minuten niemand angefasst hat. Die zwei Minuten Karenz sind die tragende Konstante: ein Browser-Tab, der da ist, schreibt die Zeile innerhalb eines Ticks oder zwei nach dem Terminal-Übergang, das Karenzfenster hält den dominanten Pfad — Tab offen, Lauf beendet — also bei genau einem Write. Was danach immer noch fehlt, hatte keinen Browser hinter sich, und der Sweep zeichnet es auf. Der Judge und die Ops-Diagnose-Hooks sind mit umgezogen, weil sie am Finalize-Write hingen und für diese Läufe sonst nie feuern würden.

SELECT j.id, j.run_id
  FROM analysis_jobs j
  LEFT JOIN analysis_runs r ON r.id = j.run_id
 WHERE j.status IN ('complete', 'failed')
   AND j.deleted_at IS NULL
   AND j.doc_html IS NOT NULL
   AND j.results IS NOT NULL
   AND j.results <> '{}'::jsonb
   AND j.updated_at < NOW() - ($1::int * INTERVAL '1 second')
   AND r.id IS NULL
 ORDER BY j.updated_at ASC
 LIMIT $2
 FOR UPDATE OF j SKIP LOCKED

Zwei Schutzmassnahmen, die sich als wichtig herausgestellt haben. Der Sweep ist vom Tick entkoppelt, denn einen aufgezeichneten Lauf zu beurteilen ist ein LLM-Call, der die 30 Sekunden, die ihn gestartet haben, überleben kann, und die Lease-Recovery, die auf demselben Tick läuft, darf nicht hinter einem Netzwerk-Round-Trip Schlange stehen. Der Sweep trägt zudem einen In-Flight-Bool, damit er sich nie selbst stapelt — feuert der nächste Tick, während ein langsamer LLM-Call noch zurückkommt, überspringt der zweite Sweep und der erste räumt fertig. Die Arbeit ist idempotent, weil das Einfügen der Zeile auf dem Primary Key upsertet, es geht also nichts verloren, wenn übersprungen wird.

Die verallgemeinerbare Lehre ist eine, die wir angedeutet haben, als wir den Analyzer aus Next.js in einen Bun-Worker herausgelöst haben: ein durables Background-System hat fast immer zwei Pfade, einen schnellen und erwarteten und einen langsamen und unwahrscheinlichen. Beide auf denselben Code-Pfad zu legen, kostet den Durchsatz des schnellen Pfads. Sie zu trennen erlaubt es, jeden auf die Form seines eigenen Traffics zu optimieren. Das Muster „Client schreibt, dann sweept" ist die Row-Durability-Variante desselben Trade-Offs.

SKIP LOCKED, Ältester zuerst, drain-bewusst

Den Sweep in Produktion auszuliefern, hat das zweite Problem innerhalb eines Tages ans Licht gebracht. Das Rolling-Redeploy von Coolify lässt den alten und den neuen Container kurz nebeneinander laufen, während der Health-Check des neuen sich stabilisiert — normal, unsichtbar, gewollt. Beide Container booten den Resume-Sweeper. Beide Container scannen alle 30 Sekunden nach fehlenden Zeilen. Beide Container greifen dieselbe fehlende Zeile. Beide upserten. Der Sweep, dessen ganzer Existenzgrund es ist, den doppelten Write zu entfernen, hat während jedes Deploys still einen wiederhergestellt.

Der Fix ist dasselbe Idiom, das jeder andere Sweep in der Datei schon nutzt, und eines, das wir bereits im Kontext durabler Postgres-Queues beschrieben haben: FOR UPDATE OF j SKIP LOCKED. Die offizielle Postgres-Semantik ist genau das, was man hier will — eine Zeile, die eine andere Transaktion gesperrt hat, wird still übersprungen statt blockiert, sodass zwei nebenläufige Sweeper disjunkte Zeilen greifen, ohne verteilten Lock und ohne voneinander zu wissen. Es ist eine Zeile SQL, und es ist der Unterschied zwischen „ein Sweep, der funktioniert" und „ein Sweep, der den Deploy überlebt, der ihn nötig gemacht hat."

Zwei kleinere Änderungen sind mitgegangen. Die Scan-Sortierung ist von Neuester-zuerst auf Ältester-zuerst gekippt, denn die Konstante, die darüber entscheidet, ob eine Zeile überhaupt noch rettbar ist, ist der 24-Stunden-Trim auf doc_html, und der Job, der am längsten gewartet hat, ist der, der dem Verlust des Bodys am nächsten steht, den der Sweep schreiben muss. Ein Backlog ist genau die Situation, in der die Reihenfolge zählt, und ein neuerer Job bekommt seine Runde auf dem nächsten Tick sowieso. Der Sweep hat sich zudem als In-Flight-Arbeit registriert, sodass SIGTERM — Coolifys Shutdown-Signal — ihn drainiert, statt zwischen dem Aufzeichnen einer Zeile und dem Feuern des Judge dagegen zu schneiden. Genau diese Race würde eine Lauf-Zeile hinterlassen, die existiert, aber nie geurteilt wurde, und kein späterer Sweep schaut nochmal hin, weil der Zeile-fehlt-Filter nicht mehr passt.

Die Komposition hier reimt sich mit den Debounced-Autosave-Races, die wir vor zwei Wochen aufgeschrieben haben, wo ein flush()-Primitiv und ein In-Flight-Save zusammenspielen mussten, ohne sich zu jagen. Die Regel ist an beiden Enden des Stacks dieselbe: jeder Code-Pfad, der einen Write auf eine Ressource auslösen könnte, muss wissen, ob ein anderer Writer diese Ressource in Flight hat. Im Browser trackst du ein Promise in einem Ref. In der Datenbank nimmst du einen Row-Lock. Der Mechanismus ist ein anderer; die Disziplin ist identisch.

Drei Regeln für eine Zeile mit zwei Writern

Drei Regeln überleben diesen Rewrite und generalisieren auf jede Tabelle mit einem Happy-Path-Writer und einem Durability-Path-Writer:

Bestimme, wer gewinnt. In dem Moment, in dem eine Zeile zwei mögliche Writer hat, ergänze eine nullable Spalte, die aufzeichnet, welcher geschrieben hat — bei uns saved_by, client oder worker — und kodiere eine Vorrangregel an jedem Write-Site. Ein Client-Save schlägt immer einen Worker-Save, ein späterer Job darf eine frühere Worker-Zeile ersetzen, eine Legacy-NULL-Zeile zählt als client. Den Tie-Breaker auf Schema-Ebene explizit zu machen kostet eine Migration und verhindert ein Jahrzehnt „welche ist jetzt eigentlich autoritativ"-Diskussionen im Review.

Gib dem schnellen Pfad ein Karenzfenster. Ein Backstop, der sofort feuert, ist kein Backstop — er ist ein zweiter Writer, der gegen den ersten antritt. Wähle eine Karenz, die lang genug ist, dass der Happy Path immer darin abschliesst (zwei Minuten waren ~50× die beobachtete Client-Save-Latenz bei uns) und kurz genug, dass ein browserloser Job seinen doc_html nicht vor der Rettung an den Trim verliert.

SKIP LOCKED überall, wo zwei Container racen könnten, und drainiere den Sweep mit ihnen. Ein Rolling-Deploy ist die billigste Art, deine Annahmen über Nebenläufigkeit zu prüfen. Wenn dein Sweep sich falsch verhält, wenn zwei von ihm 30 Sekunden lang parallel laufen, wird er sich in jedem Deploy falsch verhalten. FOR UPDATE ... SKIP LOCKED ist eine Zeile; die Alternative ist ein Distributed-Lock-Service, den du nicht betreiben wolltest. Und wenn der Sweep Folgearbeit auf dem verrichtet, was er aufgezeichnet hat, markiere ihn als In-Flight, damit SIGTERM auf ihn wartet — sonst ist der Zustand „aufgezeichnet, aber nicht geurteilt" ein Bug, den kein späterer Sweep mehr anschaut.

Die Buchführung über fertige Läufe ist wieder still. Aus neun Geisterläufen wurden null, und die Write-Kosten des Common Path sind von zwei grossen Tupeln auf eines gesunken. Wenn du eine Background-Pipeline auf einem Ein-Container-Deploy in Coolify oder Fly betreibst und dich zu fragen beginnst, ob der In-Process-Writer reicht, ist die Antwort mit ziemlicher Sicherheit plus ein Backstop-Sweep — nicht ersetze ihn durch ein Zwei-Writer-Schema.

Wenn du eine durable Background-Job-Pipeline in Postgres verkabelst — oder bereits eine hast, die auf Wegen, die niemand ganz festnageln kann, Zeilen verliert — buche einen kostenlosen AI Potenzial-Check, oder lies unser Next.js-Worker-Split mit pg_notify für die Prozess-Split-Hälfte desselben „Durability-unter-Redeploy"-Themas.

acurio · Halluzinierte Zitate? Nicht in deinem Manuskript.

Citation‑Checker für Zotero. Findet halluzinierte oder nur teilweise gestützte Quellen in KI‑geschriebenen Texten. Thesis‑Pakete ab CHF 19, Schweizer Datenverarbeitung.

NÄCHSTER SCHRITTHat dich das interessiert?