Skip to content
Merged
86 changes: 75 additions & 11 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,11 +45,13 @@
"@nestjs/websockets": "^11.1.28",
"@prisma/client": "^6.19.3",
"@socket.io/redis-adapter": "^8.3.0",
"@types/compression": "^1.8.1",
"ajv": "^8.17.1",
"bcryptjs": "^2.4.3",
"cache-manager": "^7.2.4",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.0",
"compression": "^1.8.1",
"fs-extra": "^11.3.2",
"ldapts": "^7.1.0",
"looks-same": "^9.0.0",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
-- CreateIndex
-- Single statement on purpose: Prisma runs one-statement migrations outside a
-- transaction, which CREATE INDEX CONCURRENTLY requires. Concurrent build
-- avoids write-locking TestRun on large production tables.
--
-- Deliberately without IF NOT EXISTS: a concurrent build that fails leaves an
-- invalid index behind, and skipping it on a retry would let the migration
-- succeed while the index stays unusable. Drop the invalid index, then retry.
CREATE INDEX CONCURRENTLY "TestRun_buildId_branchName_name_idx" ON "TestRun"("buildId", "branchName", "name");
2 changes: 2 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,8 @@ model TestRun {
build Build @relation(fields: [buildId], references: [id])
project Project? @relation(fields: [projectId], references: [id])
testVariation TestVariation? @relation(fields: [testVariationId], references: [id])

@@index([buildId, branchName, name])
}

model TestVariation {
Expand Down
55 changes: 55 additions & 0 deletions src/builds/build-stats.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { TestStatus } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';

export interface BuildStats {
passedCount: number;
unresolvedCount: number;
failedCount: number;
merge: boolean;
}

const PASSED_STATUSES: TestStatus[] = [TestStatus.ok, TestStatus.approved, TestStatus.autoApproved];
const UNRESOLVED_STATUSES: TestStatus[] = [TestStatus.unresolved, TestStatus.new];

/**
* Aggregates test-run statistics per build inside the database instead of
* loading every test-run row into the API process: builds can hold thousands
* of runs and these stats are recomputed on every build_updated event burst
* during ingestion.
*/
export async function getBuildsStats(prisma: PrismaService, buildIds: string[]): Promise<Map<string, BuildStats>> {
const stats = new Map<string, BuildStats>(
buildIds.map((id) => [id, { passedCount: 0, unresolvedCount: 0, failedCount: 0, merge: false }])
);
if (buildIds.length === 0) {
return stats;
}

const [statusCounts, mergeBuilds] = await Promise.all([
prisma.testRun.groupBy({
by: ['buildId', 'status'],
where: { buildId: { in: buildIds } },
_count: { _all: true },
}),
prisma.testRun.groupBy({
by: ['buildId'],
where: { buildId: { in: buildIds }, merge: true },
}),
]);

for (const row of statusCounts) {
const buildStats = stats.get(row.buildId);
const count = row._count._all;
if (PASSED_STATUSES.includes(row.status)) {
buildStats.passedCount += count;
} else if (UNRESOLVED_STATUSES.includes(row.status)) {
buildStats.unresolvedCount += count;
} else if (row.status === TestStatus.failed) {
buildStats.failedCount += count;
}
}
for (const row of mergeBuilds) {
stats.get(row.buildId).merge = true;
}
return stats;
}
21 changes: 18 additions & 3 deletions src/builds/builds.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ const initService = async ({
testRunDeleteMock = jest.fn(),
testRunApproveMock = jest.fn(),
testRunFindManyMock = jest.fn(),
testRunGroupByMock = jest.fn().mockResolvedValue([]),
eventsBuildUpdatedMock = jest.fn(),
eventsBuildCreatedMock = jest.fn(),
eventBuildDeletedMock = jest.fn(),
Expand All @@ -47,6 +48,9 @@ const initService = async ({
upsert: buildUpsertMock,
count: buildCountMock,
},
testRun: {
groupBy: testRunGroupByMock,
},
},
},
{
Expand Down Expand Up @@ -115,13 +119,24 @@ describe('BuildsService', () => {

it('findOne', async () => {
const buildFindUniqueMock = jest.fn().mockResolvedValueOnce(build);
const testRunFindManyMock = jest.fn().mockResolvedValueOnce(build.testRuns);
const testRunGroupByMock = jest
.fn()
.mockResolvedValueOnce([
{ buildId: 'someId', status: TestStatus.ok, _count: { _all: 2 } },
{ buildId: 'someId', status: TestStatus.unresolved, _count: { _all: 1 } },
])
.mockResolvedValueOnce([{ buildId: 'someId' }]);
mocked(BuildDto).mockReturnValueOnce(buildDto as MockedObject<BuildDto>);
service = await initService({ buildFindUniqueMock, testRunFindManyMock });
service = await initService({ buildFindUniqueMock, testRunGroupByMock });

const result = await service.findOne('someId');

expect(mocked(BuildDto)).toHaveBeenCalledWith({ ...build, testRuns: build.testRuns });
expect(mocked(BuildDto)).toHaveBeenCalledWith(build, {
passedCount: 2,
unresolvedCount: 1,
failedCount: 0,
merge: true,
});
expect(result).toBe(buildDto);
});

Expand Down
10 changes: 4 additions & 6 deletions src/builds/builds.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { Build, Prisma, TestStatus } from '@prisma/client';
import { TestRunsService } from '../test-runs/test-runs.service';
import { EventsGateway } from '../shared/events/events.gateway';
import { BuildDto } from './dto/build.dto';
import { getBuildsStats } from './build-stats';
import { PaginatedBuildDto } from './dto/build-paginated.dto';
import { ModifyBuildDto } from './dto/build-modify.dto';

Expand All @@ -20,16 +21,13 @@ export class BuildsService {
) {}

async findOne(id: string): Promise<BuildDto> {
const [build, testRuns] = await Promise.all([
const [build, stats] = await Promise.all([
this.prismaService.build.findUnique({
where: { id },
}),
this.testRunsService.findMany(id),
getBuildsStats(this.prismaService, [id]),
]);
return new BuildDto({
...build,
testRuns,
});
return new BuildDto(build, stats.get(id));
}

async findMany(projectId: string, take: number, skip: number, ciBuildId?: string): Promise<PaginatedBuildDto> {
Expand Down
Loading
Loading