@squiz/job-runner-lib 2.8.0 → 2.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +6 -0
- package/lib/domains/job/constants/QueueBacklog.constants.d.ts +11 -0
- package/lib/domains/job/index.d.ts +1 -0
- package/lib/domains/job/repository/JobExecutionRepository/JobExecutionRepository.d.ts +15 -0
- package/lib/index.js +9 -9
- package/lib/index.js.map +4 -4
- package/package.json +1 -1
- package/src/domains/job/constants/QueueBacklog.constants.ts +12 -0
- package/src/domains/job/constants/RuntimeImage.constants.ts +1 -1
- package/src/domains/job/index.ts +1 -0
- package/src/domains/job/repository/JobExecutionRepository/JobExecutionRepository.spec.ts +65 -0
- package/src/domains/job/repository/JobExecutionRepository/JobExecutionRepository.ts +53 -0
- package/src/domains/manifest/utils/validateRuntimeImage.spec.ts +0 -8
package/package.json
CHANGED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/*!
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright Squiz Australia Pty Ltd. All Rights Reserved.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Backlog guard defaults. A job's `begin` is rejected with 422 once it has more than
|
|
8
|
+
* DEFAULT_MAX_QUEUED_EXECUTIONS queued AND its oldest queued execution is older than
|
|
9
|
+
* DEFAULT_MAX_QUEUED_AGE_HOURS. Both conditions are required (large AND stale).
|
|
10
|
+
*/
|
|
11
|
+
export const DEFAULT_MAX_QUEUED_EXECUTIONS = 500;
|
|
12
|
+
export const DEFAULT_MAX_QUEUED_AGE_HOURS = 2;
|
|
@@ -11,7 +11,7 @@ export const LEGACY_RUNTIME_IMAGE = 'node:20';
|
|
|
11
11
|
export const RECOMMENDED_RUNTIME_IMAGE = 'node:24';
|
|
12
12
|
|
|
13
13
|
// Still runnable but rejected on new uploads.
|
|
14
|
-
export const DEPRECATED_RUNTIME_IMAGES: readonly string[] = [];
|
|
14
|
+
export const DEPRECATED_RUNTIME_IMAGES: readonly string[] = ['node:20'];
|
|
15
15
|
|
|
16
16
|
export const getRuntimeImageOrDefault = (image: string | null | undefined): string =>
|
|
17
17
|
image === null || image === undefined ? LEGACY_RUNTIME_IMAGE : image;
|
package/src/domains/job/index.ts
CHANGED
|
@@ -3,6 +3,7 @@ export * from './core/pagination';
|
|
|
3
3
|
|
|
4
4
|
export * from './constants/InjectTokens';
|
|
5
5
|
export * from './constants/JobExecutionStatus.constants';
|
|
6
|
+
export * from './constants/QueueBacklog.constants';
|
|
6
7
|
export * from './constants/Repository.constants';
|
|
7
8
|
export * from './constants/RetryConfig.constants';
|
|
8
9
|
export * from './constants/RuntimeImage.constants';
|
|
@@ -381,6 +381,71 @@ describe('JobExecutionRepository', (): void => {
|
|
|
381
381
|
});
|
|
382
382
|
});
|
|
383
383
|
|
|
384
|
+
describe('getOldestQueuedExecution', () => {
|
|
385
|
+
const jobName = 'backed-up-job';
|
|
386
|
+
|
|
387
|
+
it('queries lsi1 for the oldest queued execution (ascending, limit 1)', async () => {
|
|
388
|
+
const mockJobExecution = createMockJobExecution({ jobName, status: JOB_EXECUTION_STATUS.queued });
|
|
389
|
+
dynamoClient.on(QueryCommand).resolvesOnce({ Items: [createMockJobExecutionDto(mockJobExecution)] });
|
|
390
|
+
|
|
391
|
+
const result = await jobExecutionRepository.getOldestQueuedExecution(jobName);
|
|
392
|
+
|
|
393
|
+
expect(result).toEqual(mockJobExecution);
|
|
394
|
+
expect(dynamoClient).toHaveReceivedCommandWith(QueryCommand, {
|
|
395
|
+
TableName: tableName,
|
|
396
|
+
IndexName: 'lsi1',
|
|
397
|
+
KeyConditionExpression: '#pk = :pk and #lsi1sk = :lsi1sk',
|
|
398
|
+
ExpressionAttributeNames: { '#pk': 'pk', '#lsi1sk': 'lsi1sk' },
|
|
399
|
+
ExpressionAttributeValues: {
|
|
400
|
+
':pk': { S: `${ENTITY_TYPES.jobExecution}~${tenant}` },
|
|
401
|
+
':lsi1sk': { S: `${JOB_EXECUTION_STATUS.queued}~${jobName}` },
|
|
402
|
+
},
|
|
403
|
+
ScanIndexForward: true,
|
|
404
|
+
Limit: 1,
|
|
405
|
+
});
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
it('returns null when there are no queued executions', async () => {
|
|
409
|
+
dynamoClient.on(QueryCommand).resolvesOnce({ Items: [] });
|
|
410
|
+
|
|
411
|
+
const result = await jobExecutionRepository.getOldestQueuedExecution(jobName);
|
|
412
|
+
|
|
413
|
+
expect(result).toBeNull();
|
|
414
|
+
});
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
describe('countQueuedExecutions', () => {
|
|
418
|
+
const jobName = 'backed-up-job';
|
|
419
|
+
|
|
420
|
+
it('counts queued executions on lsi1 bounded by limit', async () => {
|
|
421
|
+
dynamoClient.on(QueryCommand).resolvesOnce({ Count: 501 });
|
|
422
|
+
|
|
423
|
+
const result = await jobExecutionRepository.countQueuedExecutions(jobName, 501);
|
|
424
|
+
|
|
425
|
+
expect(result).toBe(501);
|
|
426
|
+
expect(dynamoClient).toHaveReceivedCommandWith(QueryCommand, {
|
|
427
|
+
TableName: tableName,
|
|
428
|
+
IndexName: 'lsi1',
|
|
429
|
+
Select: 'COUNT',
|
|
430
|
+
KeyConditionExpression: '#pk = :pk and #lsi1sk = :lsi1sk',
|
|
431
|
+
ExpressionAttributeNames: { '#pk': 'pk', '#lsi1sk': 'lsi1sk' },
|
|
432
|
+
ExpressionAttributeValues: {
|
|
433
|
+
':pk': { S: `${ENTITY_TYPES.jobExecution}~${tenant}` },
|
|
434
|
+
':lsi1sk': { S: `${JOB_EXECUTION_STATUS.queued}~${jobName}` },
|
|
435
|
+
},
|
|
436
|
+
Limit: 501,
|
|
437
|
+
});
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
it('returns 0 when DynamoDB returns no count', async () => {
|
|
441
|
+
dynamoClient.on(QueryCommand).resolvesOnce({});
|
|
442
|
+
|
|
443
|
+
const result = await jobExecutionRepository.countQueuedExecutions(jobName, 501);
|
|
444
|
+
|
|
445
|
+
expect(result).toBe(0);
|
|
446
|
+
});
|
|
447
|
+
});
|
|
448
|
+
|
|
384
449
|
describe('update', () => {
|
|
385
450
|
it('Updates the record properties', async () => {
|
|
386
451
|
const jobExecution = createMockJobExecution({ status: JOB_EXECUTION_STATUS.queued });
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
QueryCommand,
|
|
10
10
|
QueryCommandInput,
|
|
11
11
|
QueryCommandOutput,
|
|
12
|
+
Select,
|
|
12
13
|
UpdateItemCommand,
|
|
13
14
|
UpdateItemCommandInput,
|
|
14
15
|
} from '@aws-sdk/client-dynamodb';
|
|
@@ -136,6 +137,58 @@ export class JobExecutionRepository extends Repository<JobExecution, JobExecutio
|
|
|
136
137
|
return results.map((item) => this.convertToItem(item as JobExecutionDto));
|
|
137
138
|
}
|
|
138
139
|
|
|
140
|
+
/**
|
|
141
|
+
* Returns the single oldest queued execution for a job, or null if there are none.
|
|
142
|
+
*
|
|
143
|
+
* Queries the `lsi1` index (`lsi1sk = "<status>~<jobName>"`). All queued executions of one
|
|
144
|
+
* job share the same `lsi1sk`, so DynamoDB breaks ties by the base-table `sk`
|
|
145
|
+
* (`"<jobName>~<id>"`); `id` is prefixed with the ISO `timeQueued`, which sorts
|
|
146
|
+
* lexicographically, so ascending order is chronological and the first item is the oldest.
|
|
147
|
+
*/
|
|
148
|
+
async getOldestQueuedExecution(jobName: string): Promise<JobExecution | null> {
|
|
149
|
+
const res = await this.dynamoDbClient.send(
|
|
150
|
+
new QueryCommand({
|
|
151
|
+
TableName: this.tableName,
|
|
152
|
+
IndexName: 'lsi1',
|
|
153
|
+
KeyConditionExpression: '#pk = :pk and #lsi1sk = :lsi1sk',
|
|
154
|
+
ExpressionAttributeNames: { '#pk': 'pk', '#lsi1sk': 'lsi1sk' },
|
|
155
|
+
ExpressionAttributeValues: {
|
|
156
|
+
':pk': { S: this.pk },
|
|
157
|
+
':lsi1sk': { S: `${JOB_EXECUTION_STATUS.queued}~${jobName}` },
|
|
158
|
+
},
|
|
159
|
+
ScanIndexForward: true,
|
|
160
|
+
Limit: 1,
|
|
161
|
+
}),
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
const item = res.Items?.[0];
|
|
165
|
+
return item ? this.convertToItem(item as JobExecutionDto) : null;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Counts queued executions for a job, evaluating at most `limit` items (`Select: COUNT` so no
|
|
170
|
+
* item data is returned). Pass a `limit` just above the threshold you care about — queued
|
|
171
|
+
* items are small, so a single page covers the bounded count.
|
|
172
|
+
*/
|
|
173
|
+
async countQueuedExecutions(jobName: string, limit?: number): Promise<number> {
|
|
174
|
+
const res = await this.dynamoDbClient.send(
|
|
175
|
+
new QueryCommand({
|
|
176
|
+
TableName: this.tableName,
|
|
177
|
+
IndexName: 'lsi1',
|
|
178
|
+
Select: Select.COUNT,
|
|
179
|
+
KeyConditionExpression: '#pk = :pk and #lsi1sk = :lsi1sk',
|
|
180
|
+
ExpressionAttributeNames: { '#pk': 'pk', '#lsi1sk': 'lsi1sk' },
|
|
181
|
+
ExpressionAttributeValues: {
|
|
182
|
+
':pk': { S: this.pk },
|
|
183
|
+
':lsi1sk': { S: `${JOB_EXECUTION_STATUS.queued}~${jobName}` },
|
|
184
|
+
},
|
|
185
|
+
Limit: limit,
|
|
186
|
+
}),
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
return res.Count ?? 0;
|
|
190
|
+
}
|
|
191
|
+
|
|
139
192
|
/**
|
|
140
193
|
* Get list of records from database.
|
|
141
194
|
* @abstract
|
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
* Copyright Squiz Australia Pty Ltd. All Rights Reserved.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import * as runtimeImageConstants from '../../job/constants/RuntimeImage.constants';
|
|
7
6
|
import {
|
|
8
7
|
DEPRECATED_RUNTIME_IMAGES,
|
|
9
8
|
RECOMMENDED_RUNTIME_IMAGE,
|
|
@@ -13,10 +12,6 @@ import { ManifestValidationError } from '../errors/ManifestValidationError';
|
|
|
13
12
|
import { validateAndDefaultRuntimeImage } from './validateRuntimeImage';
|
|
14
13
|
|
|
15
14
|
describe('validateAndDefaultRuntimeImage', () => {
|
|
16
|
-
afterEach(() => {
|
|
17
|
-
jest.restoreAllMocks();
|
|
18
|
-
});
|
|
19
|
-
|
|
20
15
|
it('returns the recommended runtime image when image is undefined', () => {
|
|
21
16
|
expect(validateAndDefaultRuntimeImage(undefined)).toEqual(RECOMMENDED_RUNTIME_IMAGE);
|
|
22
17
|
});
|
|
@@ -38,9 +33,6 @@ describe('validateAndDefaultRuntimeImage', () => {
|
|
|
38
33
|
});
|
|
39
34
|
|
|
40
35
|
it('throws ManifestValidationError when the image is deprecated', () => {
|
|
41
|
-
jest.spyOn(runtimeImageConstants, 'isSupportedRuntimeImage').mockReturnValue(true);
|
|
42
|
-
jest.spyOn(runtimeImageConstants, 'isDeprecatedRuntimeImage').mockReturnValue(true);
|
|
43
|
-
|
|
44
36
|
expect(() => validateAndDefaultRuntimeImage('node:20')).toThrow(ManifestValidationError);
|
|
45
37
|
expect(() => validateAndDefaultRuntimeImage('node:20')).toThrow(/deprecated/);
|
|
46
38
|
});
|