Next.js Worker Split mit pg_notify: Wie ein Analyzer Redeploys überlebt
Unsere Next.js-App hielt in-Flight-Analyse-Jobs in einer Prozess-Memory-Map — jeder Redeploy tötete laufende Runs mit einem sources_unavailable 409. Die Pipeline in einen Bun-Worker über pg_notify auszulagern — mit Advisory Locks um die Boot-DDL und einem Postgres-basierten Source-Cache — beendete diese Bug-Klasse, ohne eine zweite Replica einzuführen.
Next.js Worker Split mit pg_notify: Wie ein Analyzer Redeploys überlebt
Ein „gesunder" Redeploy, der still und leise jeden laufenden User-Job tötet, ist eine der peinlicheren Formen von Produktionsbugs — denn alles sieht in Ordnung aus, bis ein Kunde per E-Mail nachfragt, wo sein Report geblieben ist. In unserem Fall lautete die Antwort: zurück auf dem vorherigen Docker-Image, zusammen mit der Prozess-Memory-Map, in der die hochgeladenen PDF-Texte lagen. In dem Moment, in dem CI das Image tauschte, war die Map im neuen Container leer; der Analyse-Worker im gleichen Node-Prozess schrieb höflich sources_unavailable in die Run-Zeile und gab auf. Nichts crashte, kein Alarm feuerte, und der Kunde musste neu hochladen.
Der Fix, der diese Woche landete, liest sich wie langweilige Architektur — die Analyse-Pipeline aus Next.js in einen eigenständigen Bun-Worker herausziehen, beide Seiten über pg_notify verdrahten, den Boot mit einem Postgres Advisory Lock absichern und aufhören, In-Process-Memory so zu behandeln, als wäre es durabler State. Interessant daran war, dass wir das ohne eine zweite Replica gemacht haben. Wir wollten einen Web-Container und einen Worker-Container, beide gegen ein Postgres, und wir wollten, dass das Ganze einen Redeploy einer der beiden Seiten übersteht, ohne dass ein User etwas merkt. Dieser Post ist die Aufarbeitung dessen, was wir dabei über das Next.js Worker Split mit pg_notify-Pattern gelernt haben.
Der Bug und der Fix liegen beide in Acurio, unserem Zitations-Verifikations-Produkt für akademische Arbeiten (Repo-Name: zoterohero). Die Pipeline liest hochgeladene Thesis- und Source-PDFs ein, extrahiert Claims, ruft die relevanten Chunks über pgvector plus BM25 ab und lässt ein LLM jedes Zitat bewerten. Bisher lief das alles innerhalb des Next.js-Server-Prozesses. „Alles" bedeutet in einem Single-Instance-Deploy auf Coolify: im selben Node-Event-Loop wie die HTTP-Handler. Das Design funktionierte. Es zog aber auch eine Linie mitten durch jeden long-running Request, sobald wir einen Hotfix ausrollen wollten.
Warum „ein Container" nicht mehr „kein State-Problem" heisst
Unser eigenes CLAUDE.md warnte schon monatelang davor, und wir hatten es ignoriert. Drei Subsysteme hielten State in Prozess-Memory — die hochgeladenen Source-Texte in lib/source-store.ts (eine globale Map plus ein minisearch-BM25-Index), das Rate-Limit-Sliding-Window und ein kleiner Subscription-Seat-Cache — mit einem Kommentar der sinngemäss lautete: „Das bricht in dem Moment still und leise, wenn eine zweite Replica dazukommt." Was der Kommentar nicht erwähnte und was uns trotzdem erwischte, ist: man braucht keine zweite Replica, um in den gleichen Bug zu laufen — ein Redeploy hat denselben Effekt. Ein Rolling Restart in Docker ersetzt den laufenden Container; der neue Container startet mit einer leeren Map. Aus Sicht eines Zitations-Verifikations-Jobs, der mitten in vierundachtzig Claims steckt, ist „leere Map" funktional identisch mit „die andere Replica hat diese Session noch nie gesehen".
Zusätzlich liessen wir die Analyse-Worker innerhalb des Next.js-Server-Prozesses laufen. instrumentation.ts bootet beim Server-Start, spielt SQL-Migrationen aus lib/migrations/ ein, registriert einen SIGTERM-Drain, versöhnt verwaiste Jobs und startet einen 30-Sekunden-Resume-Sweeper plus einen 60-Sekunden-Batch-Poller. Auf dem Papier ist das elegant: ein Binary, ein Deploy-Ziel, kein zweites Ding, das kaputtgehen kann. In der Praxis heisst es: Ein HTTP-Request, der auf einem beschäftigten Worker landet, kämpft um denselben Event-Loop; next build-Restarts terminieren die Sweeper mitten im Tick; und jeder State, den die Worker in Memory legen, hängt an derselben TTL wie der HTTP-Prozess — nämlich „bis zum nächsten Deploy", und wir deployen mehrmals pro Woche.
Der Failure-Mode, um den es uns ging, war klein und spezifisch: der sources_unavailable 409, den ein Run warf, wenn er seine hochgeladenen Source-Texte suchte und die Map leer vorfand. Als Fehlerklasse ist das im Abstrakten nicht spannend; ein User kann jederzeit neu hochladen. Aber für ein Produkt, dessen ganzes Wertversprechen „gib uns deine Thesis, bekomm eine Stunde später einen Report zurück" lautet, ist „bitte lade nochmal achtzig Megabyte PDFs hoch, weil wir redeployed haben" keine Nachricht, die man verschicken will. Genau dieser eine Fehler — und die Bug-Klasse, für die er steht — hat den Split angetrieben.
pg_notify und Advisory Locks: die langweilige Verkabelung
Die Next.js Worker Split mit pg_notify-Architektur, die gelandet ist, ist bewusst langweilig. Der Web-Prozess gated alle Pipeline-Kickoffs auf eine ANALYSIS_WORKER_MODE=remote Env-Variable; wenn gesetzt, werden Kickoff-Calls zu einem einzelnen pg_notify('acurio_task_kick', payload) und kehren zurück. Ein eigenständiger Bun-Einstiegspunkt (worker.ts) öffnet einen dauerhaften LISTEN-Client mit Reconnect und Keepalive, holt beim Boot verwaiste Rows zurück und übernimmt Sweeper und Batch-Poller, die vorher im Next.js-instrumentation-Hook lebten. Beide Prozesse lesen und schreiben gegen dasselbe Postgres. Keiner hält irgendetwas Durables im Memory.
Zwei Details aus dem eigentlichen Patch verdienen Erwähnung.
Das erste ist ein pg_advisory_lock um die Boot-DDL herum. Beide Container spielen beim Start Migrationen ein; wenn sie sich in die Quere kommen — ein frischer Deploy, bei dem der Web-Container zehn Sekunden vor dem Worker-Container hochkommt, oder umgekehrt —, versuchen zwei Connections gleichzeitig CREATE TABLE IF NOT EXISTS und CREATE INDEX CONCURRENTLY auf demselben Schema. Postgres lässt das bereitwillig zu, und der Verlierer scheitert mit einem Duplicate-Relation-Fehler, der in den Logs wie eine korrupte Installation aussieht. Der Advisory Lock (SELECT pg_advisory_lock(<hash>) am Anfang der Boot-Funktion, pg_advisory_unlock im finally) sorgt dafür, dass der Container, der zuerst am Boot ankommt, die Migrationen zu Ende führt, und der zweite auf demselben Connection-Level-Lock wartet und dann auf IF NOT EXISTS no-oppt. Zwei Zeilen SQL. Kein Koordinationsdienst.
Das zweite ist das Claim-Protokoll auf den Queue-Zeilen selbst. Jede long-lived Tabelle — job_citations, parse_jobs, parse_items — hat dieselbe Form: eine State-Spalte, ein claimed_at, ein lease_expires_at und ein worker_id. Ein Claim ist ein einzelnes SELECT ... FOR UPDATE SKIP LOCKED LIMIT 1 innerhalb einer Transaktion, die gleichzeitig state = 'running' und lease_expires_at = now() + interval '2 minutes' schreibt. Ein Hintergrund-Heartbeat alle 30 Sekunden verlängert das Lease; ein Stall-Sweeper alle paar Minuten gibt jede Zeile frei, deren Lease abgelaufen ist. FOR UPDATE SKIP LOCKED ist der Mechanismus, der das sicher macht: konkurrierende Worker claimen disjunkte Zeilen ohne Distributed Lock, ohne Broker und ohne wissen zu müssen, wie viele Worker existieren. Dasselbe Pattern taucht im Postgres-Skip-Locked-Queue-Pattern auf, das seit einem Jahrzehnt gut dokumentiert ist — wir haben nichts erfunden, wir haben es endlich benutzt.
Der pg_notify-Bus ist der schnelle Pfad; die Sweeper sind der langsame-aber-korrekte Pfad. Ein verlorener NOTIFY (der Client hörte gerade nicht zu, oder der Notify kam während eines Reconnects) verliert keine Arbeit — der Poller nimmt die Zeile beim nächsten Tick auf, weil sie noch queued ist. Genau diese Entkopplung ist der Unterschied zwischen „durable Queue auf Postgres-Basis" und „hoffnungsvoller Message-Bus"; viele pg_notify-Writeups überspringen den Sweeper und debuggen dann ewig stille Stalls.
Der Source-Store war die eigentliche Geisel
Den Prozess aufzuteilen war die einfachere Hälfte. Die schwierigere Hälfte war die Source-Map, die das Ganze angestossen hatte.
Der Move bestand darin, die Map von „Source of Truth" zu „L1-Cache" degradieren und eine session_sources-Linkage-Tabelle in Postgres hinzufügen, die das Mapping Session → hochgeladenes Dokument beim Upload-Zeitpunkt spiegelt. Der Write-Path ist Best-Effort — parse-sources und resolve-sources upserten die Linkage, aber der Request blockiert nie darauf; der Read-Path in analysis-jobs und resolve-sources fällt auf Postgres zurück, wenn die Map missed, filtert nach user_id (strenger als der In-Process-Owner-Check) und baut den BM25-Index aus den persistierten Chunks neu auf. Ein Unlink passiert nur an den zwei expliziten User-Reset-Stellen, nicht beim Post-Job-Memory-Cleanup — genau diese Entkopplung macht den Cache wegwerfbar.
Das Ergebnis: Ein Redeploy mitten im Flow oder der 24-Stunden-TTL-Sweep, der feuert, während ein User mitten in einem Batch steckt, produziert keinen sources_unavailable mehr. Der nächste Request baut die Sources aus Postgres neu auf, und der Run läuft dort weiter, wo er stehengeblieben ist. Die Änderung ist für den User unsichtbar — genau das wollten wir.
Die Parse-Pipeline selbst hat dieselbe Behandlung bekommen. /api/parse-sources läuft pdfjs-Extraktion und Embedding nicht mehr auf dem Web-Event-Loop; im Remote-Worker-Mode (gated auf einem parse_jobs-Capability-Heartbeat, damit wir nicht an einen Worker dispatchen, der noch nicht deployed ist) staged die Route die rohen Upload-Bytes als BYTEA in parse_jobs / parse_items, notifiziert den Worker und liefert 202 {jobId}. Der Client polled im 1,5-Sekunden-Takt bis zum Terminal-State. file_bytes wird bei jedem Terminal-Übergang genullt — Success, Failure, Stall-Sweep —, sodass die Sechs-Stunden-Blob-TTL ein Safety-Net und nicht das primäre Cleanup ist. Admission Caps (drei aktive Jobs pro User, 120 MB gestagete Bytes pro User) verhindern, dass ein Runaway-Upload die Queue aushungert.
Es gibt eine Version dieses Writeups, die sich in grossen Worten verliert — „Event-driven Microservices", „Reactive Backend" — und die eigentliche Form der Arbeit verfehlt. Die Form ist: Hör auf so zu tun, als wäre In-Process-Memory durable, leg Queues in die Datenbank, die du sowieso hast, und lass einen kleinen eigenständigen Prozess das LLM-und-CPU-Heavy-Lifting übernehmen. Alles andere folgt daraus. Wer Long-Running Background-Arbeit in einem Next.js-Container auf Coolify, Fly oder Railway laufen lässt, kann dieselben drei Moves anwenden. Die NODE_ENV-gegen-Hostname-Falle, die wir in unserem PostHog-Host-Gate-Post im Juni beschrieben haben, reimt sich auf diese: Das Design, das für einen einzelnen Laptop korrekt war, ist nicht das Design, das einen Container überlebt, der zweimal am Tag redeployed wird. Unsere frühere Notiz zu Acurios Zitations-Verifikations-Workflow für Zotero-Researcher beschreibt, was diese Runs eigentlich tun, und das Production-Failure-Playbook für AI-Agenten deckt das breitere Thema „stille Failures sind die schlimmste Sorte" ab.
Das Take-away, das auf einen Post-it passt: Wenn du es in einem Redeploy verlieren kannst, ist es kein State — es ist Cache. Bring den State ins Postgres, verkabele den Notify-Bus und lass die Sweeper aus „hoffnungsvoll" ein „durable" machen. Der sources_unavailable 409 ist weg, der Redeploy ist wieder langweilig, und der Kunde hat es nicht gemerkt. Das ist der Standard, auf den wir kalibrieren.
Wer ein Next.js-Produkt gerade in einen Single-Instance-Docker-Deploy schiebt und denselben Druck spürt — LLM-Arbeit, die um den Event-Loop kämpft, Redeploys, die In-Flight-Arbeit fressen, eine In-Process-Map, die alle kennen und niemand anfassen will —, für den ist der Split, den wir gerade gemacht haben, ein ausgetretener Pfad mit billigen Teilen. Buche einen kostenlosen AI-Potenzial-Check, wenn du eine zweite Meinung zur Topologie willst, bevor du dich festlegst.
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.