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() {
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);
+18 -11
View File
@@ -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