Stop waiting on the network eleven times per page

The app got slower as pages grew, and the reason was not the queries. It
was their number.

A transaction is pinned to one connection, and a connection runs queries
one after another. Every Promise.all in a withUser block looked like
concurrency and was a queue. Measured against the real database: the
round trip is ~36 ms, ten trivial `select 1` over one connection take
343 ms, over ten connections 39 ms. Nothing here is slow — the whole
dashboard payload is under 200 kB, and every table is around a thousand
rows.

More connections is the wrong answer: the RLS session context is per
transaction, so parallel reads mean parallel transactions, and those
multiply the connections the database will grant. Fewer round trips
instead. Postgres will return each sub-select as its own JSON column of
one result.

Per page view, counting the transaction frame:

  shell (paid by every page)  10 → 4
  overview                    14 → 5
  employee file               14 → 7
  employee list                8 → 6

The overview plus its shell went from 24 round trips to 9 — about 860 ms
of pure waiting down to about 320 ms.

The one trap is documented where it bites: inside json_agg, Postgres
formats values itself and the driver's parsers (lib/db/pool.ts) never
see them. Dates, numerics and uuids come out identical; timestamptz does
not — "+00:00" where the driver gives "…Z". Timestamps are compared as
strings in lib/history.ts to decide what happened later, and those two
forms sort against each other wrongly. Every timestamptz in a bundled
query therefore goes through zeitstempel(), which was checked
character-for-character against the driver.

Four loaders moved out of their pages into lib/ so the number of round
trips can be measured without building a React tree, and so the new path
could be held against the old one field by field: same rows, same order,
same strings, for the overview and for four employee files chosen to
differ (with history, a chief, a planned entry, one with dependents).

withUser now counts the queries in each transaction and says so in
development past a threshold. Without that, this grows back: each new
tile brings its own query, and nobody notices until everybody does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-16 19:31:36 +02:00
parent 6957b95a97
commit 91b2b3406b
13 changed files with 753 additions and 336 deletions

View File

@@ -1,4 +1,5 @@
import "server-only";
import { AsyncLocalStorage } from "node:async_hooks";
import { Kysely, PostgresDialect, sql, type Transaction } from "kysely";
import { getPool } from "./pool";
import type { Schema } from "./schema";
@@ -36,8 +37,33 @@ import type { Schema } from "./schema";
// Der Pool wird als Funktion übergeben, nicht als fertige Instanz: Kysely
// ruft sie erst bei der ersten Abfrage auf. So verlangt der Import dieses
// Moduls noch keine Zugangsdaten — siehe getPool().
// ═══ Wie viele Rundreisen eine Anfrage kostet ═══
//
// Eine Transaktion hängt an einer Verbindung, und über eine Verbindung laufen
// Abfragen nacheinander — auch die in einem Promise.all. Bei rund 36 ms
// Umlaufzeit zur Datenbank ist die Zahl der Abfragen deshalb *die* Kennzahl
// für die Ladezeit einer Seite, und zwar eine, die man nicht schätzen muss.
//
// Sie wird darum mitgezählt und im Entwicklungsbetrieb gemeldet, sobald eine
// Transaktion viele davon braucht. Ohne diese Meldung wächst so etwas
// unbemerkt: jede neue Kachel bringt ihre eigene Abfrage mit, und dass die
// Seite langsamer wird, merkt man erst, wenn es alle merken.
const zaehler = new AsyncLocalStorage<{ abfragen: number }>();
export function zaehleAbfragen(): { abfragen: number } | undefined {
return zaehler.getStore();
}
/** Ab wann eine Transaktion im Entwicklungsbetrieb gemeldet wird. */
const MELDESCHWELLE = Number(process.env.DB_QUERY_WARN ?? 6);
const db = new Kysely<Schema>({
dialect: new PostgresDialect({ pool: async () => getPool() }),
log: (event) => {
const store = zaehler.getStore();
if (store) store.abfragen++;
if (event.level === "error") console.error("Abfrage fehlgeschlagen:", event.error);
},
});
export type Tx = Transaction<Schema>;
@@ -49,11 +75,26 @@ export type Tx = Transaction<Schema>;
* greift keine Policy und es kommt nichts zurück, was auch richtig ist.
*/
export async function withUser<T>(userId: string | null, fn: (tx: Tx) => Promise<T>): Promise<T> {
return db.transaction().execute(async (tx) => {
// Erste Anweisung der Transaktion, vor allem anderen.
await sql`select set_config('app.user_id', ${userId ?? ""}, true)`.execute(tx);
return fn(tx);
});
const stand = { abfragen: 0 };
const start = performance.now();
try {
return await zaehler.run(stand, () =>
db.transaction().execute(async (tx) => {
// Erste Anweisung der Transaktion, vor allem anderen.
await sql`select set_config('app.user_id', ${userId ?? ""}, true)`.execute(tx);
return fn(tx);
})
);
} finally {
// Nur im Entwicklungsbetrieb: in der Produktion gehörte das in die
// Ablaufverfolgung, nicht auf die Konsole.
if (process.env.NODE_ENV !== "production" && stand.abfragen > MELDESCHWELLE) {
console.warn(
`[db] ${stand.abfragen} Abfragen in einer Transaktion, ${Math.round(performance.now() - start)} ms — ` +
`sie laufen nacheinander über eine Verbindung. Bündeln: siehe lib/db/json.ts.`
);
}
}
}
/**

69
lib/db/json.ts Normal file
View File

@@ -0,0 +1,69 @@
// Kein `server-only` hier, anders als in ./index.ts und ./pool.ts: diese Datei
// baut nur Abfragen zusammen und hält weder Verbindung noch Zugangsdaten. Mit
// der Sperre wären die reinen Tests von lib/org.ts nicht mehr ladbar, obwohl
// dort nur ein Baum aus Zeilen gebaut wird.
import { sql, type Expression, type RawBuilder } from "kysely";
import { jsonArrayFrom, jsonObjectFrom } from "kysely/helpers/postgres";
// Mehrere unabhängige Lesevorgänge in **einer** Rundreise.
//
// ═══ Warum das nötig ist ═══
//
// Eine Transaktion hängt an genau einer Verbindung, und über eine Verbindung
// laufen Abfragen nacheinander — auch die, die in einem Promise.all stehen.
// Der Treiber stellt sie in eine Schlange. `Promise.all` sieht nach
// Gleichzeitigkeit aus und ist hier keine.
//
// Gemessen an der echten Datenbank: die Umlaufzeit beträgt rund 36 ms, zehn
// belanglose `select 1` über eine Verbindung brauchen 343 ms, über zehn
// Verbindungen 39 ms. Der Aufbau der Übersichtsseite — zehn Abfragen, die
// zusammen keine 200 kB liefern — kostete so knapp eine Sekunde, fast
// ausschliesslich Warten.
//
// Mehr Verbindungen sind trotzdem nicht die Antwort: der Sitzungskontext für
// RLS gilt je Transaktion (siehe ./index.ts), und mehrere Transaktionen je
// Anfrage vervielfachen die Verbindungen, die die Datenbank zulässt. Also
// weniger Rundreisen statt mehr Leitungen: Postgres kann jede Teilabfrage als
// JSON-Spalte in *einem* Ergebnis liefern.
//
// const { einheiten, standorte } = await tx
// .selectNoFrom((eb) => [
// jsonArrayFrom(eb.selectFrom("org_units").select([...])).as("einheiten"),
// jsonArrayFrom(eb.selectFrom("locations").selectAll()).as("standorte"),
// ])
// .executeTakeFirstOrThrow();
//
// Typisiert wie jede andere Kysely-Abfrage, mit Parametern, ohne Handarbeit
// an der Zeichenkette.
//
// ═══ Die eine Falle ═══
//
// Innerhalb von json_agg formatiert Postgres die Werte selbst, und der
// Treiber kommt nicht mehr daran (lib/db/pool.ts stellt ihn dort auf die
// Formen um, die die Typen beschreiben). Für die meisten Typen macht das
// nichts — im Gegenteil:
//
// date → "2022-03-30" wie ausserhalb
// numeric → 38.5 wie ausserhalb
// uuid → Zeichenkette wie ausserhalb
// timestamptz → "2026-08-03T12:08:06.272938+00:00"
// ← **anders**: ausserhalb "…272Z"
//
// Der Unterschied ist nicht kosmetisch. Zeitstempel werden im Projekt als
// Zeichenketten verglichen (lib/history.ts entscheidet daran, was später
// passiert ist), und "+00:00" sortiert gegen "Z" falsch herum. Deshalb geht
// **jede** timestamptz-Spalte in einer gebündelten Abfrage durch zeitstempel().
export { jsonArrayFrom, jsonObjectFrom };
/**
* Eine timestamptz-Spalte in der Form, die der Treiber ausserhalb von JSON
* liefert — ISO-8601 in UTC, auf Millisekunden gekürzt.
*
* Ohne das käme aus einer gebündelten Abfrage eine andere Zeichenkette als
* aus derselben Abfrage einzeln gestellt.
*/
export function zeitstempel(spalte: Expression<unknown> | string): RawBuilder<string> {
const ref = typeof spalte === "string" ? sql.ref(spalte) : spalte;
return sql<string>`to_char(${ref} at time zone 'utc', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')`;
}