Skip to content

Commit be235d8

Browse files
authored
Improve build removal and add concurrent deletion control (#333)
* Update builds.service.spec.ts Improve build removal and add concurrent deletion control Enhanced the remove method to handle undefined IDs gracefully and log warnings. Refactored deleteOldBuilds to prevent concurrent deletions for the same project by tracking ongoing operations, ensuring only one deletion runs per project at a time, and added comprehensive tests for these behaviors. * Update workflow.yml
1 parent 70ac5f1 commit be235d8

File tree

3 files changed

+147
-29
lines changed

3 files changed

+147
-29
lines changed

.github/workflows/workflow.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ jobs:
5959
run: docker compose -f docker-compose.yml -f docker-compose.ldap.yml down
6060

6161
- name: SonarCloud Scan
62-
uses: SonarSource/sonarqube-scan-action@v5
62+
uses: SonarSource/sonarqube-scan-action@v6
6363
env:
6464
SONAR_TOKEN: ${{ secrets.SONARCLOUD_TOKEN }}
6565

src/builds/builds.service.spec.ts

Lines changed: 118 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -151,32 +151,128 @@ describe('BuildsService', () => {
151151
});
152152
});
153153

154-
it('delete', async () => {
155-
const buildFindUniqueMock = jest.fn().mockResolvedValueOnce(build);
156-
const buildFindManyMock = jest.fn().mockImplementation(() => Promise.resolve(build));
157-
const buildDeleteMock = jest.fn().mockImplementation(() => Promise.resolve(build));
158-
const testRunDeleteMock = jest.fn();
159-
const eventBuildDeletedMock = jest.fn();
160-
service = await initService({
161-
buildFindUniqueMock,
162-
buildDeleteMock,
163-
testRunDeleteMock,
164-
eventBuildDeletedMock,
165-
buildFindManyMock,
154+
describe('remove', () => {
155+
it('should remove a build successfully', async () => {
156+
const buildFindUniqueMock = jest.fn().mockResolvedValueOnce(build);
157+
const buildFindManyMock = jest.fn().mockImplementation(() => Promise.resolve(build));
158+
const buildDeleteMock = jest.fn().mockImplementation(() => Promise.resolve(build));
159+
const testRunDeleteMock = jest.fn();
160+
const eventBuildDeletedMock = jest.fn();
161+
service = await initService({
162+
buildFindUniqueMock,
163+
buildDeleteMock,
164+
testRunDeleteMock,
165+
eventBuildDeletedMock,
166+
buildFindManyMock,
167+
});
168+
169+
await service.remove(build.id);
170+
171+
expect(buildFindUniqueMock).toHaveBeenCalledWith({
172+
where: { id: build.id },
173+
include: {
174+
testRuns: true,
175+
},
176+
});
177+
expect(testRunDeleteMock).toHaveBeenCalledWith(build.testRuns[0].id);
178+
expect(eventBuildDeletedMock).toHaveBeenCalledWith(new BuildDto(build));
179+
expect(buildDeleteMock).toHaveBeenCalledWith({
180+
where: { id: build.id },
181+
});
166182
});
167183

168-
await service.remove(build.id);
184+
it('should handle undefined ID gracefully', async () => {
185+
const buildFindUniqueMock = jest.fn();
186+
const loggerWarnMock = jest.fn();
187+
service = await initService({ buildFindUniqueMock });
188+
(service as any).logger = { warn: loggerWarnMock }; // Mock the logger
169189

170-
expect(buildFindUniqueMock).toHaveBeenCalledWith({
171-
where: { id: build.id },
172-
include: {
173-
testRuns: true,
174-
},
190+
await service.remove(undefined);
191+
192+
expect(loggerWarnMock).toHaveBeenCalledWith('Attempted to remove build with undefined ID.');
193+
expect(buildFindUniqueMock).not.toHaveBeenCalled();
175194
});
176-
expect(testRunDeleteMock).toHaveBeenCalledWith(build.testRuns[0].id);
177-
expect(eventBuildDeletedMock).toHaveBeenCalledWith(new BuildDto(build));
178-
expect(buildDeleteMock).toHaveBeenCalledWith({
179-
where: { id: build.id },
195+
});
196+
197+
describe('deleteOldBuilds', () => {
198+
const projectId = 'someProjectId';
199+
const build1 = { ...buildDto, id: 'build1', createdAt: new Date(Date.now() - 10000) };
200+
const build2 = { ...buildDto, id: 'build2', createdAt: new Date(Date.now() - 5000) };
201+
202+
it('should delete old builds and keep the specified number', async () => {
203+
const buildFindManyMock = jest.fn().mockResolvedValueOnce([build1, build2]);
204+
const buildCountMock = jest.fn().mockResolvedValueOnce(2);
205+
const buildFindUniqueMock = jest.fn().mockResolvedValueOnce(build1).mockResolvedValueOnce(build2);
206+
mocked(BuildDto)
207+
.mockReturnValueOnce(build1 as MockedObject<BuildDto>)
208+
.mockReturnValueOnce(build2 as MockedObject<BuildDto>);
209+
const testRunFindManyMock = jest.fn().mockResolvedValue([]);
210+
const removeMock = jest.fn().mockResolvedValue(undefined);
211+
const loggerDebugMock = jest.fn();
212+
213+
service = await initService({ buildFindManyMock, buildCountMock, buildFindUniqueMock, testRunFindManyMock });
214+
service.remove = removeMock;
215+
(service as any).logger = { debug: loggerDebugMock };
216+
217+
await service.deleteOldBuilds(projectId, 2);
218+
219+
expect(buildFindManyMock).toHaveBeenCalledWith({
220+
where: { projectId },
221+
take: undefined,
222+
skip: 1,
223+
orderBy: { createdAt: 'desc' },
224+
});
225+
expect(removeMock).toHaveBeenCalledTimes(2);
226+
expect(removeMock).toHaveBeenCalledWith(build1.id);
227+
expect(removeMock).toHaveBeenCalledWith(build2.id);
228+
expect(loggerDebugMock).toHaveBeenCalledWith(
229+
`Starting to delete old builds for project ${projectId}, keeping 2 builds.`
230+
);
231+
expect(loggerDebugMock).toHaveBeenCalledWith(`Finished deleting old builds for project ${projectId}.`);
232+
});
233+
234+
it('should handle concurrent calls for the same project by returning the existing promise', async () => {
235+
const buildFindManyMock = jest.fn().mockResolvedValueOnce([build1, build2]);
236+
const buildCountMock = jest.fn().mockResolvedValueOnce(2);
237+
const removeMock = jest.fn().mockResolvedValue(undefined);
238+
const testRunFindManyMock = jest.fn().mockResolvedValue([]);
239+
const buildFindUniqueMock = jest.fn().mockResolvedValueOnce(build1).mockResolvedValueOnce(build2);
240+
mocked(BuildDto)
241+
.mockReturnValueOnce(build1 as MockedObject<BuildDto>)
242+
.mockReturnValueOnce(build2 as MockedObject<BuildDto>);
243+
const loggerDebugMock = jest.fn();
244+
245+
service = await initService({ buildFindManyMock, buildCountMock, testRunFindManyMock, buildFindUniqueMock });
246+
service.remove = removeMock;
247+
(service as any).logger = { debug: loggerDebugMock };
248+
249+
const promise1 = service.deleteOldBuilds(projectId, 2);
250+
const promise2 = service.deleteOldBuilds(projectId, 2);
251+
252+
expect(promise1).toStrictEqual(promise2);
253+
expect(loggerDebugMock).toHaveBeenCalledWith(
254+
`Deletion for project ${projectId} is already in progress. Returning existing promise.`
255+
);
256+
257+
await promise1; // Wait for the deletion to complete
258+
259+
expect(buildFindManyMock).toHaveBeenCalledTimes(1); // Only called once
260+
expect(removeMock).toHaveBeenCalledTimes(2);
261+
expect(loggerDebugMock).toHaveBeenCalledWith(`Finished deleting old builds for project ${projectId}.`);
262+
});
263+
264+
it('should remove the promise from the map after completion (success)', async () => {
265+
const buildFindManyMock = jest.fn().mockResolvedValueOnce([build1]);
266+
const buildCountMock = jest.fn().mockResolvedValueOnce(1);
267+
const removeMock = jest.fn().mockResolvedValue(undefined);
268+
269+
service = await initService({ buildFindManyMock, buildCountMock });
270+
service.remove = removeMock;
271+
272+
const projectId = 'testProject';
273+
await service.deleteOldBuilds(projectId, 0);
274+
275+
expect(service['ongoingDeletions'].has(projectId)).toBe(false);
180276
});
181277
});
182278

src/builds/builds.service.ts

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { ModifyBuildDto } from './dto/build-modify.dto';
1010
@Injectable()
1111
export class BuildsService {
1212
private readonly logger: Logger = new Logger(BuildsService.name);
13+
private readonly ongoingDeletions = new Map<string, Promise<void>>();
1314

1415
constructor(
1516
private prismaService: PrismaService,
@@ -60,6 +61,10 @@ export class BuildsService {
6061
}
6162

6263
async remove(id: string): Promise<Build> {
64+
if (!id) {
65+
this.logger.warn(`Attempted to remove build with undefined ID.`);
66+
return;
67+
}
6368
this.logger.debug(`Going to remove Build ${id}`);
6469

6570
const build = await this.prismaService.build.findUnique({
@@ -93,13 +98,30 @@ export class BuildsService {
9398
return build;
9499
}
95100

96-
async deleteOldBuilds(projectId: string, keepBuilds: number) {
97-
keepBuilds = keepBuilds < 2 ? keepBuilds : keepBuilds - 1;
98-
this.findMany(projectId, undefined, keepBuilds).then((buildList) => {
99-
buildList.data.forEach((eachBuild) => {
100-
this.remove(eachBuild.id);
101-
});
101+
async deleteOldBuilds(projectId: string, keepBuilds: number): Promise<void> {
102+
if (this.ongoingDeletions.has(projectId)) {
103+
this.logger.debug(`Deletion for project ${projectId} is already in progress. Returning existing promise.`);
104+
return this.ongoingDeletions.get(projectId);
105+
}
106+
107+
const deletionPromise = (async () => {
108+
this.logger.debug(`Starting to delete old builds for project ${projectId}, keeping ${keepBuilds} builds.`);
109+
keepBuilds = keepBuilds < 2 ? keepBuilds : keepBuilds - 1;
110+
const buildList = await this.findMany(projectId, undefined, keepBuilds);
111+
for (const eachBuild of buildList.data) {
112+
await this.remove(eachBuild.id);
113+
}
114+
this.logger.debug(`Finished deleting old builds for project ${projectId}.`);
115+
})();
116+
117+
this.ongoingDeletions.set(projectId, deletionPromise);
118+
119+
deletionPromise.finally(() => {
120+
this.ongoingDeletions.delete(projectId);
121+
this.logger.debug(`Deletion promise for project ${projectId} resolved/rejected and removed from map.`);
102122
});
123+
124+
return deletionPromise;
103125
}
104126

105127
async approve(id: string, merge: boolean): Promise<void> {

0 commit comments

Comments
 (0)