@@ -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