@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@squiz/job-runner-lib",
3
- "version": "2.8.0",
3
+ "version": "2.9.0",
4
4
  "description": "",
5
5
  "main": "lib/index.js",
6
6
  "private": false,
@@ -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;
@@ -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
  });