diff --git a/src/app/api/checkout/route.test.ts b/src/app/api/checkout/route.test.ts index ab69846..149fe60 100644 --- a/src/app/api/checkout/route.test.ts +++ b/src/app/api/checkout/route.test.ts @@ -10,6 +10,7 @@ * ohne dass irgendwo ein Fehler sichtbar wurde. */ import { describe, test, expect, beforeEach, vi } from "vitest"; +import { Prisma } from "@/generated/prisma/client"; const sessionsCreate = vi.fn(); const sessionsExpire = vi.fn(); @@ -215,6 +216,58 @@ describe("Checkout-Route", () => { }); }); + describe("Wenn viele gleichzeitig bestellen", () => { + /** + * Bei "Serializable" bricht die Datenbank eine von zwei widerspruechlichen + * Transaktionen ab (P2034). Das ist die vorgesehene Antwort auf + * Gleichzeitigkeit, kein Sachfehler — und weil alle Bestellungen desselben + * Produkts auf dieselbe Bestandszeile zugreifen, ist das bei Andrang der + * Normalfall. Frueher landete jeder dieser Abbrueche als Fehlermeldung bei + * der Kundschaft. + */ + function serializationConflict(): Error { + return new Prisma.PrismaClientKnownRequestError("could not serialize access", { + code: "P2034", + clientVersion: "7", + }); + } + + test("wiederholt die Bestellanlage und kommt durch", async () => { + const runTransaction = async (arg: unknown) => + typeof arg === "function" ? (arg as (tx: typeof db) => unknown)(db) : arg; + db.$transaction + .mockRejectedValueOnce(serializationConflict()) + .mockRejectedValueOnce(serializationConflict()) + .mockImplementation(runTransaction); + + const response = await post(); + + expect(response.status).toBe(200); + expect(db.$transaction).toHaveBeenCalledTimes(3); + expect(sessionsCreate).toHaveBeenCalledOnce(); + }); + + test("gibt nach vier vergeblichen Versuchen auf, statt endlos zu wiederholen", async () => { + db.$transaction.mockRejectedValue(serializationConflict()); + + const response = await post(); + + expect(response.status).toBe(409); + expect(db.$transaction).toHaveBeenCalledTimes(4); + }); + + test("wiederholt NICHT, wenn die Ware schlicht ausverkauft ist", async () => { + // Eine Sachentscheidung faende beim zweiten Versuch dasselbe Ergebnis + // vor — die Wiederholung wuerde die Antwort nur verzoegern. + db.inventory.updateMany.mockResolvedValue({ count: 0 }); + + const response = await post(); + + expect(response.status).toBe(409); + expect(db.$transaction).toHaveBeenCalledOnce(); + }); + }); + describe("Wenn Stripe nicht mitspielt", () => { test("loest die Bestellung auf und meldet ehrlich, dass nichts abgebucht wurde", async () => { // Regression: frueher blieb die Bestellung als "NEU" ohne Sitzungs-ID diff --git a/src/app/api/checkout/route.ts b/src/app/api/checkout/route.ts index 4b7faa5..115099e 100644 --- a/src/app/api/checkout/route.ts +++ b/src/app/api/checkout/route.ts @@ -31,6 +31,42 @@ class DiscountCodeExhaustedError extends Error {} // ist nur die freundlichere Fehlermeldung fuer den haeufigeren Fall. const SERIALIZATION_CONFLICT_CODE = "P2034"; +/** + * Wie oft eine an einem Serialisierungskonflikt gescheiterte Bestellanlage + * wiederholt wird, bevor die Kundschaft einen Fehler sieht. + * + * Ein solcher Konflikt ist KEIN Fehler, sondern die vorgesehene Antwort der + * Datenbank auf zwei gleichzeitige Transaktionen, die sich widersprechen + * koennten — bei "Serializable" bricht sie eine davon ab, damit nicht beide + * auf veralteten Staenden weiterrechnen. Erwartet wird, dass der Aufrufer es + * einfach noch einmal versucht. + * + * Genau das fehlte: der Abbruch wurde direkt als 409 an die Kundschaft + * durchgereicht ("bitte in ein paar Sekunden erneut versuchen"). Bei wenigen + * Bestellungen faellt das nie auf, bei 100 gleichzeitigen schon — alle + * greifen auf dieselbe Lagerbestandszeile zu, Konflikte sind dann der + * Normalfall und nicht die Ausnahme. Ein Teil der Kundschaft bekaeme + * ausgerechnet im Moment der Kaufentscheidung eine Fehlermeldung. + * + * Vier Versuche, weil jeder die Transaktion vollstaendig neu ausfuehrt + * (inklusive frischer Bestandspruefung) und viermal hintereinander zu + * verlieren unwahrscheinlich ist. Bleibt es dabei, ist der Andrang so gross, + * dass "gleich nochmal" die ehrliche Antwort ist. + */ +const SERIALIZATION_MAX_ATTEMPTS = 4; + +/** + * Wartezeit zwischen zwei Versuchen, mit Zufallsanteil. + * + * Ohne den Zufallsanteil starten alle gleichzeitig abgebrochenen Anfragen + * ihren naechsten Versuch im selben Moment und kollidieren erneut — dasselbe + * Problem, nur verschoben. + */ +function serializationBackoffMs(attempt: number): number { + const base = 25 * 2 ** (attempt - 1); + return base + Math.random() * base; +} + /** * Lebensdauer der Stripe-Sitzung. Stripe laesst zwischen 30 Minuten und 24 * Stunden zu und nimmt ohne Angabe die 24 Stunden. @@ -49,6 +85,37 @@ const SERIALIZATION_CONFLICT_CODE = "P2034"; */ const CHECKOUT_SESSION_TTL_MINUTES = 32; +/** + * Fuehrt die Bestellanlage aus und wiederholt sie, wenn die Datenbank sie + * wegen eines Serialisierungskonflikts abgebrochen hat. + * + * Wiederholt wird AUSSCHLIESSLICH dieser eine Fehlerfall. "Ausverkauft" und + * "Gutschein aufgebraucht" sind Sachentscheidungen — sie faenden beim zweiten + * Versuch dasselbe Ergebnis vor, und eine Wiederholung wuerde die Antwort nur + * verzoegern. + */ +async function withSerializationRetry(run: () => Promise): Promise { + for (let attempt = 1; ; attempt++) { + try { + return await run(); + } catch (error: unknown) { + const isConflict = + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === SERIALIZATION_CONFLICT_CODE; + if (!isConflict || attempt >= SERIALIZATION_MAX_ATTEMPTS) throw error; + + // Auf "info" statt "error": das ist der vorgesehene Ablauf, kein + // Stoerfall. Sichtbar bleibt es trotzdem, weil eine Haeufung hier das + // erste Anzeichen dafuer waere, dass der Andrang die Datenbank an ihre + // Grenze bringt. + console.info( + `[checkout] Serialisierungskonflikt bei der Bestellanlage (Versuch ${attempt} von ${SERIALIZATION_MAX_ATTEMPTS}), wird wiederholt.` + ); + await new Promise((resolve) => setTimeout(resolve, serializationBackoffMs(attempt))); + } + } +} + function generateOrderNumber(): string { const year = new Date().getFullYear(); // Kryptografisch zufaellig statt Math.random(): nicht erratbar und @@ -263,13 +330,17 @@ export async function POST(request: Request) { // Leute abbrechen. const cancelToken = randomBytes(32).toString("hex"); - let order; - try { - // Bestellanlage und Bestandsreservierung atomar: das bedingte - // updateMany (quantity >= bestellt) verhindert Ueberverkauf auch bei - // gleichzeitigen Bestellungen — schlaegt eine Position fehl, wird die - // gesamte Transaktion (inkl. bereits reservierter Positionen) verworfen. - order = await prisma.$transaction( + // Bestellanlage und Bestandsreservierung atomar: das bedingte updateMany + // (quantity >= bestellt) verhindert Ueberverkauf auch bei gleichzeitigen + // Bestellungen — schlaegt eine Position fehl, wird die gesamte Transaktion + // (inkl. bereits reservierter Positionen) verworfen. + // + // Als eigene Funktion, damit sie bei einem Serialisierungskonflikt + // vollstaendig wiederholt werden kann (siehe SERIALIZATION_MAX_ATTEMPTS). + // Jeder Versuch liest den Bestand frisch — eine Wiederholung kann also + // durchaus zu "ausverkauft" fuehren, und das ist dann auch richtig so. + const runOrderTransaction = () => + prisma.$transaction( async (tx) => { for (const item of orderItemsData) { const reserved = await tx.inventory.updateMany({ @@ -340,8 +411,23 @@ export async function POST(request: Request) { // gleichzeitigen Transaktionen, die beide denselben knapp begrenzten // Gutscheincode einloesen wollen, und bricht eine davon zuverlaessig ab // (statt beide auf Basis veralteter Zaehlerstaende durchzulassen). - { isolationLevel: "Serializable" } + { + isolationLevel: "Serializable", + // Prisma gibt einer Transaktion voreingestellt 2 Sekunden, um + // ueberhaupt eine Verbindung zu bekommen, und 5 Sekunden zum + // Ausfuehren. Bei starkem Andrang sind alle Verbindungen belegt und + // eine Bestellung scheitert dann nicht an der Sache, sondern am + // Warten — mitten in der Kaufentscheidung. Die Transaktion selbst + // bleibt kurz; die groesszuegigen Werte sind Puffer fuer die + // Warteschlange, keine Einladung, lange zu rechnen. + maxWait: 10_000, + timeout: 15_000, + } ); + + let order; + try { + order = await withSerializationRetry(runOrderTransaction); } catch (error: unknown) { if (error instanceof OutOfStockError) { return NextResponse.json( diff --git a/src/lib/db-adapter.ts b/src/lib/db-adapter.ts index f646f5d..dd9c2df 100644 --- a/src/lib/db-adapter.ts +++ b/src/lib/db-adapter.ts @@ -1,6 +1,32 @@ import { PrismaPg } from "@prisma/adapter-pg"; import { PrismaMariaDb } from "@prisma/adapter-mariadb"; +/** + * Gleichzeitig offene Datenbankverbindungen dieses Prozesses. + * + * Ohne Angabe nimmt node-postgres 10. Das reicht im Alltag mühelos, wird aber + * beim Andrang zur Obergrenze: eine Bestellung haelt ihre Verbindung ueber + * die gesamte Transaktion (Bestandsreservierung, Gutscheinpruefung, + * Bestellanlage), und Prisma gibt einer Transaktion nur begrenzt Zeit, + * ueberhaupt eine Verbindung zu bekommen (maxWait). Sind alle zehn belegt, + * scheitern weitere Checkouts nicht an der Sache, sondern am Warten. + * + * 20 ist bewusst moderat: PostgreSQL erlaubt in der Voreinstellung 100 + * gleichzeitige Verbindungen, und daneben brauchen noch der Aufraeum-CronJob, + * die Sicherung und gelegentlich eine Konsole welche. Die Anwendung laeuft + * ausserdem mit einer einzigen Instanz (siehe k8s/05-app-deployment.yaml) — + * bei mehreren Instanzen multipliziert sich dieser Wert und will neu bedacht + * werden. + */ +const POOL_SIZE = 20; + +/** + * Wie lange eine Anfrage auf eine freie Verbindung wartet, bevor sie + * aufgibt. Lieber ein klarer Fehler nach zehn Sekunden als eine Seite, die + * ewig laedt — und im Log ein Hinweis darauf, dass der Pool zu klein ist. + */ +const POOL_ACQUIRE_TIMEOUT_MS = 10_000; + /** * Prisma 7 liest die Verbindung nicht mehr aus dem Schema, sondern erfordert * einen expliziten Treiber-Adapter im Client — welcher, haengt von @@ -16,7 +42,20 @@ export function createDbAdapter(): PrismaPg | PrismaMariaDb { if (!connectionString) throw new Error("DATABASE_URL ist nicht gesetzt."); if (provider === "mysql") { + // Bewusst unveraendert die Verbindungszeichenkette: der MariaDB-Adapter + // nimmt entweder sie ODER ein Einstellungsobjekt aus Host, Benutzer, + // Passwort und Datenbank — beides zugleich geht nicht, die Zeichenkette + // muesste also zerlegt werden. Der Produktivbetrieb laeuft auf + // PostgreSQL (DATABASE_PROVIDER-Vorgabe); fuer MySQL gilt weiterhin die + // Voreinstellung des Treibers. return new PrismaMariaDb(connectionString); } - return new PrismaPg({ connectionString }); + return new PrismaPg({ + connectionString, + max: POOL_SIZE, + // Ohne Zeitgrenze wartet eine Anfrage unbegrenzt auf eine freie + // Verbindung. Unter Andrang staut sich dann alles lautlos auf, statt dass + // irgendwo ein Fehler sichtbar wuerde. + connectionTimeoutMillis: POOL_ACQUIRE_TIMEOUT_MS, + }); }