Skip to content
This repository was archived by the owner on Sep 13, 2023. It is now read-only.

Commit c0e3eab

Browse files
Added timeout for long running landkid builds. (#202)
* Added timeout to fail long running landkid builds using LAND_BUILD_TIMEOUT_TIME * Triggerd BB API to retrieve land build status * Added tests * Fix compilation issue caused by adding root express mock Co-authored-by: Grace <[email protected]> Co-authored-by: Michael Blaszczyk <[email protected]>
1 parent 9eab9b3 commit c0e3eab

File tree

8 files changed

+221
-1
lines changed

8 files changed

+221
-1
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
"scripts": {
1919
"clean": "rimraf dist",
2020
"test:unit": "jest",
21+
"test:unit:watch": "jest --watch",
2122
"format": "prettier --write src/**/*.ts src/**/*.tsx",
2223
"build": "yarn clean && yarn build:server && yarn build:ui",
2324
"build:server": "tsc",

src/bitbucket/BitbucketClient.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,10 @@ export class BitbucketClient {
131131
return this.pipelines.stopLandBuild(buildId, lockId);
132132
}
133133

134+
getLandBuild(buildId: number) {
135+
return this.pipelines.getLandBuild(buildId);
136+
}
137+
134138
mergePullRequest(landRequestStatus: LandRequestStatus, options?: MergeOptions) {
135139
return this.bitbucket.mergePullRequest(landRequestStatus, options);
136140
}

src/bitbucket/BitbucketPipelinesAPI.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,4 +146,20 @@ export class BitbucketPipelinesAPI {
146146
}
147147
return true;
148148
};
149+
150+
getLandBuild = async (buildId: Number): Promise<BB.Pipeline> => {
151+
const endpoint = `${this.apiBaseUrl}/pipelines/${buildId}`;
152+
const { data } = await axios.get<BB.Pipeline>(
153+
endpoint,
154+
await bitbucketAuthenticator.getAuthConfig(fromMethodAndUrl('get', endpoint)),
155+
);
156+
157+
Logger.info('Successfully loaded land build data', {
158+
namespace: 'bitbucket:api:getLandBuild',
159+
state: data.state,
160+
buildId,
161+
});
162+
163+
return data;
164+
};
149165
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import axios from 'axios';
2+
import { BitbucketPipelinesAPI } from '../BitbucketPipelinesAPI';
3+
import { bitbucketAuthenticator } from '../BitbucketAuthenticator';
4+
5+
jest.mock('axios');
6+
const mockedAxios = axios as unknown as jest.Mocked<typeof axios>;
7+
8+
const bitbucketPipelineAPI = new BitbucketPipelinesAPI({
9+
repoName: 'repo',
10+
repoOwner: 'owner',
11+
});
12+
13+
describe('BitbucketPipelinesAPI', () => {
14+
beforeEach(() => {
15+
jest.resetAllMocks();
16+
jest.spyOn(bitbucketAuthenticator, 'getAuthConfig').mockResolvedValue({});
17+
});
18+
19+
test(`getLandBuild > should return land build data`, async () => {
20+
const response = {
21+
data: {
22+
state: {
23+
result: {
24+
name: 'FAILED',
25+
},
26+
},
27+
},
28+
};
29+
mockedAxios.get.mockResolvedValue(response);
30+
31+
expect(await bitbucketPipelineAPI.getLandBuild(123)).toBe(response.data);
32+
expect(mockedAxios.get).toBeCalledWith(
33+
'https://api.bitbucket.org/2.0/repositories/owner/repo/pipelines/123',
34+
{},
35+
);
36+
});
37+
});

src/bitbucket/types.d.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,4 +150,8 @@ declare namespace BB {
150150
createdOn: Date;
151151
url: string;
152152
};
153+
154+
type Pipeline = {
155+
state: { result: { name: BuildState } };
156+
};
153157
}

src/lib/Runner.ts

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ import { BitbucketAPI } from '../bitbucket/BitbucketAPI';
2525
// 3 hours should be long enough to complete it now that we only fetch requests within 7 days (about 20 waiting requests)
2626
const MAX_CHECK_WAITING_REQUESTS_TIME = 1000 * 60 * 60 * 3; // 3 hours
2727

28+
const LAND_BUILD_TIMEOUT_TIME = 1000 * 60 * 60 * 2; // 2 hours
29+
2830
export class Runner {
2931
constructor(
3032
public queue: LandRequestQueue,
@@ -46,6 +48,11 @@ export class Runner {
4648
setInterval(() => {
4749
this.next();
4850
}, 15 * 1000); // 15s
51+
52+
// call checkRunningLandRequests() function on a interval of 10 min to verify LAND_BUILD_TIMEOUT_TIME
53+
setInterval(() => {
54+
this.checkRunningLandRequests();
55+
}, 10 * 60 * 1000);
4956
}
5057

5158
getMaxConcurrentBuilds = () =>
@@ -349,7 +356,6 @@ export class Runner {
349356
// checking the rest of the queue
350357
if (didChangeState) return true;
351358
}
352-
// otherwise, we must just be running, nothing to do here
353359
}
354360
return false;
355361
},
@@ -630,6 +636,57 @@ export class Runner {
630636
);
631637
};
632638

639+
// this check prevents the system from being hung if BB webhook doesn't work as expected
640+
checkRunningLandRequests = async () => {
641+
await withLock(
642+
// this lock ensures we don't run multiple checks at the same time that might cause a race condition
643+
'check-running-requests',
644+
async () => {
645+
await withLock(
646+
// this lock ensures we don't run the check when we're running this.next()
647+
'status-transition',
648+
async () => {
649+
const requests = await this.queue.getRunning();
650+
const runningRequests = requests.filter((request) => request.state === 'running');
651+
652+
Logger.info('Checking running landrequests for timeout', {
653+
namespace: 'lib:runner:checkRunningLandRequests',
654+
runningRequests,
655+
});
656+
657+
for (const landRequestStatus of runningRequests) {
658+
const timeElapsed = Date.now() - landRequestStatus.date.getTime();
659+
660+
if (timeElapsed > LAND_BUILD_TIMEOUT_TIME) {
661+
const landRequest = landRequestStatus.request;
662+
663+
Logger.warn('Failing running land request as timeout period is breached', {
664+
pullRequestId: landRequest.pullRequestId,
665+
landRequestId: landRequest.id,
666+
namespace: 'lib:runner:checkRunningLandRequests',
667+
});
668+
await landRequest.setStatus('fail', 'Build timeout period breached');
669+
} else {
670+
const { buildId } = landRequestStatus.request;
671+
const { state } = await this.client.getLandBuild(buildId);
672+
673+
// buildStatus can be SUCCESSFUL, FAILED or STOPPED
674+
await this.onStatusUpdate({
675+
buildId,
676+
buildStatus: state.result?.name,
677+
});
678+
}
679+
}
680+
},
681+
undefined,
682+
// release the lock immediately so that this.next() can keep running, set to 100 because ttl needs to be > 0
683+
100,
684+
);
685+
},
686+
undefined,
687+
);
688+
};
689+
633690
getStatusesForLandRequests = async (
634691
requestIds: string[],
635692
): Promise<{ [id: string]: LandRequestStatus[] }> => {

src/lib/__tests__/Runner.test.ts

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -326,6 +326,102 @@ describe('Runner', () => {
326326
});
327327
});
328328

329+
describe('Check running landrequests for timeout', () => {
330+
let onStatusUpdateSpy: jest.SpyInstance;
331+
const getLandRequestStatus = (date: Date) => {
332+
const request = new LandRequest({
333+
created: new Date(123),
334+
forCommit: 'abc',
335+
id: '1',
336+
buildId: 1,
337+
triggererAaid: '123',
338+
pullRequestId: 1,
339+
pullRequest: mockPullRequest,
340+
});
341+
return new LandRequestStatus({
342+
date,
343+
id: '1',
344+
isLatest: true,
345+
request,
346+
requestId: '1',
347+
state: 'running',
348+
});
349+
};
350+
351+
beforeEach(() => {
352+
onStatusUpdateSpy = jest.spyOn(runner, 'onStatusUpdate').mockResolvedValue();
353+
});
354+
355+
afterEach(() => {
356+
onStatusUpdateSpy.mockRestore();
357+
});
358+
359+
test('should get land build status (failed) if timeout period is not breached', async () => {
360+
jest.spyOn(mockClient, 'getLandBuild').mockResolvedValue({
361+
state: {
362+
result: {
363+
name: 'FAILED',
364+
},
365+
},
366+
} as any);
367+
const mockLandRequestStatus = getLandRequestStatus(new Date());
368+
369+
//running state is within the timeout period of 2 hours
370+
mockQueue.getRunning = jest.fn(async () => [mockLandRequestStatus]);
371+
await wait(500);
372+
expect(mockLandRequestStatus.request.setStatus).not.toHaveBeenCalled();
373+
await runner.checkRunningLandRequests();
374+
375+
expect(mockLandRequestStatus.request.setStatus).not.toHaveBeenCalled();
376+
expect(mockClient.getLandBuild).toHaveBeenCalled();
377+
expect(onStatusUpdateSpy).toHaveBeenCalledWith({
378+
buildId: 1,
379+
buildStatus: 'FAILED',
380+
});
381+
});
382+
383+
test('should get land build status (running) if timeout period is not breached', async () => {
384+
jest.spyOn(mockClient, 'getLandBuild').mockResolvedValue({
385+
state: {
386+
stage: {
387+
name: 'RUNNING',
388+
},
389+
},
390+
} as any);
391+
//running state is within the timeout period of 2 hours
392+
const mockLandRequestStatus = getLandRequestStatus(new Date());
393+
394+
await wait(500);
395+
mockQueue.getRunning = jest.fn(async () => [mockLandRequestStatus]);
396+
397+
expect(mockLandRequestStatus.request.setStatus).not.toHaveBeenCalled();
398+
await runner.checkRunningLandRequests();
399+
400+
expect(mockClient.getLandBuild).toHaveBeenCalled();
401+
expect(mockLandRequestStatus.request.setStatus).not.toHaveBeenCalled();
402+
expect(onStatusUpdateSpy).toHaveBeenCalledWith({
403+
buildId: 1,
404+
buildStatus: undefined,
405+
});
406+
});
407+
408+
test('should fail land request if timeout period is breached', async () => {
409+
//running state is beyond the timeout period of 2 hours
410+
const mockLandRequestStatus = getLandRequestStatus(new Date('2022-12-13T03:42:48.071Z'));
411+
412+
mockQueue.getRunning = jest.fn(async () => [mockLandRequestStatus]);
413+
414+
expect(mockLandRequestStatus.request.setStatus).not.toHaveBeenCalled();
415+
await runner.checkRunningLandRequests();
416+
417+
expect(mockLandRequestStatus.request.setStatus).toHaveBeenCalledTimes(1);
418+
expect(mockLandRequestStatus.request.setStatus).toHaveBeenCalledWith(
419+
'fail',
420+
'Build timeout period breached',
421+
);
422+
});
423+
});
424+
329425
describe('moveFromQueueToRunning', () => {
330426
test('should successfully transition land request from queued to running if all checks pass', async () => {
331427
const request = new LandRequest({

tsconfig.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
11
{
22
"extends": "./tsconfig.base.json",
33
"compilerOptions": {
4+
"rootDir": "src",
45
"outDir": "dist",
56
},
7+
"include": [
8+
"./src",
9+
"./typings"
10+
],
611
"exclude": [
712
"node_modules",
813
"tests",

0 commit comments

Comments
 (0)