fix(quick-260909-jts): Lastprobe nachreichen statt sie zu behaupten

This commit is contained in:
2026-09-09 15:18:55 +02:00
parent abb6c8bea3
commit 604428a91d
3 changed files with 134 additions and 20 deletions
+89
View File
@@ -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() { async function main() {
const adminUrl = parseAdminUrl(); const adminUrl = parseAdminUrl();
const results = []; const results = [];
@@ -953,6 +1041,7 @@ async function main() {
await runLdapAreaChecks(adminUrl, scratchRoleUrlString, results); await runLdapAreaChecks(adminUrl, scratchRoleUrlString, results);
await runGroupsAreaChecks(adminUrl, scratchRoleUrlString, results); await runGroupsAreaChecks(adminUrl, scratchRoleUrlString, results);
await runTransactionShapeMeasurement(scratchRoleUrlString, results); await runTransactionShapeMeasurement(scratchRoleUrlString, results);
await runConcurrencyProbe(scratchRoleUrlString, results);
} finally { } finally {
console.log(`Raeume Wegwerf-Datenbank "${SCRATCH_DB_NAME}" ab...`); console.log(`Raeume Wegwerf-Datenbank "${SCRATCH_DB_NAME}" ab...`);
await teardownScratchDatabase(adminUrl); await teardownScratchDatabase(adminUrl);
+18 -11
View File
@@ -75,13 +75,9 @@ import { PrismaClient } from '@prisma/client';
* der aeusseren Transaktion ist nicht mehr gegeben. * der aeusseren Transaktion ist nicht mehr gegeben.
* Form (ii) — interaktive Callback-Form auf dem gebundenen Client * Form (ii) — interaktive Callback-Form auf dem gebundenen Client
* (`forTenant(prisma, tenantId).$transaction(async (tx) => ...)`): * (`forTenant(prisma, tenantId).$transaction(async (tx) => ...)`):
* bestand die Einzelmessung (gleiche Verbindung, richtiger * besteht die Einzelmessung (gleiche Verbindung, richtiger
* Kontext, richtige Zeilenzahl), brach aber unter einer * Kontext, richtige Zeilenzahl), faellt aber unter Last aus.
* zusaetzlichen Lastprobe (40 parallele Aufrufe, * Ursache: jeder `tx`-Aufruf innerhalb der interaktiven
* 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
* Transaktion loest selbst wieder eine VERSCHACHTELTE * Transaktion loest selbst wieder eine VERSCHACHTELTE
* Array-Transaktion aus (weil `$allOperations` bei jedem * Array-Transaktion aus (weil `$allOperations` bei jedem
* Aufruf erneut feuert), und die aeussere plus jede innere * Aufruf erneut feuert), und die aeussere plus jede innere
@@ -89,10 +85,21 @@ import { PrismaClient } from '@prisma/client';
* demselben, endlichen Pool. * demselben, endlichen Pool.
* Form (iii) — interaktive Callback-Form auf dem UNGEBUNDENEN Client, * Form (iii) — interaktive Callback-Form auf dem UNGEBUNDENEN Client,
* `set_config` als ERSTE Anweisung direkt auf `tx` (nicht auf * `set_config` als ERSTE Anweisung direkt auf `tx` (nicht auf
* dem aeusseren Client): bestand sowohl die Einzelmessung als * dem aeusseren Client): besteht Einzelmessung UND Lastprobe —
* auch die Lastprobe (0 Verletzungen unter 40 parallelen * sie belegt pro Aufruf genau EINE Verbindung, ohne
* Aufrufen) — sie belegt pro Aufruf genau EINE Verbindung, * Verschachtelung.
* 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 * Entscheidung: `withTenantTransaction()` unten baut Form (iii) nach und
* ist das Hilfsmittel fuer alle mehrschrittigen, mandantengebundenen * ist das Hilfsmittel fuer alle mehrschrittigen, mandantengebundenen
@@ -236,13 +236,23 @@ Einzelmessung (gleiche Verbindungskennung, korrekter Kontext, korrekte
Zeilenzahl). Über diese Einzelmessung hinaus wurde als zusätzliche, Zeilenzahl). Über diese Einzelmessung hinaus wurde als zusätzliche,
sicherheitsrelevante Sorgfaltsprüfung (nicht durch den Plan verlangt, aber sicherheitsrelevante Sorgfaltsprüfung (nicht durch den Plan verlangt, aber
durch die Tragweite dieses Bereichs geboten) beide Formen unter echter durch die Tragweite dieses Bereichs geboten) beide Formen unter echter
Nebenläufigkeit erneut gemessen — 40 parallele Aufrufe, alternierend Nebenläufigkeit erneut gemessen — 40 parallele Aufrufe über EINEN
TENANT-A/TENANT-B, gegen eine separate Experiment-Datenbank mit derselben gemeinsamen Client, alternierend TENANT-A/TENANT-B.
Struktur:
- **Form (ii)** brach unter dieser Last mit Diese Belastungsprobe lief zunächst gegen eine separate Experiment-Datenbank
`PrismaClientKnownRequestError: Transaction API error: Unable to start a und war damit **nicht nachvollziehbar** — eine Zahl, die eine Entscheidung
transaction in the given time.` (Code `P2028`) ab. Ursache: jede 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 `tx.$queryRaw`-Anweisung innerhalb der interaktiven Transaktion auf dem
gebundenen Client löst selbst wieder eine VERSCHACHTELTE gebundenen Client löst selbst wieder eine VERSCHACHTELTE
Array-Transaktion auf dem äußeren, ungebundenen Client aus (weil Array-Transaktion auf dem äußeren, ungebundenen Client aus (weil
@@ -250,9 +260,17 @@ Struktur:
interaktive Transaktion UND jede innere Verschachtelung belegen interaktive Transaktion UND jede innere Verschachtelung belegen
gleichzeitig eine Verbindung aus demselben, endlichen Pool. Unter Last gleichzeitig eine Verbindung aus demselben, endlichen Pool. Unter Last
reicht der Pool nicht mehr aus. reicht der Pool nicht mehr aus.
- **Form (iii)** bestand dieselbe Belastung mit 0 Verletzungen unter 40 - **Form (iii)** besteht dieselbe Belastung ohne Verletzung — sie belegt
parallelen Aufrufen — sie belegt pro Aufruf genau eine Verbindung, ohne pro Aufruf genau eine Verbindung, ohne Verschachtelung.
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: Das ist der entscheidende Befund für die Werkzeugentscheidung in Aufgabe 2:
obwohl Form (ii) die im Plan geforderte EINZELMESSUNG technisch besteht, obwohl Form (ii) die im Plan geforderte EINZELMESSUNG technisch besteht,