Prisma P2024: Timeout beim Holen einer neuen Connection — connection_limit trifft Coolify und Serverless
Nach einem Coolify-Redeploy hat ein Next.js-plus-Bun-Worker-Stack unter bescheidenem Traffic intermittierend Prisma P2024 geworfen. connection_limit hochzusetzen oder new PrismaClient() pro Request hat es verschlimmert. Das ist die Feldnotiz für einen Client pro Prozess, PgBouncer Transaction- versus Session-Mode, Pool-Rechnung über Replicas und einen Healthcheck, der nicht bei jedem Probe eine Connection leakt.
Prisma P2024: Timeout beim Holen einer neuen Connection — connection_limit trifft Coolify und Serverless
Der teuerste Datenbankfehler ist der, der nach Last aussieht. Wir haben an einem ruhigen Nachmittag ein Coolify-Redeploy ausgeliefert, den neuen Web-Container grün werden sehen, und zehn Minuten später landete der erste PrismaClientKnownRequestError in den Logs: P2024, Timed out fetching a new connection from the connection pool. Der Traffic war bescheiden. Die Postgres-CPU langweilte sich. Die Dashboard-Charts, die wir wirklich anschauen — Query-Zeit, Disk, Replication-Lag — haben sich nicht bewegt. Dann hat jemand connection_limit=10 an die DATABASE_URL gehängt, weil die Prisma-Docs den Parameter im selben Absatz wie den Fehler erwähnen, und die Timeouts wurden dichter. Ein zweiter Anlauf hat im Request-Helper ein new PrismaClient() erzeugt, "damit Serverless keinen State teilt." Das hat es nochmal verschlimmert. Das ist die Aufzeichnung von Prisma P2024, wenn connection_limit auf Coolify und Serverless trifft — ein Client pro Prozess gegen einen pro Invocation, PgBouncer Transaction-Mode gegen Session-Mode, Pool-Rechnung über N Replicas, und das Rolling-Deploy-Fenster, in dem alte und neue Container beide einen vollen Pool halten.
Der Stack ist der, den wir schon einmal geteilt haben. Ein Next.js-App-Router-Prozess und ein Bun-Worker, beide an einem Postgres, verdrahtet über pg_notify so, wie es die Worker-Split-Notiz beschrieben hat. Dieselbe Form treffen wir in Produkten wie Acurio: die meiste Woche ein langlebiges Coolify-Paar, und gelegentlich ein Vercel-förmiger Serverless-Pfad, der im Prisma-Client identisch aussieht und es nicht ist. Der Fehlerstring sagt dir nicht, in welchem von beiden du bist. Der naive Fix fragt nicht.
Der Timeout, der nicht Postgres ist, das Nein sagt
P2024 ist ein clientseitiges Warten. Die Query-Engine von Prisma hält einen eigenen Pool. Eine Query, die aus diesem Pool innerhalb von pool_timeout (zehn Sekunden, wenn du es nicht geändert hast) keine Connection auschecken kann, wirft P2024. Postgres hat die Query nie gesehen. Kein Row-Lock, kein langsamer Sequential Scan, kein "too many connections" im Postgres-Log für diesen Request. Der Prozess sass auf der internen Queue von Prisma und hat aufgegeben.
Deshalb lügen die Dashboards. pg_stat_activity kann beschäftigt aussehen — viele idle-Backends aus Pools, die schon geöffnet haben — und trotzdem unter max_connections noch Platz haben. Oder es fehlt ein Slot, und die App wirft trotzdem P2024, weil Prisma Connection Nummer connection_limit + 1 nicht öffnet, selbst wenn Postgres sie akzeptiert hätte. Zwei Decken. Ein Fehlerstring. Leute behandeln den String als "die Datenbank ist überlastet" und drehen die Client-Decke hoch.
Der Default-connection_limit ist num_physical_cpus * 2 + 1. Auf einer Coolify-Box, die vier CPUs meldet, sind das schon neun Connections pro PrismaClient. In einer Vercel-Function ist die CPU-Zahl eine Fiktion, die trotzdem einen echten TCP-Pool erzeugt. Default-pool_timeout von zehn Sekunden ist lang genug, damit ein User-Request kaputt wirkt, und kurz genug, dass ein Burst ihn auslöst, bevor du eine brauchbare pg_stat_activity-Stichprobe hast.
Ein Cousin-Fehler — P1001 / P1017 / Postgres too many clients already — ist der Server, der ein neues Backend ablehnt. Wer nur nach P2024 grept, verpasst die Stunde nach einem Deploy, in der die beiden die Plätze tauschen. Logge beides, plus pg_stat_activity nach application_name und state. P2024 allein ist kein Capacity-Graph.
Die zwei Fixes, die den Pool multiplizieren
Der erste naive Fix ist ein Query-Parameter.
# sieht aus wie eine Drossel. ist eine Untergrenze pro Prozess.
DATABASE_URL="postgresql://app:…@db:5432/app?connection_limit=10"
connection_limit ist die Grösse des Prisma-Pools dieses Prozesses. Es ist keine Cluster-weite Kappe. Ein Next.js-Container plus ein Bun-Worker sind zwei Clients. Ein Coolify-Rolling-Deploy, das den alten Container oben lässt, bis der neue die Healthchecks besteht, sind vier. Eine Preview-App, ein CI-prisma migrate und ein Laptop auf dieselbe Instanz sind mehr. Zehn mal vier sind vierzig Backends, bevor irgendwer etwas Kluges getan hat. Ein kleines Coolify-Postgres — oder ein gehosteter Plan, dessen max_connections 60 oder 100 ist, mit Slots für Superuser und Autovacuum — hat keine vierzig idle Backends übrig. Die Zahl hochzusetzen, weil "wir beim Holen einer Connection getimeoutet haben", ist, wie du ein Client-Queue-Timeout in too many clients verwandelst, sobald zwei Deploys überlappen.
Der zweite naive Fix ist ein Konstruktor.
// Request-Helper, "damit wir keinen State teilen"
export async function getUser(id: string) {
const prisma = new PrismaClient();
try {
return await prisma.user.findUnique({ where: { id } });
} finally {
// oft vergessen; selbst wenn vorhanden, hast du den Handshake bezahlt
await prisma.$disconnect();
}
}
Jeder PrismaClient besitzt einen Pool. In einem langlebigen Next.js- oder Bun-Prozess ist das keine "eine Connection." Es sind connection_limit Connections, lazy geöffnet und gehalten bis $disconnect oder Prozess-Tod. Bei jedem Request konstruieren und $disconnect vergessen heisst, einen Pool pro Request zu leaken, bis Postgres den nächsten Handshake ablehnt. Im finally disconnecten und du zahlst trotzdem TLS plus Auth auf dem Hot Path, mit N überlappenden Clients, die jeweils ihren eigenen kleinen Pool öffnen. Die Serverless-Folklore — "teile keinen Client über Invocations" — galt eingefrorenen Isolates und geleakten Listeners, nicht einem neuen Engine-Konstrukt bei jedem HTTP-Treffer in einem warmen Node-Prozess.
Wir haben beides im selben PR gesehen: connection_limit=10 "um sicher zu gehen", und einen frischen Client im Webhook-Handler "weil Stripe retried." Der Webhook-Claim ist der richtige Instinkt für das Event. Er ist der falsche Instinkt für das Datenbank-Handle. Claim das Event einmal. Claim den Client einmal pro Prozess.
Ein PrismaClient pro Prozess — und was "Prozess" heisst
Die Regel, die überlebt, ist langweilig: ein PrismaClient pro Node- oder Bun-Prozess, auf globalThis gelegt, damit Next.js-Hot-Reload in der Entwicklung keine zweite Engine alloziert. Produktion auf Coolify hot-reloadet nicht, aber dasselbe Modul ist das, was du auslieferst, und ein zweiter Import-Graph, der einen eigenen Client konstruiert, ist, wie du still zwei Pools in einem Container fährst.
import { PrismaClient } from "@prisma/client";
const globalForPrisma = globalThis as unknown as {
prisma?: PrismaClient;
};
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({
log: process.env.NODE_ENV === "development" ? ["error", "warn"] : ["error"],
});
if (process.env.NODE_ENV !== "production") {
globalForPrisma.prisma = prisma;
}
In Produktion willst du den Singleton trotzdem; du brauchst nur nicht den globalThis-Guard für HMR. Was du brauchst, ist ein Import-Pfad. Ein instrumentation.ts, das einen Client für Migrationen konstruiert, ein lib/db.ts, das einen zweiten konstruiert, und eine Worker-Datei, die einen dritten konstruiert — in einem Container — sind drei Pools. Web und Worker sollen je einen Client haben. Sie sollen sich keinen Module-Level-Prisma-Client mit einem rohen pg-LISTEN teilen, und sie sollen nicht jeder Prisma zweimal "für Isolation" konstruieren.
Serverless ändert das Substantiv, nicht die Regel. Ein warmes Vercel-Isolate soll einen PrismaClient wiederverwenden. Ein brandneues Isolate soll einen konstruieren und mit ihm sterben — oder, besser, gar keinen Pool aus Postgres-TCP-Connections halten. Der Failure-Mode ist N warme Isolates × connection_limit. Zwanzig gleichzeitige Isolates mit einem default-nahen Limit von zehn sind zweihundert Backends auf eine Datenbank, die für eine Coolify-Box dimensioniert war. Das ist kein Traffic-Spike im Produkt-Sinn. Es ist ein Fan-out von Pools. Die Cold-Start-Warmup-Notiz hat die andere Hälfte schon benannt: ein Scale-from-Zero-Pfad, der gesund aussieht, weil das Teure auf einem anderen Isolate passiert ist als dem, das du anschaust. Hier ist das Teure der Pool, den das Isolate geöffnet hat und halten wird, bis es eingefroren oder getötet wird.
Wenn der Pfad wirklich serverless ist — kurzlebig, hochconcurrent, kein klebriger Prozess — ist die Prisma-förmige Antwort ein Serverless-Driver oder ein Pooler davor, mit connection_limit von 1–3 pro Isolate, nicht 10. Die Coolify-DATABASE_URL unverändert auf ein Vercel-Projekt zu kopieren, ist, wie ein Freitags-Preview-Deploy das Produktions-Postgres umlegt.
PgBouncer Transaction-Mode gegen Session-Mode
PgBouncer ist der andere Ort, an dem Leute eine URL einfügen und denken, sie hätten Pooling gelöst. Sie haben einen zweiten Pool dazugetan, mit einem Mode, den Prisma vielleicht nicht überlebt.
Session-Mode weist eine Server-Connection für die Lebensdauer der Client-Connection zu. Prisma öffnet connection_limit Client-Connections, und PgBouncer hält connection_limit Server-Connections dafür. Du hast die Peak-Backends nicht reduziert. Du hast einen Hop dazugetan. Prepared Statements funktionieren. LISTEN / NOTIFY funktionieren. Die Rolling-Deploy-Überlappung verdoppelt die serverseitige Zahl trotzdem. Session-Mode ist ein Proxy, kein Multiplexor.
Transaction-Mode gibt die Server-Connection am Transaktionsende zurück. Viele Prisma-Clients können sich ein viel kleineres Set Postgres-Backends teilen. Das ist der Mode, den du wirklich wolltest, als du gesagt hast "wir haben PgBouncer davor gesetzt." Er zerbricht zwei Dinge, die Prisma by Default tut.
Die Query-Engine von Prisma nutzt Prepared Statements. Im Transaction-Mode überleben diese Statements die Rückgabe der Server-Connection nicht; der nächste Checkout kann ein anderes Backend sein, das S_1 nie gesehen hat. Das Symptom ist nicht immer P2024. Es ist prepared statement "s0" does not exist, intermittierend, schlimmer nach einem Pool-Squeeze. Der dokumentierte Prisma-Ausweg ist das URL-Flag:
# Transaction-Mode-Pooler. schaltet Prismas Prepared Statements ab.
DATABASE_URL="postgresql://app:…@pgbouncer:6432/app?pgbouncer=true&connection_limit=5&pool_timeout=10"
pgbouncer=true heisst nicht "ich habe PgBouncer." Es heisst "keine Prepared Statements, weil das Backend unter mir wechselt." Prisma auf einen Transaction-Mode-Pooler ohne dieses Flag zu zeigen, ist ein zweiter Vorfall, gewöhnlich eröffnet als "Prisma ist flaky, seit wir den Pooler dazugetan haben."
Der andere Bruch ist Session-State. Der LISTEN acurio_task_kick-Client des Bun-Workers aus dem pg_notify-Split kann nicht auf einem Transaction-Mode-Pool leben. LISTEN ist session-scoped; das Backend der nächsten Transaktion ist nicht subscribed. Diese Connection muss um PgBouncer herum, durch einen Session-Mode-Pool oder ein direkter pg-Client mit einem eigenen Ein-Connection-Budget sein. Sie in die Prisma-DATABASE_URL zu mischen, ist, wie Notify still stirbt, nachdem du "Pooling gefixt" hast.
Coolify macht die URL-Verwechslung billig. Die Postgres-Service-URL und die PgBouncer-Service-URL sehen gleich aus, ausser Host und Port. Teams kopieren connection_limit auf beide. Vier App-Prozesse × fünf Prisma-Clients können in ein default_pool_size von 20 passen — bis beim Deploy ein fünfter Prozess erscheint und PgBouncer zu queuen beginnt, was Prisma als P2024 reportet, weil sein Checkout vom Pooler timeoutet hat. Derselbe Fehlerstring. Eine andere Queue.
Statement-Mode ist der dritte PgBouncer-Mode und der, den Prisma nicht sehen sollte. Ein Statement, dann geht die Server-Connection zurück. Keine Multi-Statement-Transaktion, kein LISTEN. Wenn dein Pooler im Statement-Mode ist, ist der Fix eine andere Datenbank-URL, kein Prisma-Flag.
Das Rolling Deploy, das zwei Pools hält
Der Coolify-Fall, der unseren ersten dichten P2024-Burst erzeugt hat, war kein Traffic. Es war Überlappung.
Ein Rolling Deploy startet den neuen Container, wartet auf einen Healthcheck, dann stoppt es den alten. In diesem Fenster sind beide Container lebendig. Jeder hat einen Prisma-Singleton. Jeder Singleton hat bereits connection_limit Backends gewärmt — oder wird es, sobald der erste Request oder Worker-Poll läuft. Für ein Web-plus-Worker-Paar sind das vier Pools. Wenn du connection_limit so dimensioniert hast, dass zwei Prozesse knapp unter max_connections sitzen, ist die Überlappung der Überschuss. Wir haben P2024 auf dem neuen Web-Container getroffen, während der alte Worker noch einen vollen Idle-Pool hielt, der niemandem diente.
SIGTERM-Handling gehört zum Budget. Die Worker-Split-Aufzeichnung draint bereits In-Flight-Jobs. Der Pool muss auch drainen. Ein Prozess, der SIGTERM bis zum Kill-Timeout ignoriert, behält seine Backends, bis Postgres das TCP-Reset sieht. Ein Prozess, der den Singleton im Signal-Handler $disconnect()et, gibt sie rechtzeitig zurück, damit der neue Container seine eigenen öffnen kann. Ohne das ist "rolling" "zwei volle Allokationen plus eine Leiche."
function shutdown(signal: string) {
console.info("shutdown", { signal });
void prisma.$disconnect().finally(() => process.exit(0));
}
process.on("SIGTERM", () => shutdown("SIGTERM"));
process.on("SIGINT", () => shutdown("SIGINT"));
Healthchecks sitzen im selben Fenster und sind der Leak, der einen korrekten Singleton überlebt. Coolify probed /api/health alle paar Sekunden. Wenn diese Route den Prozess-Prisma-Client nutzt und SELECT 1 laufen lässt, ist das ein weiterer Checkout eines bestehenden Pools — in Ordnung, fast umsonst, und es hält den Pool warm, so wie ein Warmup-Ping ein Modell geladen hält. Wenn der Healthcheck ein getrenntes Kommando ist — bun run healthcheck.ts als Container-Test, der new PrismaClient() macht, queried und ohne $disconnect exitet — ist jeder Probe ein kurzlebiger Prozess, der ein Backend in idle hinterlassen kann, bis das Server-Timeout greift. Ein Probe alle fünf Sekunden plus ein 60-Sekunden-Backend-Idle-Timeout ist ein rollierender Leak. Wir haben einen "gesunden" Service ein Dutzend Backends allein am Probe fressen sehen, während User-Requests P2024 warfen.
Der Healthcheck, den wir wollen, trifft eine HTTP-Route, die sich den Singleton schon teilt, öffnet pg_isready ohne Prisma, oder konstruiert einen Client, queried und $disconnectet in derselben Prozesslebenszeit. Was er nicht tun darf, ist eine Connection "nur um sicherzugehen" zu öffnen und anzunehmen, Process-Exit sei ein sofortiges Backend-Teardown. Die Backstop-Sweep-Notiz hat eine Coolify-Überlappung bereits als zwei Writer auf einer Zeile behandelt. Behandle sie auch als zwei Pools auf einem max_connections.
Die Checkliste, die wir nach P2024 abarbeiten
Fünf Checks, in dieser Reihenfolge, bevor irgendwer connection_limit hochsetzen darf.
Zähl die Prozesse, die einen PrismaClient halten werden. Web-Replicas, Worker-Replicas, CI-Migrate-Jobs, Preview-Apps auf dieselbe Datenbank, und das extra Set, das für die Dauer eines Rolling Deploys existiert. Serverless: Peak gleichzeitige Isolates, nicht "eine Function." Schreib die Zahl auf. Ein LISTEN-Client und eine prisma migrate-Session sind extra Backends, die in connection_limit nicht auftauchen.
Mach die Rechnung gegen die echte Decke.
processes_during_overlap × connection_limit
+ listen_clients
+ migrate_and_admin
+ leaky_healthcheck_budget
≤ usable_postgres_backends
usable ist nicht max_connections. Es ist max_connections minus Superuser-Reserve minus Autovacuum minus Pooler-Overhead. Wenn PgBouncer im Transaction-Mode ist, ist die Postgres-seitige Zahl sein pool_size, und die Prisma-seitige Zahl ist, was du Richtung Pooler öffnen darfst. Zwei Ungleichungen. Beide müssen halten. Wir dimensionieren ein Coolify-Paar auf connection_limit=5 an Web und Worker, nehmen Überlappung an, also zehn Prisma-Backends plus ein LISTEN plus zwei Admin, und halten ein gehostetes max_connections von 60 davon ab, mehr als etwa zwanzig App-Backends zu sehen. Wenn die Rechnung nicht passt, senkst du connection_limit oder du setzt einen Transaction-Mode-Pooler davor — du hebst das Client-Limit nicht, weil P2024 nach Hunger klang.
Ein Singleton pro Prozess, ein Import-Pfad. lib/db.ts exportiert prisma. Sonst ruft niemand new PrismaClient() auf. Der Worker importiert denselben Helper in seinem eigenen Prozess. $disconnect gehört in den Prozess-Shutdown, nicht in ein finally an jeder Query.
Benenn den PgBouncer-Mode in der URL, die du wirklich nutzt. Direktes Postgres: kein pgbouncer=true. Transaction-Mode-Pooler: pgbouncer=true, kleineres connection_limit, und eine getrennte direkte oder Session-Mode-URL für LISTEN. Session-Mode-Pooler: behandle ihn kapazitätsseitig wie direkt. Wenn du nicht sagen kannst, welcher Mode das Host:Port ist, bist du nicht bereit, connection_limit zu setzen.
Beweise, dass der Healthcheck keinen Pool besitzt. Wenn er HTTP gegen den laufenden Prozess ist, soll die Route den Singleton nutzen oder Postgres überspringen. Wenn er ein One-Shot-Skript ist, muss er $disconnect(), und pg_stat_activity-idle darf der Probe-Frequenz nicht folgen. SIGTERM muss den Singleton $disconnecten, damit die Überlappung zwei lebendige Pools ist, nicht zwei lebendige plus einen sterbenden. Auf einem Serverless-Pfad: connection_limit=1 oder 2, ein Transaction-Pooler oder ein Serverless-Driver — kein Coolify-grosser Pool.
Drei Regeln, die das nächste Hosting-Panel überleben
Drei Regeln überleben diese Aufzeichnung und verallgemeinern sich über Coolifys Rolling-Flag und das hinaus, wie Vercel ein Isolate in diesem Jahr nennt.
P2024 ist Prisma, das auf Prisma wartet, nicht Postgres, das dich ablehnt. Die Decke ist connection_limit und pool_timeout in diesem Prozess. connection_limit hochzusetzen, ohne die Prozesse neu zu zählen — inklusive der toten, die noch im Deploy sind — verschiebt den Fehler auf max_connections oder die Pooler-Queue, wo der String P2024 bleiben kann. Lies pg_stat_activity nach Application und State, bevor du die URL editierst.
Ein Pool ist pro Prozess, ein Prozess ist pro Container oder Isolate, und ein Deploy ist zwei von jedem. Der Singleton fixt "neuer Client pro Request." Er ist keine Cluster-weite Kappe. Überlappung, Replicas und Serverless-Concurrency multiplizieren die Zahl in der DATABASE_URL. Dimensioniere für die Überlappung, die du wirklich fährst, oder draine so, dass die Überlappung keinen Pool hält.
Der PgBouncer-Mode ist ein Vertrag mit Prisma, keine Checkbox. Transaction-Mode braucht pgbouncer=true und kann kein LISTEN tragen. Session-Mode schrumpft keine Backends. Statement-Mode ist die falsche URL. Der Worker, der auf pg_notify subscribed, und der Request-Pfad, der findUnique laufen lässt, dürfen sich über die URL uneinig sein. Sie dürfen nicht die falsche teilen.
Die Komposition ist die Notiz. Wir haben P2024 als Last behandelt, weil die Meldung "timed out fetching a connection" sagt und der erste Knopf im selben Satz connection_limit ist. Produktion war ein rollierendes Paar Container, jedes mit einem default-grossen Engine-Pool, plus ein Healthcheck-Skript, das einen eigenen Client konstruiert hat. Ein Singleton, ein Budget als Multiplikation, ein Pooler-Mode, den wir benennen konnten, und ein Probe, der nicht leakt, hätten das Loch in der ersten Minute gezeigt — derselben Minute, die wir mit dem Hochsetzen des Limits verbracht haben.
Wenn du nach einem Coolify-Deploy oder auf einem Vercel-Pfad, der die Produktionsdatenbank teilt, intermittierendes P2024 siehst — buche einen kostenlosen AI-Potenzial-Check. Die Worker-Split-Aufzeichnung ist die Prozess-Topologie, in die dieser Pool passen muss; der Stripe-Claim ist die Erinnerung, dass der Handler in dieser Überlappung zweimal laufen darf und trotzdem einen Client teilen muss.
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.