Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions api/src/controllers/bulk-update-load-test.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { Body, Controller, Put, UseGuards, UsePipes, ValidationPipe } from '@nestjs/common';

Check failure on line 1 in api/src/controllers/bulk-update-load-test.controller.ts

View workflow job for this annotation

GitHub Actions / Run linters

Replace `·Body,·Controller,·Put,·UseGuards,·UsePipes,·ValidationPipe·` with `⏎··Body,⏎··Controller,⏎··Put,⏎··UseGuards,⏎··UsePipes,⏎··ValidationPipe,⏎`
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<SuccessDTO> {
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<SuccessDTO> {
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<SuccessDTO> {
return await this.scriptRunnerService.bulkUpdateTransactionLoadTest(dto);
}
}
13 changes: 13 additions & 0 deletions api/src/dtos/script-runner/bulk-update-load-test.dto.ts
Original file line number Diff line number Diff line change
@@ -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;
}
21 changes: 21 additions & 0 deletions api/src/dtos/script-runner/bulk-update-seed.dto.ts
Original file line number Diff line number Diff line change
@@ -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;
}
5 changes: 4 additions & 1 deletion api/src/modules/script-runner.module.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
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';
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: [
Expand All @@ -16,8 +18,9 @@ import { PrismaModule } from './prisma.module';
MultiselectQuestionModule,
PermissionModule,
PrismaModule,
SnapshotCreateModule,
],
controllers: [ScriptRunnerController],
controllers: [ScriptRunnerController, BulkUpdateLoadTestController],
providers: [ScriptRunnerService, Logger],
exports: [ScriptRunnerService],
})
Expand Down
259 changes: 259 additions & 0 deletions api/src/services/script-runner.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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),
) {}
Expand Down Expand Up @@ -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: <API_PASS_KEY>" \
* -H "Content-Type: application/json" \
* -d '{"listingId": "<listingId>", "count": <count>}'
*/
async seedApplicationsForLoadTest(
dto: import('../dtos/script-runner/bulk-update-seed.dto').BulkUpdateSeedDTO,
): Promise<SuccessDTO> {
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: <API_PASS_KEY>" \
* -H "Content-Type: application/json" \
* -d '{"listingId": "<listingId>"}'
*/
async bulkUpdateLoadTest(dto: BulkUpdateLoadTestDTO): Promise<SuccessDTO> {
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: <API_PASS_KEY>" \
* -H "Content-Type: application/json" \
* -d '{"listingId": "<listingId>"}'
*/
async bulkUpdateTransactionLoadTest(
dto: BulkUpdateLoadTestDTO,
): Promise<SuccessDTO> {
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 };
}
}
Loading