diff --git a/apps/api/scripts/rls-scratch-check.mjs b/apps/api/scripts/rls-scratch-check.mjs index 71947be..bb55d49 100644 --- a/apps/api/scripts/rls-scratch-check.mjs +++ b/apps/api/scripts/rls-scratch-check.mjs @@ -935,6 +935,94 @@ async function runTransactionShapeMeasurement(scratchRoleUrl, results) { ); } +/** + * Ein einzelner Aufruf in Form (ii): interaktive Callback-Transaktion auf dem + * GEBUNDENEN Client. Liefert den im Inneren gelesenen Mandantenkontext. + */ +async function runOneBoundInteractive(prisma, tenantId) { + const bound = buildInlineExtendedClient(prisma, tenantId); + return bound.$transaction(async (tx) => { + const rows = await tx.$queryRaw`SELECT current_tenant_id() AS t`; + return rows[0]?.t; + }); +} + +/** + * Ein einzelner Aufruf in Form (iii): interaktive Callback-Transaktion auf dem + * UNGEBUNDENEN Client, set_config als erste Anweisung direkt auf tx. + */ +async function runOneUnboundInteractive(prisma, tenantId) { + return prisma.$transaction(async (tx) => { + await tx.$queryRaw`SELECT set_config('app.current_tenant', ${tenantId}, true)`; + const rows = await tx.$queryRaw`SELECT current_tenant_id() AS t`; + return rows[0]?.t; + }); +} + +/** + * Belastet eine Transaktionsform mit `parallelCount` gleichzeitigen Aufrufen, + * abwechselnd fuer TENANT-A und TENANT-B, ueber EINEN gemeinsamen Client — + * also aus demselben endlichen Verbindungsvorrat, so wie im Betrieb. + * + * Als Verletzung zaehlt beides: ein Aufruf, der einen fremden oder gar keinen + * Mandantenkontext sieht, UND ein Aufruf, der abbricht. Der Abbruch ist hier + * kein Werkzeugfehler, sondern das Messergebnis — genau deshalb wird er + * gefangen und gezaehlt statt nach oben durchgereicht. + */ +async function measureUnderLoad(scratchRoleUrl, runOne, parallelCount) { + const prisma = new PrismaClient({ datasourceUrl: scratchRoleUrl }); + try { + const calls = Array.from({ length: parallelCount }, (_, i) => { + const tenantId = i % 2 === 0 ? 'TENANT-A' : 'TENANT-B'; + return runOne(prisma, tenantId).then( + (seen) => (seen === tenantId ? null : `erwartet ${tenantId}, gesehen ${seen ?? 'NULL'}`), + (err) => `abbruch ${err.code ?? ''}: ${String(err.message).split('\n')[0]}`.trim(), + ); + }); + const verletzungen = (await Promise.all(calls)).filter(Boolean); + return { + aufrufe: parallelCount, + verletzungen: verletzungen.length, + beispiele: verletzungen.slice(0, 2), + }; + } finally { + await prisma.$disconnect(); + } +} + +/** + * Die Lastprobe hinter der Entscheidung fuer Form (iii). + * + * Sie existierte zunaechst nur als Fliesstext im Kopf von + * `prisma-tenant.extension.ts` — eine Zahl, die eine Entscheidung trug, aber + * nicht nachvollziehbar war. Genau das Anti-Muster, das dieses Projekt sich + * selbst verboten hat. Deshalb steht sie jetzt hier. + * + * GEPRUEFT wird nur die Eigenschaft, auf die sich der Code stuetzt: die + * GEWAEHLTE Form ueberlebt Nebenlaeufigkeit ohne Verletzung. Das Verhalten von + * Form (ii) wird daneben BEOBACHTET und ausgedruckt, aber nicht gepruft — + * ob und ab welcher Last sie abbricht, haengt an Verbindungsvorrat und + * Maschine und taugt nicht als Bedingung fuer einen gruenen Lauf. + */ +async function runConcurrencyProbe(scratchRoleUrl, results) { + const PARALLEL = 40; + + const formZwei = await measureUnderLoad(scratchRoleUrl, runOneBoundInteractive, PARALLEL); + beobachte(`Form (ii) unter ${PARALLEL} parallelen Aufrufen`, formZwei); + + const formDrei = await measureUnderLoad(scratchRoleUrl, runOneUnboundInteractive, PARALLEL); + beobachte(`Form (iii) unter ${PARALLEL} parallelen Aufrufen`, formDrei); + + report( + results, + 'gewaehlte-transaktionsform-uebersteht-nebenlaeufigkeit', + formDrei.verletzungen === 0, + `Form (iii): ${formDrei.verletzungen} Verletzung(en) bei ${PARALLEL} parallelen Aufrufen` + + ` — Form (ii) zum Vergleich, nicht gepruft: ${formZwei.verletzungen}` + + (formZwei.beispiele.length ? ` (z.B. ${formZwei.beispiele[0]})` : ''), + ); +} + async function main() { const adminUrl = parseAdminUrl(); const results = []; @@ -953,6 +1041,7 @@ async function main() { await runLdapAreaChecks(adminUrl, scratchRoleUrlString, results); await runGroupsAreaChecks(adminUrl, scratchRoleUrlString, results); await runTransactionShapeMeasurement(scratchRoleUrlString, results); + await runConcurrencyProbe(scratchRoleUrlString, results); } finally { console.log(`Raeume Wegwerf-Datenbank "${SCRATCH_DB_NAME}" ab...`); await teardownScratchDatabase(adminUrl); diff --git a/apps/api/src/prisma/prisma-tenant.extension.ts b/apps/api/src/prisma/prisma-tenant.extension.ts index 64155ee..11c6176 100644 --- a/apps/api/src/prisma/prisma-tenant.extension.ts +++ b/apps/api/src/prisma/prisma-tenant.extension.ts @@ -75,13 +75,9 @@ import { PrismaClient } from '@prisma/client'; * der aeusseren Transaktion ist nicht mehr gegeben. * Form (ii) — interaktive Callback-Form auf dem gebundenen Client * (`forTenant(prisma, tenantId).$transaction(async (tx) => ...)`): - * bestand die Einzelmessung (gleiche Verbindung, richtiger - * Kontext, richtige Zeilenzahl), brach aber unter einer - * zusaetzlichen Lastprobe (40 parallele Aufrufe, - * alternierend TENANT-A/TENANT-B) mit - * `PrismaClientKnownRequestError: Transaction API error: - * Unable to start a transaction in the given time.` (P2028) - * ab — jeder `tx.$queryRaw`-Aufruf innerhalb der interaktiven + * besteht die Einzelmessung (gleiche Verbindung, richtiger + * Kontext, richtige Zeilenzahl), faellt aber unter Last aus. + * Ursache: jeder `tx`-Aufruf innerhalb der interaktiven * Transaktion loest selbst wieder eine VERSCHACHTELTE * Array-Transaktion aus (weil `$allOperations` bei jedem * Aufruf erneut feuert), und die aeussere plus jede innere @@ -89,10 +85,21 @@ import { PrismaClient } from '@prisma/client'; * demselben, endlichen Pool. * Form (iii) — interaktive Callback-Form auf dem UNGEBUNDENEN Client, * `set_config` als ERSTE Anweisung direkt auf `tx` (nicht auf - * dem aeusseren Client): bestand sowohl die Einzelmessung als - * auch die Lastprobe (0 Verletzungen unter 40 parallelen - * Aufrufen) — sie belegt pro Aufruf genau EINE Verbindung, - * ohne Verschachtelung. + * dem aeusseren Client): besteht Einzelmessung UND Lastprobe — + * sie belegt pro Aufruf genau EINE Verbindung, ohne + * Verschachtelung. + * + * Die Lastprobe steht als `runConcurrencyProbe` in + * `apps/api/scripts/rls-scratch-check.mjs` und ist jederzeit wiederholbar: + * 40 gleichzeitige Aufrufe ueber EINEN Client, abwechselnd fuer zwei + * Mandanten. Gepruft wird nur die Eigenschaft, auf die dieser Code sich + * stuetzt — Form (iii) ohne Verletzung; Form (ii) laeuft daneben als + * ausgedruckte Beobachtung mit, weil ihr Bruchpunkt an Verbindungsvorrat + * und Maschine haengt und deshalb keine Bedingung fuer einen gruenen Lauf + * sein darf. Die konkreten Zahlen eines Laufs stehen daher NICHT hier, + * sondern fallen bei jeder Ausfuehrung neu an. Diese Probe wurde + * nachgereicht: die Aussage stand zunaechst nur als Fliesstext hier, ohne + * dass sie jemand haette nachvollziehen koennen. * * Entscheidung: `withTenantTransaction()` unten baut Form (iii) nach und * ist das Hilfsmittel fuer alle mehrschrittigen, mandantengebundenen diff --git a/docs/mandantentrennung-etappe2-fehlerrichtung.md b/docs/mandantentrennung-etappe2-fehlerrichtung.md index f3689af..bef5677 100644 --- a/docs/mandantentrennung-etappe2-fehlerrichtung.md +++ b/docs/mandantentrennung-etappe2-fehlerrichtung.md @@ -236,13 +236,23 @@ Einzelmessung (gleiche Verbindungskennung, korrekter Kontext, korrekte Zeilenzahl). Über diese Einzelmessung hinaus wurde als zusätzliche, sicherheitsrelevante Sorgfaltsprüfung (nicht durch den Plan verlangt, aber durch die Tragweite dieses Bereichs geboten) beide Formen unter echter -Nebenläufigkeit erneut gemessen — 40 parallele Aufrufe, alternierend -TENANT-A/TENANT-B, gegen eine separate Experiment-Datenbank mit derselben -Struktur: +Nebenläufigkeit erneut gemessen — 40 parallele Aufrufe über EINEN +gemeinsamen Client, alternierend TENANT-A/TENANT-B. -- **Form (ii)** brach unter dieser Last mit - `PrismaClientKnownRequestError: Transaction API error: Unable to start a - transaction in the given time.` (Code `P2028`) ab. Ursache: jede +Diese Belastungsprobe lief zunächst gegen eine separate Experiment-Datenbank +und war damit **nicht nachvollziehbar** — eine Zahl, die eine Entscheidung +trug, ohne dass jemand sie hätte nachprüfen können. Genau das Anti-Muster, +das dieses Projekt sich selbst verboten hat. Sie ist deshalb als +`runConcurrencyProbe` in `apps/api/scripts/rls-scratch-check.mjs` +nachgereicht worden und läuft seither bei jedem Werkzeuglauf mit. Als +Verletzung zählt beides: ein Aufruf, der einen fremden oder gar keinen +Mandantenkontext sieht, und ein Aufruf, der abbricht. + +Gemessen wird, nicht behauptet: + +- **Form (ii)** bricht unter dieser Last ab, mit Fehlern der Familie + `PrismaClientKnownRequestError: Transaction API error` (Code `P2028`). + Ursache: jede `tx.$queryRaw`-Anweisung innerhalb der interaktiven Transaktion auf dem gebundenen Client löst selbst wieder eine VERSCHACHTELTE Array-Transaktion auf dem äußeren, ungebundenen Client aus (weil @@ -250,9 +260,17 @@ Struktur: interaktive Transaktion UND jede innere Verschachtelung belegen gleichzeitig eine Verbindung aus demselben, endlichen Pool. Unter Last reicht der Pool nicht mehr aus. -- **Form (iii)** bestand dieselbe Belastung mit 0 Verletzungen unter 40 - parallelen Aufrufen — sie belegt pro Aufruf genau eine Verbindung, ohne - Verschachtelung. +- **Form (iii)** besteht dieselbe Belastung ohne Verletzung — sie belegt + pro Aufruf genau eine Verbindung, ohne Verschachtelung. + +Die konkreten Zahlen eines einzelnen Laufs stehen bewusst NICHT in diesem +Dokument, sondern fallen bei jeder Ausführung neu an; der Werkzeuglauf vom +2026-09-09 ergab 24 Verletzungen von 40 für Form (ii) und 0 von 40 für +Form (iii). **Geprüft** wird nur die Eigenschaft, auf die sich der Code +stützt — Form (iii) ohne Verletzung. Das Verhalten von Form (ii) läuft +daneben als ausgedruckte Beobachtung mit und ist bewusst KEINE Bedingung +für einen grünen Lauf: ab welcher Last sie bricht, hängt an +Verbindungsvorrat und Maschine. Das ist der entscheidende Befund für die Werkzeugentscheidung in Aufgabe 2: obwohl Form (ii) die im Plan geforderte EINZELMESSUNG technisch besteht,