Skip to content

Commit 2b431e8

Browse files
supertokens-agent-runner[bot]claude
andcommitted
test: two-size stress run with scaling-ratio assertions
Run the measured read-path steps at two dataset sizes — a ~100k-user checkpoint mid-seed and the full 1M — and assert the per-step cost ratio time(1M)/time(100k) against an env-overridable per-class bound (O(1) ≤ 3×, O(n) ≤ 15×). This is hardware-independent: it catches superlinear scaling (the class this suite exists for) that absolute duration budgets can't distinguish from a slow runner. - Split the bulk import into a first ~100k checkpoint tranche and the remainder to 1M (importMillionUsers refactored into reusable helpers). - Extract the read-path steps into runReadPaths(deployment, size), run at both sizes via an ambient checkpoint context: the small pass records only into the ratio harness; the large pass feeds both the existing summary/budget table and the ratio harness. - RatioCollector computes clamped ratios (50ms floor), classifies each step, and fails the run when a ratio exceeds its bound; the globally destructive role-delete step runs only on the large pass and carries no ratio. - stats.json measurements are enriched with small/large/ratio; the workflow summary table gains 100k and ratio columns. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 0c9b06c commit 2b431e8

7 files changed

Lines changed: 505 additions & 131 deletions

File tree

.github/workflows/stress-tests.yml

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -53,9 +53,10 @@ jobs:
5353
exit 0
5454
fi
5555
echo "## Stress Test Results" >> $GITHUB_STEP_SUMMARY
56-
echo "| Test | Duration | Budget | Status |" >> $GITHUB_STEP_SUMMARY
57-
echo "|------|----------|--------|--------|" >> $GITHUB_STEP_SUMMARY
58-
jq -r '.measurements[] | "| \(.title) | \(.formatted) | \(.budgetFormatted // "-") | \(if .overBudget then "⚠️ OVER BUDGET" else "✅ OK" end) |"' stress-tests/stats.json >> $GITHUB_STEP_SUMMARY
56+
echo "Duration is the 1M (large) measurement; the 100k (small) column and the ratio come from the two-size scaling run." >> $GITHUB_STEP_SUMMARY
57+
echo "| Test | Duration (1M) | 100k | Ratio (1M/100k) | Budget | Status |" >> $GITHUB_STEP_SUMMARY
58+
echo "|------|---------------|------|-----------------|--------|--------|" >> $GITHUB_STEP_SUMMARY
59+
jq -r '.measurements[] | "| \(.title) | \(.formatted) | \(.smallFormatted // "-") | \(.ratioText // "-") | \(.budgetFormatted // "-") | \(if .overBudget then "⚠️ OVER BUDGET" else "✅ OK" end) |"' stress-tests/stats.json >> $GITHUB_STEP_SUMMARY
5960
6061
- name: Display pg_stat_statements summary
6162
if: always()

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,11 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
1818
seeding finishes (then resetting) and the steady-state read/query profile at the end — renders per-phase tables (top
1919
statements by total execution time and every statement that spilled to temp) into `stats.json` and the workflow step
2020
summary, and fails the run when a read-phase statement writes more than an env-overridable number of temp blocks
21+
- Test-only: the 1M-user stress-test suite now runs the measured read-path steps at two dataset sizes (a ~100k-user
22+
checkpoint mid-seed and the full 1M) and asserts the per-step cost ratio `time(1M)/time(100k)` against an
23+
env-overridable per-class bound (O(1) steps ≤ 3×, O(n) steps ≤ 15×) — a hardware-independent check for superlinear
24+
scaling that the absolute duration budgets can't catch; the small/large/ratio columns are rendered in the workflow
25+
"Stress Test Results" table and the run fails when a step's ratio exceeds its bound
2126
(default 10k ≈ 80 MB)
2227

2328
## [12.0.8]

stress-tests/src/common/utils.ts

Lines changed: 278 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,12 @@ export const DEFAULT_STEP_BUDGETS_MS: Record<string, number> = {
119119
// Existing seeding steps (kept generous; seeding dominates the run).
120120
'Loading users for bulk import': 1_800_000,
121121
'Waiting for users to be imported': 3_600_000,
122+
// Two-size run: the bulk import is split into a first ~100k checkpoint tranche
123+
// and the remainder to 1M (see the scaling-ratio harness in index.ts).
124+
'Loading users for bulk import (100k checkpoint)': 600_000,
125+
'Waiting for import (100k checkpoint)': 1_800_000,
126+
'Loading users for bulk import (remaining to 1M)': 1_800_000,
127+
'Waiting for import (remaining to 1M)': 3_600_000,
122128
'Emailpassword users creation': 1_800_000,
123129
'Passwordless users (with email) creation': 1_800_000,
124130
'Passwordless users (with phone) creation': 1_800_000,
@@ -211,15 +217,34 @@ export class StatsCollector {
211217
}
212218

213219
public writeToFile(extra: Record<string, unknown> = {}) {
214-
const formattedMeasurements = this.measurements.map((measurement) => ({
215-
title: measurement.title,
216-
ms: measurement.timeMs,
217-
formatted: formatTime(measurement.timeMs),
218-
budgetMs: measurement.budgetMs,
219-
budgetFormatted: formatTime(measurement.budgetMs),
220-
overBudget: measurement.timeMs > measurement.budgetMs,
221-
status: measurement.timeMs > measurement.budgetMs ? 'OVER BUDGET' : 'OK',
222-
}));
220+
const ratios = RatioCollector.getInstance();
221+
const formattedMeasurements = this.measurements.map((measurement) => {
222+
const base = {
223+
title: measurement.title,
224+
ms: measurement.timeMs,
225+
formatted: formatTime(measurement.timeMs),
226+
budgetMs: measurement.budgetMs,
227+
budgetFormatted: formatTime(measurement.budgetMs),
228+
overBudget: measurement.timeMs > measurement.budgetMs,
229+
status: measurement.timeMs > measurement.budgetMs ? 'OVER BUDGET' : 'OK',
230+
};
231+
// Merge in the two-size scaling ratio for steps measured at both sizes so
232+
// the summary table can show small/large/ratio columns per step.
233+
const r = ratios.resultFor(measurement.title);
234+
if (!r) return base;
235+
const ratio = Math.round(r.ratio * 100) / 100;
236+
return {
237+
...base,
238+
scaleClass: r.scaleClass,
239+
smallMs: r.smallMs,
240+
smallFormatted: formatTime(r.smallMs),
241+
ratio,
242+
ratioBound: r.bound,
243+
ratioOverBound: r.overBound,
244+
ratioStatus: r.overBound ? 'OVER RATIO' : 'OK',
245+
ratioText: `${ratio.toFixed(1)}× (≤ ${r.bound}) ${r.overBound ? '⚠️' : '✅'}`,
246+
};
247+
});
223248

224249
const stats = {
225250
measurements: formattedMeasurements,
@@ -257,6 +282,233 @@ export class StatsCollector {
257282
}
258283
}
259284

285+
// ---------------------------------------------------------------------------
286+
// Two-size scaling-ratio harness.
287+
//
288+
// Absolute duration budgets catch big regressions but can't tell a slow runner
289+
// apart from pathological scaling. The read-path steps are therefore measured
290+
// at two dataset sizes — a ~100k-user checkpoint mid-seed ("small") and the
291+
// full 1M ("large") — and the per-step cost ratio time(1M)/time(100k) is
292+
// asserted against a per-class bound. That ratio is hardware-independent: a
293+
// step that is meant to be O(1) in total user count but grows with it shows a
294+
// ratio well above 1, which is exactly the regression class this suite exists
295+
// to catch.
296+
//
297+
// measureTime consults the ambient checkpoint (set by runReadPaths): during the
298+
// small pass it records only into the RatioCollector; during the large pass it
299+
// records into both the StatsCollector (so the existing summary/budget table is
300+
// unchanged) and the RatioCollector; outside any checkpoint (seeding steps) it
301+
// records only into the StatsCollector, as before.
302+
// ---------------------------------------------------------------------------
303+
304+
export type CheckpointSize = 'small' | 'large';
305+
306+
let currentCheckpoint: CheckpointSize | undefined;
307+
308+
export const setCheckpoint = (size: CheckpointSize | undefined): void => {
309+
currentCheckpoint = size;
310+
};
311+
312+
export const getCheckpoint = (): CheckpointSize | undefined => currentCheckpoint;
313+
314+
export type ScaleClass = 'O(1)' | 'O(n)';
315+
316+
/**
317+
* Expected scaling class of each measured read-path step in total user count.
318+
* O(1) steps (single-user lookups/writes, sign-in, first-page pagination, TOTP
319+
* verify) must stay roughly flat as the dataset grows 10x; O(n) steps (counts,
320+
* full pagination walk, analytics aggregates, large-share role listing) may
321+
* grow with the data. Steps not listed default to the lenient O(n) bound.
322+
*/
323+
export const STEP_SCALE_CLASS: Record<string, ScaleClass> = {
324+
'Pagination first page (newest first)': 'O(1)',
325+
'Pagination first page (oldest first)': 'O(1)',
326+
'Pagination full walk (newest first)': 'O(n)',
327+
'Pagination full walk (oldest first)': 'O(n)',
328+
'User count (all tenants)': 'O(n)',
329+
'User count (tenant: public)': 'O(n)',
330+
'Dashboard search by email prefix': 'O(n)',
331+
'Dashboard search by provider': 'O(n)',
332+
'Dashboard search by email + provider': 'O(n)',
333+
'Third-party sign-in for existing user': 'O(1)',
334+
'Email update (linked user)': 'O(1)',
335+
'Phone update (linked user)': 'O(1)',
336+
'Associate linked user to tenant': 'O(1)',
337+
'Disassociate linked user from tenant': 'O(1)',
338+
'canLinkAccounts precheck': 'O(1)',
339+
'Unlink account': 'O(1)',
340+
'Delete user (full, linked)': 'O(1)',
341+
'Active users count': 'O(n)',
342+
'Active users count (with more-than-one-login-method window)': 'O(n)',
343+
'Feature-flag usage stats aggregate': 'O(n)',
344+
'List users for role (large share)': 'O(n)',
345+
'TOTP verify (user with many used codes)': 'O(1)',
346+
'Email-verification status update (mapped user)': 'O(1)',
347+
'Delete user with userid mapping': 'O(1)',
348+
};
349+
350+
/**
351+
* Default per-class ratio bounds. O(1) steps get ~3x headroom over a perfectly
352+
* flat 1.0; O(n) steps get ~15x (10x data plus headroom). Everything is
353+
* env-overridable:
354+
* STRESS_TEST_RATIO_O1_BOUND / STRESS_TEST_RATIO_ON_BOUND - per-class bounds
355+
* STRESS_TEST_RATIO_BOUNDS - JSON object of per-step overrides (by title)
356+
* STRESS_TEST_RATIO_FLOOR_MS - clamp floor before dividing (default 50ms), so
357+
* sub-noise measurements can't manufacture a false ratio
358+
* STRESS_TEST_ENFORCE_RATIOS - "false" to measure + report without failing
359+
*/
360+
export const DEFAULT_RATIO_BOUNDS: Record<ScaleClass, number> = { 'O(1)': 3, 'O(n)': 15 };
361+
export const DEFAULT_RATIO_FLOOR_MS = 50;
362+
363+
const ratioFloorMs = (): number =>
364+
Number(process.env.STRESS_TEST_RATIO_FLOOR_MS ?? String(DEFAULT_RATIO_FLOOR_MS)) ||
365+
DEFAULT_RATIO_FLOOR_MS;
366+
367+
const scaleClassFor = (title: string): ScaleClass => STEP_SCALE_CLASS[title] ?? 'O(n)';
368+
369+
const classBound = (cls: ScaleClass): number => {
370+
const envKey = cls === 'O(1)' ? 'STRESS_TEST_RATIO_O1_BOUND' : 'STRESS_TEST_RATIO_ON_BOUND';
371+
return (
372+
Number(process.env[envKey] ?? String(DEFAULT_RATIO_BOUNDS[cls])) || DEFAULT_RATIO_BOUNDS[cls]
373+
);
374+
};
375+
376+
let cachedRatioOverrides: Record<string, number> | undefined;
377+
378+
const ratioOverrides = (): Record<string, number> => {
379+
if (cachedRatioOverrides) return cachedRatioOverrides;
380+
let overrides: Record<string, number> = {};
381+
const raw = process.env.STRESS_TEST_RATIO_BOUNDS;
382+
if (raw) {
383+
try {
384+
overrides = JSON.parse(raw);
385+
} catch (e) {
386+
console.warn(` Ignoring invalid STRESS_TEST_RATIO_BOUNDS: ${(e as Error).message}`);
387+
}
388+
}
389+
return (cachedRatioOverrides = overrides);
390+
};
391+
392+
const boundFor = (title: string): number => {
393+
const override = ratioOverrides()[title];
394+
if (override !== undefined) return override;
395+
return classBound(scaleClassFor(title));
396+
};
397+
398+
const ratiosEnforced = (): boolean =>
399+
(process.env.STRESS_TEST_ENFORCE_RATIOS ?? 'true').toLowerCase() !== 'false';
400+
401+
export interface RatioResult {
402+
title: string;
403+
scaleClass: ScaleClass;
404+
smallMs: number;
405+
largeMs: number;
406+
floorMs: number;
407+
ratio: number;
408+
bound: number;
409+
overBound: boolean;
410+
}
411+
412+
export class RatioCollector {
413+
private static instance: RatioCollector;
414+
private data: Map<string, { small?: number; large?: number }> = new Map();
415+
416+
private constructor() {}
417+
418+
public static getInstance(): RatioCollector {
419+
if (!RatioCollector.instance) {
420+
RatioCollector.instance = new RatioCollector();
421+
}
422+
return RatioCollector.instance;
423+
}
424+
425+
public record(title: string, size: CheckpointSize, timeMs: number) {
426+
const entry = this.data.get(title) ?? {};
427+
entry[size] = timeMs;
428+
this.data.set(title, entry);
429+
}
430+
431+
/** Ratio result for a single step, or undefined if it lacks both measurements. */
432+
public resultFor(title: string): RatioResult | undefined {
433+
const entry = this.data.get(title);
434+
if (!entry || entry.small === undefined || entry.large === undefined) return undefined;
435+
const floorMs = ratioFloorMs();
436+
const small = Math.max(entry.small, floorMs);
437+
const large = Math.max(entry.large, floorMs);
438+
const bound = boundFor(title);
439+
const ratio = large / small;
440+
return {
441+
title,
442+
scaleClass: scaleClassFor(title),
443+
smallMs: entry.small,
444+
largeMs: entry.large,
445+
floorMs,
446+
ratio,
447+
bound,
448+
overBound: ratio > bound,
449+
};
450+
}
451+
452+
/** Every step that has both a small and a large measurement, sorted by title. */
453+
public results(): RatioResult[] {
454+
return [...this.data.keys()]
455+
.map((title) => this.resultFor(title))
456+
.filter((r): r is RatioResult => r !== undefined)
457+
.sort((a, b) => a.title.localeCompare(b.title));
458+
}
459+
460+
/** Structured payload merged into stats.json alongside the duration measurements. */
461+
public toJSON() {
462+
return {
463+
floorMs: ratioFloorMs(),
464+
bounds: {
465+
'O(1)': classBound('O(1)'),
466+
'O(n)': classBound('O(n)'),
467+
},
468+
results: this.results().map((r) => ({
469+
...r,
470+
smallFormatted: formatTime(r.smallMs),
471+
largeFormatted: formatTime(r.largeMs),
472+
ratio: Math.round(r.ratio * 100) / 100,
473+
status: r.overBound ? 'OVER RATIO' : 'OK',
474+
})),
475+
};
476+
}
477+
478+
/**
479+
* Fails the run if any step's large/small duration ratio exceeded its bound —
480+
* i.e. its per-request cost grew with the database size beyond what its
481+
* scaling class allows. Called at the very end so every step still appears in
482+
* stats.json and the summary table. Honors STRESS_TEST_ENFORCE_RATIOS=false.
483+
*/
484+
public throwIfRatioExceeded() {
485+
const results = this.results();
486+
if (results.length === 0) {
487+
console.log('\nNo two-size measurements captured; skipping scaling-ratio check.');
488+
return;
489+
}
490+
const over = results.filter((r) => r.overBound);
491+
if (over.length === 0) {
492+
console.log('\nAll measured steps scaled within their per-class ratio bounds.');
493+
return;
494+
}
495+
console.error('\nSteps exceeding their scaling-ratio bound:');
496+
for (const r of over) {
497+
console.error(
498+
` ${r.title} [${r.scaleClass}]: ratio ${r.ratio.toFixed(2)} > bound ${r.bound} ` +
499+
`(${formatTime(r.smallMs)} -> ${formatTime(r.largeMs)}, floor ${r.floorMs}ms)`
500+
);
501+
}
502+
if (!ratiosEnforced()) {
503+
console.error('\nSTRESS_TEST_ENFORCE_RATIOS=false — reporting only, not failing the run.');
504+
return;
505+
}
506+
throw new Error(
507+
`${over.length} step(s) exceeded their scaling-ratio bound; per-request cost is growing with database size.`
508+
);
509+
}
510+
}
511+
260512
/**
261513
* Tracks non-OK results produced by the seeding steps. A seeding step that
262514
* silently errors would otherwise still "pass" and invalidate every
@@ -315,7 +567,22 @@ export const measureTime = async <T>(title: string, fn: () => Promise<T>): Promi
315567
const timeMs = et - st;
316568
const budgetMs = getStepBudgetMs(title);
317569
const flag = timeMs > budgetMs ? ' [OVER BUDGET]' : '';
318-
console.log(` ${title} took ${formatTime(timeMs)} (budget ${formatTime(budgetMs)})${flag}`);
319-
StatsCollector.getInstance().addMeasurement(title, timeMs);
570+
const checkpoint = getCheckpoint();
571+
const checkpointTag = checkpoint ? ` [${checkpoint}]` : '';
572+
console.log(
573+
` ${title}${checkpointTag} took ${formatTime(timeMs)} (budget ${formatTime(budgetMs)})${flag}`
574+
);
575+
// Small checkpoint pass: record only into the ratio harness, so the 100k
576+
// measurements neither pollute the 1M summary/budget table nor trip 1M
577+
// budgets. Large pass and un-checkpointed seeding steps record into the
578+
// StatsCollector as before; the large pass additionally feeds the ratio.
579+
if (checkpoint === 'small') {
580+
RatioCollector.getInstance().record(title, 'small', timeMs);
581+
} else {
582+
StatsCollector.getInstance().addMeasurement(title, timeMs);
583+
if (checkpoint === 'large') {
584+
RatioCollector.getInstance().record(title, 'large', timeMs);
585+
}
586+
}
320587
return result;
321588
};

0 commit comments

Comments
 (0)