diff --git a/api/src/controllers/bulk-update-load-test.controller.ts b/api/src/controllers/bulk-update-load-test.controller.ts new file mode 100644 index 00000000000..71981432055 --- /dev/null +++ b/api/src/controllers/bulk-update-load-test.controller.ts @@ -0,0 +1,55 @@ +import { Body, Controller, Put, UseGuards, UsePipes, ValidationPipe } from '@nestjs/common'; +import { ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { ApiKeyGuard } from '../guards/api-key.guard'; +import { ScriptRunnerService } from '../services/script-runner.service'; +import { BulkUpdateLoadTestDTO } from '../dtos/script-runner/bulk-update-load-test.dto'; +import { BulkUpdateSeedDTO } from '../dtos/script-runner/bulk-update-seed.dto'; +import { SuccessDTO } from '../dtos/shared/success.dto'; +import { defaultValidationPipeOptions } from '../utilities/default-validation-pipe-options'; + +@Controller('scriptRunner') +@ApiTags('scriptRunner') +@UsePipes(new ValidationPipe(defaultValidationPipeOptions)) +@UseGuards(ApiKeyGuard) +export class BulkUpdateLoadTestController { + constructor(private readonly scriptRunnerService: ScriptRunnerService) {} + + @Put('seedApplicationsForLoadTest') + @ApiOperation({ + summary: + 'Seeds realistic applications for a listing to use with bulkUpdateLoadTest. Safe to call multiple times.', + operationId: 'seedApplicationsForLoadTest', + }) + @ApiOkResponse({ type: SuccessDTO }) + async seedApplicationsForLoadTest( + @Body() dto: BulkUpdateSeedDTO, + ): Promise { + return await this.scriptRunnerService.seedApplicationsForLoadTest(dto); + } + + @Put('bulkUpdateLoadTest') + @ApiOperation({ + summary: + 'POC load test (Option 2): read → snapshot → write per record. Check server logs for timing output.', + operationId: 'bulkUpdateLoadTest', + }) + @ApiOkResponse({ type: SuccessDTO }) + async bulkUpdateLoadTest( + @Body() dto: BulkUpdateLoadTestDTO, + ): Promise { + return await this.scriptRunnerService.bulkUpdateLoadTest(dto); + } + + @Put('bulkUpdateTransactionLoadTest') + @ApiOperation({ + summary: + 'POC load test (Option 1): bulk read → N snapshots → single $transaction. Check server logs for timing output.', + operationId: 'bulkUpdateTransactionLoadTest', + }) + @ApiOkResponse({ type: SuccessDTO }) + async bulkUpdateTransactionLoadTest( + @Body() dto: BulkUpdateLoadTestDTO, + ): Promise { + return await this.scriptRunnerService.bulkUpdateTransactionLoadTest(dto); + } +} diff --git a/api/src/dtos/script-runner/bulk-update-load-test.dto.ts b/api/src/dtos/script-runner/bulk-update-load-test.dto.ts new file mode 100644 index 00000000000..0be24716fb8 --- /dev/null +++ b/api/src/dtos/script-runner/bulk-update-load-test.dto.ts @@ -0,0 +1,13 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Expose } from 'class-transformer'; +import { IsDefined, IsString, IsUUID } from 'class-validator'; +import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum'; + +export class BulkUpdateLoadTestDTO { + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @IsUUID(4, { groups: [ValidationsGroupsEnum.default] }) + @ApiProperty() + listingId: string; +} diff --git a/api/src/dtos/script-runner/bulk-update-seed.dto.ts b/api/src/dtos/script-runner/bulk-update-seed.dto.ts new file mode 100644 index 00000000000..a03e6b7dd64 --- /dev/null +++ b/api/src/dtos/script-runner/bulk-update-seed.dto.ts @@ -0,0 +1,21 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Expose } from 'class-transformer'; +import { IsDefined, IsInt, IsString, IsUUID, Max, Min } from 'class-validator'; +import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum'; + +export class BulkUpdateSeedDTO { + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @IsUUID(4, { groups: [ValidationsGroupsEnum.default] }) + @ApiProperty() + listingId: string; + + @Expose() + @IsInt({ groups: [ValidationsGroupsEnum.default] }) + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @Min(1, { groups: [ValidationsGroupsEnum.default] }) + @Max(10000, { groups: [ValidationsGroupsEnum.default] }) + @ApiProperty() + count: number; +} diff --git a/api/src/modules/script-runner.module.ts b/api/src/modules/script-runner.module.ts index 315fe57fbad..1532fa226c5 100644 --- a/api/src/modules/script-runner.module.ts +++ b/api/src/modules/script-runner.module.ts @@ -1,5 +1,6 @@ import { Logger, Module } from '@nestjs/common'; import { ScriptRunnerController } from '../controllers/script-runner.controller'; +import { BulkUpdateLoadTestController } from '../controllers/bulk-update-load-test.controller'; import { ScriptRunnerService } from '../services/script-runner.service'; import { AmiChartModule } from './ami-chart.module'; import { FeatureFlagModule } from './feature-flag.module'; @@ -7,6 +8,7 @@ import { EmailModule } from './email.module'; import { MultiselectQuestionModule } from './multiselect-question.module'; import { PermissionModule } from './permission.module'; import { PrismaModule } from './prisma.module'; +import { SnapshotCreateModule } from './snapshot-create.module'; @Module({ imports: [ @@ -16,8 +18,9 @@ import { PrismaModule } from './prisma.module'; MultiselectQuestionModule, PermissionModule, PrismaModule, + SnapshotCreateModule, ], - controllers: [ScriptRunnerController], + controllers: [ScriptRunnerController, BulkUpdateLoadTestController], providers: [ScriptRunnerService, Logger], exports: [ScriptRunnerService], }) diff --git a/api/src/services/script-runner.service.ts b/api/src/services/script-runner.service.ts index 61a46d2c2a6..f52742bd1bf 100644 --- a/api/src/services/script-runner.service.ts +++ b/api/src/services/script-runner.service.ts @@ -5,6 +5,8 @@ import { Inject, Logger, } from '@nestjs/common'; +import { SnapshotCreateService } from './snapshot-create.service'; +import { BulkUpdateLoadTestDTO } from '../dtos/script-runner/bulk-update-load-test.dto'; import { LanguagesEnum, ListingsStatusEnum, @@ -47,6 +49,7 @@ export class ScriptRunnerService { private featureFlagService: FeatureFlagService, private multiselectQuestionService: MultiselectQuestionService, private prisma: PrismaService, + private snapshotCreateService: SnapshotCreateService, @Inject(Logger) private logger = new Logger(ScriptRunnerService.name), ) {} @@ -1517,4 +1520,260 @@ export class ScriptRunnerService { }), ); } + + /** + * Seeds realistic applications (with household members + alternate contact) for load testing. + * Safe to run multiple times — each call adds `count` more applications. + * + * curl -X PUT http://localhost:3100/scriptRunner/seedApplicationsForLoadTest \ + * -H "passkey: " \ + * -H "Content-Type: application/json" \ + * -d '{"listingId": "", "count": }' + */ + async seedApplicationsForLoadTest( + dto: import('../dtos/script-runner/bulk-update-seed.dto').BulkUpdateSeedDTO, + ): Promise { + const { applicationFactory } = await import( + '../../prisma/seed-helpers/application-factory' + ); + const { householdMemberFactoryMany } = await import( + '../../prisma/seed-helpers/household-member-factory' + ); + const { randomInt } = await import('crypto'); + + this.logger.log( + `[BulkUpdateSeed] Seeding ${dto.count} applications for listing ${dto.listingId}`, + ); + + for (let i = 0; i < dto.count; i++) { + const householdSize = randomInt(1, 5); + const householdMembers = await householdMemberFactoryMany( + householdSize - 1, + ); + const appData = await applicationFactory({ + listingId: dto.listingId, + householdMember: householdMembers, + }); + await this.prisma.applications.create({ data: appData }); + + if ((i + 1) % 100 === 0) { + this.logger.log(`[BulkUpdateSeed] Created ${i + 1}/${dto.count}`); + } + } + + this.logger.log( + `[BulkUpdateSeed] Done. Created ${dto.count} applications.`, + ); + return { success: true }; + } + + /** + * Load test for the bulk application update processing loop (Option 2: one-by-one). + * Reads applications in pages, then processes each record: snapshot → write. + * Assumes all records change (worst case). Emails skipped. Check server logs for timing. + * + * curl -X PUT http://localhost:3100/scriptRunner/bulkUpdateLoadTest \ + * -H "passkey: " \ + * -H "Content-Type: application/json" \ + * -d '{"listingId": ""}' + */ + async bulkUpdateLoadTest(dto: BulkUpdateLoadTestDTO): Promise { + const PAGE_SIZE = 500; + const jobStart = Date.now(); + + // Get total count and all IDs upfront for progress tracking + const allIds = await this.prisma.applications.findMany({ + where: { listingId: dto.listingId }, + select: { id: true }, + }); + const totalRecords = allIds.length; + + this.logger.log( + `[BulkUpdateLoadTest] Starting: listingId=${dto.listingId} records=${totalRecords} page_size=${PAGE_SIZE}`, + ); + + let totalReadMs = 0; + let totalSnapshotMs = 0; + let totalWriteMs = 0; + let processedCount = 0; + let page = 0; + + while (processedCount < totalRecords) { + // Paginated bulk read + const readStart = Date.now(); + const apps = await this.prisma.applications.findMany({ + where: { listingId: dto.listingId }, + select: { + id: true, + status: true, + applicationDeclineReason: true, + applicationDeclineReasonAdditionalDetails: true, + manualLotteryPositionNumber: true, + accessibleUnitWaitlistNumber: true, + conventionalUnitWaitlistNumber: true, + submissionDate: true, + applicant: { select: { firstName: true, lastName: true } }, + }, + skip: page * PAGE_SIZE, + take: PAGE_SIZE, + }); + totalReadMs += Date.now() - readStart; + + if (apps.length === 0) break; + + // Process each record in the page one-by-one + for (const app of apps) { + const snapshotStart = Date.now(); + await this.snapshotCreateService.createApplicationSnapshot(app.id); + totalSnapshotMs += Date.now() - snapshotStart; + + const writeStart = Date.now(); + await this.prisma.applications.update({ + where: { id: app.id }, + data: { updatedAt: new Date() }, + }); + totalWriteMs += Date.now() - writeStart; + + processedCount++; + + if (processedCount % 100 === 0) { + const elapsedMs = Date.now() - jobStart; + const recsPerMin = processedCount / (elapsedMs / 1000 / 60); + const estTotalMin = totalRecords / recsPerMin; + this.logger.log( + `[BulkUpdateLoadTest] ${processedCount}/${totalRecords} ` + + `(${Math.round((processedCount / totalRecords) * 100)}%) ` + + `elapsed=${Math.round(elapsedMs / 1000)}s ` + + `rate=${Math.round(recsPerMin)}rec/min ` + + `est_total=${Math.round(estTotalMin)}min`, + ); + } + } + + page++; + } + + const totalMs = Date.now() - jobStart; + this.logger.log( + `[BulkUpdateLoadTest] Done. ` + + `records=${totalRecords} ` + + `total=${Math.round(totalMs / 1000)}s (${Math.round( + totalMs / 1000 / 60, + )}min) ` + + `avg_per_record=${Math.round(totalMs / totalRecords)}ms ` + + `total_read=${totalReadMs}ms (${Math.round( + totalReadMs / totalRecords, + )}ms/rec) ` + + `total_snapshot=${totalSnapshotMs}ms (${Math.round( + totalSnapshotMs / totalRecords, + )}ms/rec) ` + + `total_write=${totalWriteMs}ms (${Math.round( + totalWriteMs / totalRecords, + )}ms/rec)`, + ); + + return { success: true }; + } + + /** + * Load test for Option 1 (single transaction) approach. + * Runs: bulk read → interactive $transaction (snapshot + update per record) → post-tx snapshot read. + * The post-tx snapshot read simulates the cost of fetching change data to construct emails. + * Compare results to bulkUpdateLoadTest (Option 2) to quantify the difference. + * + * curl -X PUT http://localhost:3100/scriptRunner/bulkUpdateTransactionLoadTest \ + * -H "passkey: " \ + * -H "Content-Type: application/json" \ + * -d '{"listingId": ""}' + */ + async bulkUpdateTransactionLoadTest( + dto: BulkUpdateLoadTestDTO, + ): Promise { + const jobStart = Date.now(); + + // Phase 1: bulk read (one query for all records + fields) + const readStart = Date.now(); + const applications = await this.prisma.applications.findMany({ + where: { listingId: dto.listingId }, + select: { + id: true, + status: true, + applicationDeclineReason: true, + applicationDeclineReasonAdditionalDetails: true, + manualLotteryPositionNumber: true, + accessibleUnitWaitlistNumber: true, + conventionalUnitWaitlistNumber: true, + submissionDate: true, + applicant: { select: { firstName: true, lastName: true } }, + }, + }); + const totalReadMs = Date.now() - readStart; + const totalRecords = applications.length; + const applicationIds = applications.map((a) => a.id); + + this.logger.log( + `[BulkUpdateTxLoadTest] Starting: listingId=${dto.listingId} records=${totalRecords} bulk_read=${totalReadMs}ms`, + ); + + // Phase 2: N snapshots before opening the transaction + let totalSnapshotMs = 0; + for (const { id } of applications) { + const snapshotStart = Date.now(); + await this.snapshotCreateService.createApplicationSnapshot(id); + totalSnapshotMs += Date.now() - snapshotStart; + } + + this.logger.log( + `[BulkUpdateTxLoadTest] Snapshots done. ` + + `total=${totalSnapshotMs}ms avg=${Math.round( + totalSnapshotMs / totalRecords, + )}ms`, + ); + + // Phase 3: single transaction with N updates + const txStart = Date.now(); + await this.prisma.$transaction( + applications.map(({ id }) => + this.prisma.applications.update({ + where: { id }, + data: { updatedAt: new Date() }, + }), + ), + ); + const totalTxMs = Date.now() - txStart; + + this.logger.log( + `[BulkUpdateTxLoadTest] Transaction done. tx_total=${totalTxMs}ms`, + ); + + // Phase 3: post-transaction snapshot read — simulates fetching change data to build emails + const snapshotReadStart = Date.now(); + await this.prisma.applicationSnapshot.findMany({ + where: { originalId: { in: applicationIds } }, + select: { + originalId: true, + status: true, + applicationDeclineReason: true, + applicationDeclineReasonAdditionalDetails: true, + manualLotteryPositionNumber: true, + accessibleUnitWaitlistNumber: true, + conventionalUnitWaitlistNumber: true, + }, + orderBy: { createdAt: 'desc' }, + }); + const snapshotReadMs = Date.now() - snapshotReadStart; + + const totalMs = Date.now() - jobStart; + this.logger.log( + `[BulkUpdateTxLoadTest] Done. ` + + `records=${totalRecords} ` + + `total=${Math.round(totalMs / 1000)}s ` + + `bulk_read=${totalReadMs}ms ` + + `transaction=${totalTxMs}ms ` + + `post_tx_snapshot_read=${snapshotReadMs}ms ` + + `avg_per_record=${Math.round(totalMs / totalRecords)}ms`, + ); + + return { success: true }; + } }