Stop hoarding connections the provider will not give twice

The app died with "max clients reached in session mode - pool_size: 15".
Two causes, both real, neither visible without a live database.

The connection string pointed at the pooler's session mode, which pins one
backend per client and caps at 15 on Supabase. Every query here already runs
inside a transaction and the session context is set transaction-locally, so
transaction mode is not a workaround but the mode this design was written
for. Verified: 20 concurrent transactions, all 852 rows, 0.4s — and still
nothing without a session context.

The second cause was the dev server. Next.js re-evaluates changed modules,
so a module-local `let` was empty afterwards while the previous pool stayed
alive holding its connections. An afternoon of editing exhausted the quota.
The pool now hangs off globalThis, which is inert in production where
nothing reloads.

Documented in .env.example and DEPLOYMENT.md, because a deployment that
picks port 5432 fails this way under load and not before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-03 10:28:36 +02:00
parent 4d8e3f7154
commit 99e50fbbf9
3 changed files with 25 additions and 2 deletions

View File

@@ -4,6 +4,14 @@
# #
# Die Rolle in diesem String darf KEIN BYPASSRLS haben: fehlt der # Die Rolle in diesem String darf KEIN BYPASSRLS haben: fehlt der
# Sitzungskontext, sollen die Policies nichts zurückgeben statt alles. # Sitzungskontext, sollen die Policies nichts zurückgeben statt alles.
#
# Hinter einem Verbindungspooler (Supabase Supavisor, PgBouncer) den
# TRANSAKTIONS-Modus nehmen, nicht den Sitzungs-Modus — bei Supabase Port
# 6543 statt 5432. Jede Abfrage dieser Anwendung läuft ohnehin in einer
# Transaktion, und der Sitzungskontext wird transaktionslokal gesetzt; beides
# passt genau dazu. Der Sitzungs-Modus belegt dagegen je Client eine feste
# Verbindung und ist bei Supabase auf 15 begrenzt — danach antwortet die
# Anwendung nur noch mit „max clients reached".
DATABASE_URL= DATABASE_URL=
# Auf "false" setzen, wenn die Datenbank ohne TLS läuft (lokal, CI). # Auf "false" setzen, wenn die Datenbank ohne TLS läuft (lokal, CI).
DATABASE_SSL= DATABASE_SSL=

View File

@@ -37,7 +37,7 @@ Werte eintragen:
| Variable | Woher | | Variable | Woher |
|---|---| |---|---|
| `DATABASE_URL` | Verbindungsstring der PostgreSQL-Instanz. Die Rolle darf **kein** `BYPASSRLS` haben | | `DATABASE_URL` | Verbindungsstring der PostgreSQL-Instanz. Die Rolle darf **kein** `BYPASSRLS` haben; hinter einem Pooler den **Transaktions-Modus** (bei Supabase Port 6543) |
| `DATABASE_SSL` | nur setzen (`false`), wenn die Datenbank ohne TLS läuft | | `DATABASE_SSL` | nur setzen (`false`), wenn die Datenbank ohne TLS läuft |
| `AUTH_SECRET` | selbst generieren: `openssl rand -base64 32` | | `AUTH_SECRET` | selbst generieren: `openssl rand -base64 32` |
| `AUTH_MICROSOFT_ENTRA_ID_ID` | Entra-Portal → App-Registrierung → Übersicht | | `AUTH_MICROSOFT_ENTRA_ID_ID` | Entra-Portal → App-Registrierung → Übersicht |
@@ -158,6 +158,9 @@ Single-Instance-Compose-Konfiguration ist das nicht nötig.
- **Cron läuft nicht:** `docker compose logs cron` prüft, ob - **Cron läuft nicht:** `docker compose logs cron` prüft, ob
`/etc/crontabs/root` korrekt geschrieben wurde und ob `CRON_SECRET` in `/etc/crontabs/root` korrekt geschrieben wurde und ob `CRON_SECRET` in
`.env` gesetzt ist (leer/fehlend führt serverseitig zu `401`). `.env` gesetzt ist (leer/fehlend führt serverseitig zu `401`).
- **`max clients reached in session mode`:** der Verbindungsstring zeigt auf
den Sitzungs-Modus des Poolers. Auf den Transaktions-Modus wechseln (bei
Supabase Port 6543).
- **Healthcheck rot:** `docker compose logs app` meist `DATABASE_URL` - **Healthcheck rot:** `docker compose logs app` meist `DATABASE_URL`
fehlend oder nicht erreichbar. Der Pool baut die Verbindung erst beim fehlend oder nicht erreichbar. Der Pool baut die Verbindung erst beim
ersten Zugriff auf, der Fehler steht deshalb im Log der Anfrage, nicht im ersten Zugriff auf, der Fehler steht deshalb im Log der Anfrage, nicht im

View File

@@ -45,7 +45,18 @@ types.setTypeParser(1700, (v) => Number(v)); // numeric → Zahl
// die Kysely-Instanz, die ihn benutzt, und eine ESLint-Regel verbietet den // die Kysely-Instanz, die ihn benutzt, und eine ESLint-Regel verbietet den
// Import von `pg` und von dieser Datei überall sonst. // Import von `pg` und von dieser Datei überall sonst.
let instance: Pool | undefined; // Der Pool hängt am globalen Objekt, nicht nur am Modul.
//
// Im Entwicklungsbetrieb lädt Next.js geänderte Module neu. Ein modul-lokales
// `let` wäre danach leer, der alte Pool aber weiterhin am Leben — mit seinen
// Verbindungen. Nach ein paar Bearbeitungen ist das Kontingent des Anbieters
// aufgebraucht, und die Anwendung antwortet nur noch mit „max clients
// reached". Genau so ist sie hier stehengeblieben.
//
// In der Produktion gibt es kein Neuladen; dort ist die Zeile wirkungslos.
const globalForPool = globalThis as typeof globalThis & { __alpenwerkPool?: Pool };
let instance: Pool | undefined = globalForPool.__alpenwerkPool;
/** /**
* Der Verbindungspool — erst beim ersten Zugriff angelegt, nicht beim Import. * Der Verbindungspool — erst beim ersten Zugriff angelegt, nicht beim Import.
@@ -86,5 +97,6 @@ export function getPool(): Pool {
console.error("Unerwarteter Fehler auf einer Leerlaufverbindung:", err); console.error("Unerwarteter Fehler auf einer Leerlaufverbindung:", err);
}); });
globalForPool.__alpenwerkPool = instance;
return instance; return instance;
} }