Zum Inhalt springen
tecminds

Pro-Nutzer-Analyse-Queue: Uploads mit Postgres Advisory Locks und gehaltenen Runs serialisieren

Eine Nutzerin, die fünf Abschlussarbeiten hintereinander vorbereitet, hat aus einem Klick fünf gleichzeitige LLM-Analysen gemacht. Der Fix war eine Pro-Nutzer-Queue — Postgres Advisory Locks für die Admit-Entscheidung, ein 'gehaltener' Upstream-Run, damit jeder Fehler weiterhin synchron landet, durabler State, damit ein Redeploy die Queue nicht wegwirft, und drei subtilere Regeln, die die Queue davon abhalten, mit sich selbst zu rennen.

TTobias LüscherCo‑Founder · TecMinds2026-10-05 · 9 Min Lesezeit

Pro-Nutzer-Analyse-Queue: Uploads mit Postgres Advisory Locks und gehaltenen Runs serialisieren

Eine einzelne begeisterte Nutzerin ist das schwierigste Load-Muster, das es zu designen gilt. Hundert Nutzerinnen, die jeweils eine Datei hochladen, sind eine glatte Kurve; eine Nutzerin, die an einem Nachmittag fünf Dateien hintereinander hochlädt, sind fünf gleichzeitige Spitzen ohne jedes Interleaving. Das klassische Rate Limit fängt den Hundert-Nutzer-Fall sauber und den Back-to-Back-selbe-Nutzerin-Fall ungeschickt — ein 429 Too Many Requests sagt der Nutzerin, sie solle es später noch einmal versuchen, was genau die falsche Antwort ist, wenn die Nutzerin gerade aktiv weitere Arbeit anstellt. Das ist die Aufzeichnung der Pro-Nutzer-Queue, die wir letzte Woche ausgeliefert haben, um das zu beheben, wie wir die Admit-Entscheidung atomar bekommen haben, ohne einen eigenen Queue-Broker, und der drei subtileren Regeln, die die Queue daran hindern, über ein Coolify-Rolling-Deploy hinweg mit sich selbst zu rennen.

Das Feature lebt in Acurio, unserem Zitations-Verifikations-Produkt für akademische Abschlussarbeiten. Der Workflow, den es adressiert, ist spezifisch und wiedererkennbar: Eine Dozentin oder eine Betreuungsperson setzt sich mit einem Stapel von fünf Studierenden-Arbeiten hin, lädt das erste PDF hoch, hängt seine Quellen an, klickt Analyse starten, lädt das zweite hoch, hängt dessen Quellen an, klickt wieder Analyse starten, und so den Stapel hinunter. Vor der Queue haben diese fünf Klicks fünf Slots unseres Judging-Budgets gleichzeitig verbraucht, den Extraktor festgenagelt und oft die Nebenläufigkeitsobergrenze unseres Providers getroffen, die als vages „temporarily unavailable" bei der Nutzerin landet, eine halbe Stunde in ihren Batch hinein. Nach der Queue nehmen die fünf Analysen alle fünf Klicks weiterhin an — jede Validierung, jede Credit-Buchung, jedes synchrone 4xx — und laufen dann in Reihe. Die Nutzerin sieht einen Lauf und vier in der Warteschlange, das Dashboard zeigt den Batch, und die zweite Analyse startet in dem Moment, in dem die erste landet.

Die Form des Problems: Früh zulassen, später ausführen

Die Versuchung, wenn eine Nutzerin zu viele Dinge einreicht, ist, die zusätzlichen abzuweisen. Das ist die richtige Antwort, wenn man sie sich nicht leisten kann, sie anzunehmen, und die Nutzerin woanders hin kann — ein Form-POST, bei dem ein 429 „bitte komm wieder" bedeutet, ist ehrlich. Es ist die falsche Antwort, wenn die Nutzerin ernsthaft gewollte Arbeit auf einem UI aufreiht, das verspricht, all das zu tracken. Wenn Klick fünf einen Toast produziert, dass die ersten vier „noch verarbeitet werden", muss die Nutzerin ihre eigene Queue babysitten, sich merken, welche Arbeiten sie schon geklickt hat, und die Seite neu laden, um herauszufinden, wann ein Slot frei wird. Das ist eine Form, auf die wir in Enterprise-AI-Agents: Production-Failure-Modi hingewiesen haben — eine Backpressure-Geschichte, die die Disziplin auf die Nutzerin schiebt, ist eine Backpressure-Geschichte, die scheitert.

Die Queue hat deshalb zwei Jobs, die die meisten Queue-Implementierungen in einen kollabieren. Der erste Job ist, die Anfrage zuzulassen: den Upload anzunehmen, jede synchrone Prüfung durchlaufen zu lassen, die der Start-Pfad werfen kann (Credit ausreichend, Quellen angehängt, Run-ID frisch reserviert), die Credit-Buchung auszuführen und der Nutzerin mitzuteilen, dass ihr Klick angekommen ist. Der zweite Job ist, die Analyse auszuführen: die Quellen zu ziehen, den Extraktor laufen zu lassen, den Zitats-Richter zu füttern, die Verdikte zu schreiben. Der Grund, warum die Queue sie trennen muss, ist, dass jeder Failure Mode, über den man die Nutzerin informieren möchte, zum ersten Job gehört. Wenn die Nutzerin einen insufficient credits-Fehler bekommen wird, will sie ihn beim Klick, nicht vierzig Minuten später, wenn vier andere Analysen fertig sind.

Admit beim INSERT, Entscheidung unter einem Advisory Lock

Der Admit-Schritt läuft in einem Postgres-Advisory-Lock, der auf der User-ID schlüsselt. Wir benutzen hier kein SELECT ... FOR UPDATE, weil es noch keine Zeile zu locken gibt — der ganze Punkt des Admit-Schritts ist zu entscheiden, ob die Zeile, die wir gleich einfügen, als pending (Slot sofort nehmen) oder als queued (auf den Slot warten) geboren wird. Advisory Locks sind das richtige Primitiv für „über Sessions hinweg auf einem Key koordinieren, der nicht zu einer Zeile korrespondiert": die Postgres-Dokumentation zu Advisory Locks beschreibt sie als „locks that have application-defined meanings": Postgres erzwingt sie nicht, die Anwendung wählt den Key und entscheidet, was er schützt — genau das, was wir brauchen.

export async function withUserQueueLock<T>(
  userId: string,
  fn: (db: Queryable) => Promise<T>,
): Promise<T> {
  const client = await getPool().connect()
  try {
    await client.query("BEGIN")
    await client.query(
      "SELECT pg_advisory_xact_lock(hashtextextended('analysis_queue:' || $1, 0))",
      [userId],
    )
    const result = await fn(client)
    await client.query("COMMIT")
    return result
  } catch (err) {
    await client.query("ROLLBACK").catch(() => {})
    throw err
  } finally {
    client.release()
  }
}

Der Lock ist xact-scoped: Er wird automatisch freigegeben, wenn die Transaktion committed oder zurückgerollt wird, also gibt es keinen „wir haben einen User-Lock geleakt"-Failure-Mode, wenn ein Request mitten im Admit erroret. Der Namespace analysis_queue: wird mit hashtextextended eingehasht, damit unsere anderen Advisory-Keys (es gibt inzwischen mehrere im Codebase) sich den 64-Bit-Raum teilen können, ohne zu kollidieren. Die Funktion, die der Admit-Pfad in diesem Lock ausführt, macht ein SQL-INSERT, dessen status-Spalte ein CASE-Ausdruck ist:

INSERT INTO analysis_jobs (user_id, run_id, status, ...)
VALUES ($user, $run,
  CASE WHEN $held::boolean AND EXISTS (
      SELECT 1 FROM analysis_jobs b
       WHERE b.user_id = $user
         AND b.run_id <> $run
         AND b.delta_of_job_id IS NULL
         AND b.deleted_at IS NULL
         AND b.status IN ('queued', 'pending', 'running')
    ) THEN 'queued' ELSE 'pending' END,
  ...)

Zwei Tabs, die den Admit-Schritt gegeneinander laufen lassen, erwerben den Lock in Reihenfolge, also sieht das spätere INSERT die frühere Zeile und wählt queued. Kein In-Memory-Zähler, kein Redis-INCR, kein Race-Fenster zwischen „entscheide" und „committe". Die Entscheidung und die Zeile landen atomar.

Der „gehaltene" Upstream-Run: Fehler synchron halten

Der Lock bekommt das Race richtig. Er lässt uns, allein genommen, nicht jeden Fehler beim ersten Klick halten. Der Admit-Schritt ruft einen Rust-Dienst auf (acurio-core), um einen Run zu erstellen — das Upstream-Artefakt, das die Verdikte tragen wird — und das Create kann scheitern: eine ungültige DOI-Angabe, ein Arbeitskörper, den der Extraktor nicht parsen konnte, ein Provider-Budget, das wir für den Tag bereits verbrannt haben. Wenn der Admit-Schritt den Run normal erstellt und den Job dann einreiht, kommen diese Fehler vierzig Minuten zu spät. Wenn er das Erstellen des Runs überspringt, bis die Queue den Job erreicht, sitzt die Nutzerin im Dashboard, schaut vier Jobs klären — nur um beim fünften zu erfahren, dass der fünfte Upload nie wirklich valide war.

Der Fix ist eine zweistufige Upstream-API: create with hold, release later. Ein gehaltener Run existiert in der State Machine des Core-Dienstes, weigert sich aber zu urteilen. Er validiert zur Create-Zeit trotzdem jede synchrone Vorbedingung — DOI-Form, Quellliste, Credit, Provider-Obergrenze — damit jeder Fehler, der beim Create gefeuert hätte, immer noch beim Create feuert. Wenn die Queue den Job erreicht, ruft processJob releaseHeldCoreRun, das den Run aus dem gehaltenen Zustand holt, Quellen anhängt, und das Judging startet. Die Nutzerin hat die Credit-Buchung bereits beim Admit bezahlt; der Release ist aus ihrer Sicht ein No-Op.

Das ist dieselbe zweistufige Form, auf die wir uns in Stripe-Webhook-Idempotenz gestützt haben: Jedes Mal, wenn eine Aktion zwei Dienste mit ihren eigenen State Machines umspannt, muss der frühere Dienst etwas committen, mit dem der spätere Dienst sich erholen kann, auch nach einem Redeploy. Ein gehaltener Run ist dieses Commit: eine durable Quittung, dass der Admit-Schritt erfolgreich war, mit jeder synchronen Prüfung bereits dagegen ausgewertet.

Durabler State: Die Queue lebt in Postgres, nicht im Speicher

Die Queue ist ein WHERE status = 'queued' auf analysis_jobs. Es gibt keine separate queue-Tabelle, keine Redis-LIST, keinen In-Memory-Promotion-Ring. Ein 30-Sekunden-Resume-Sweeper und der Tail-Call von processJob rufen beide promoteNextQueuedJob(userId), das — unter demselben Advisory Lock wie das Admit — die älteste queued-Zeile der Nutzerin aufgreift, verifiziert, dass keiner ihrer anderen Erst-Pass-Jobs bereits pending oder running ist, und den Status auf pending UPDATEt. Zwei Promoter können nicht zwei Runs nebeneinander starten, weil sie sich den User-Lock teilen, und ein Coolify-Rolling-Deploy, der alt und neu überlappt, kann es auch nicht, weil der Lock auf Postgres-Ebene sitzt, nicht auf Node-Ebene. Das ist dieselbe Durability-Geschichte, die wir in Postgres-Job-Row-Durability: ein Backstop-Sweep erzählt haben — die Invarianten leben in der Datenbank, nicht im Prozess.

Drei subtilere Regeln fielen aus dem Betrieb der Queue in der ersten Woche in Produktion heraus:

Delta-Re-Checks überspringen die Queue. Das Feature erlaubt einer Nutzerin, nur die Zitate neu laufen zu lassen, die sich seit der letzten Analyse geändert haben. Diese Re-Checks sind billig, aus Nutzersicht synchron, und sollten sich nicht hinter die fünf frischen Arbeiten der Dozentin stellen. Die Queue blockt nur auf Erst-Pass-Jobs, in SQL gefiltert über delta_of_job_id IS NULL. Der Sweeper betrachtet Deltas nie; processJob queued sie nie.

Der Release des gehaltenen Runs hat sein eigenes Retry-Fenster. Core könnte für die dreissig Sekunden zwischen Queue-Promotion und Release nicht erreichbar sein. Der Resume-Sweeper gibt dem Release ein 30-Minuten-Retry-Fenster, bevor er aufgibt und den Credit zurückerstattet, damit ein Provider-Hickser der Nutzerin keine Arbeit kostet. Länger und die Nutzerin ist bereits weg und erwartet ein Verdikt; kürzer und ein Zwei-Minuten-Core-Restart ist ein verlorener Lauf. AWS' Leitfaden zu Exponential Backoff und Jitter deckt die Form ab — unsere dreissig Minuten sind die „Give-up"-Grenze, mit dem standardmässigen gedeckelten Jitter-Backoff dazwischen.

Promoter und Admitter teilen sich denselben Lock und dasselbe Key-Format. Der Lock-Key ist ein gehashtes String-Template, analysis_queue:<user>. Beide Aufrufstellen nutzen genau dasselbe Template. Ein Copy-Paste-Tippfehler (analysis-queue: mit Bindestrich, zum Beispiel) würde den Lock-Namespace still in zwei spalten: Beide Aufrufer würden „einen" Lock erwerben, keiner den Lock, und die Serialisierung würde verdampfen. Es gibt jetzt einen einzelnen withUserQueueLock(userId, fn)-Helper, dem das Key-Format gehört, und jedes Admit und jede Promotion läuft hindurch.

Die ganze Queue sind rund 500 Zeilen TypeScript, zwei SQL-Ausdrücke und eine zweistufige Upstream-API. Sie serialisiert die eigene Back-to-Back-Arbeit einer Nutzerin, hält jeden synchronen Fehler beim ersten Klick, überlebt einen Redeploy, ohne In-Flight-State zu verlieren, und tut all das auf Postgres-Primitiven, die wir für andere Gründe schon in Produktion hatten. Wenn Sie einen Workflow bedienen, bei dem das schwierigste Load-Muster eine einzelne motivierte Nutzerin ist — buchen Sie einen kostenlosen AI-Potenzial-Check — oder lesen Sie unseren Debounced-Autosave-Race-Writeup für die frontend-geformte Version desselben „Komposition unter einem Lock"-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?