@squiz/job-runner-lib 2.9.0 → 2.10.1

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.
Files changed (40) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/lib/domains/job/constants/RuntimeImage.constants.d.ts +3 -4
  3. package/lib/domains/job/constants/RuntimeImage.constants.spec.d.ts +5 -0
  4. package/lib/domains/job/errors/ConflictError.d.ts +14 -0
  5. package/lib/domains/job/errors/Error.constants.d.ts +4 -0
  6. package/lib/domains/job/errors/index.d.ts +1 -0
  7. package/lib/domains/job/model/Base/Base.d.ts +5 -0
  8. package/lib/domains/job/model/JobExecution/JobExecution.d.ts +15 -3
  9. package/lib/domains/job/repository/JobExecutionRepository/JobExecutionRepository.d.ts +3 -6
  10. package/lib/domains/job/utils/ResolveRuntimeImageTaskDefinitionArn/ResolveRuntimeImageTaskDefinitionArn.d.ts +1 -1
  11. package/lib/domains/job/utils/ResolveRuntimeImageTaskDefinitionArn/ResolveRuntimeImageTaskDefinitionArn.spec.d.ts +5 -0
  12. package/lib/domains/job/utils/ResolveRuntimeImageTaskDefinitionArn/ResolveRuntimeImageTaskDefinitionArn.types.d.ts +1 -1
  13. package/lib/index.js +11 -11
  14. package/lib/index.js.map +4 -4
  15. package/package.json +4 -3
  16. package/src/domains/job/constants/RuntimeImage.constants.spec.ts +26 -0
  17. package/src/domains/job/constants/RuntimeImage.constants.ts +5 -8
  18. package/src/domains/job/errors/ConflictError.ts +19 -0
  19. package/src/domains/job/errors/Error.constants.ts +4 -0
  20. package/src/domains/job/errors/index.ts +1 -0
  21. package/src/domains/job/manifest/mocks/JobManifest.mock.ts +2 -2
  22. package/src/domains/job/manifest/mocks/JobManifestDto.mock.ts +2 -2
  23. package/src/domains/job/manifest/mocks/JobManifestRecord.mock.ts +2 -2
  24. package/src/domains/job/manifest/mocks/JobManifestRequest.mock.ts +2 -2
  25. package/src/domains/job/model/Base/Base.ts +6 -0
  26. package/src/domains/job/model/JobExecution/JobExecution.ts +17 -3
  27. package/src/domains/job/model/JobExecution/mocks/JobExecution.mock.ts +1 -0
  28. package/src/domains/job/model/JobExecution/mocks/JobExecutionDto.mock.ts +3 -0
  29. package/src/domains/job/model/JobExecution/mocks/JobExecutionRecord.mock.ts +1 -0
  30. package/src/domains/job/repository/JobExecutionRepository/JobExecutionRepository.spec.ts +77 -16
  31. package/src/domains/job/repository/JobExecutionRepository/JobExecutionRepository.ts +19 -15
  32. package/src/domains/job/repository/JobExecutionRepository/utils/convertDynamoDbItemToJobExecution/convertDynamoDbItemToJobExecution.ts +1 -0
  33. package/src/domains/job/repository/JobManifestRepository/JobManifestRepository.spec.ts +24 -3
  34. package/src/domains/job/repository/JobRepository/JobRepository.spec.ts +24 -3
  35. package/src/domains/job/services/JobExecutionTaskService/JobExecutionTaskService.spec.ts +5 -6
  36. package/src/domains/job/services/JobExecutionTaskService/JobExecutionTaskService.ts +1 -1
  37. package/src/domains/job/utils/ResolveRuntimeImageTaskDefinitionArn/ResolveRuntimeImageTaskDefinitionArn.spec.ts +64 -0
  38. package/src/domains/job/utils/ResolveRuntimeImageTaskDefinitionArn/ResolveRuntimeImageTaskDefinitionArn.ts +2 -5
  39. package/src/domains/job/utils/ResolveRuntimeImageTaskDefinitionArn/ResolveRuntimeImageTaskDefinitionArn.types.ts +1 -1
  40. package/src/domains/manifest/utils/validateRuntimeImage.ts +5 -5
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@squiz/job-runner-lib",
3
- "version": "2.9.0",
3
+ "version": "2.10.1",
4
4
  "description": "",
5
5
  "main": "lib/index.js",
6
6
  "private": false,
@@ -19,7 +19,7 @@
19
19
  "devDependencies": {
20
20
  "@faker-js/faker": "8.0.2",
21
21
  "@smithy/util-stream": "4.5.17",
22
- "@squiz/virus-scanner-lib": "1.68.0",
22
+ "@squiz/virus-scanner-lib": "1.68.1",
23
23
  "@types/cli-color": "2.0.6",
24
24
  "@types/fs-extra": "11.0.1",
25
25
  "@types/jest": "28.1.8",
@@ -34,11 +34,12 @@
34
34
  "@aws-sdk/client-s3": "3.1003.0",
35
35
  "@aws-sdk/client-sqs": "3.1003.0",
36
36
  "@aws-sdk/s3-request-presigner": "3.1003.0",
37
+ "@aws-sdk/util-dynamodb": "3.996.2",
37
38
  "@squiz/dx-common-lib": "1.66.1",
38
39
  "@squiz/dx-json-schema-lib": "1.86.0",
39
40
  "@squiz/dx-logger-lib": "1.64.0",
40
41
  "@squiz/optimization-logger": "1.11.2",
41
- "axios": "1.16.1",
42
+ "axios": "1.19.0",
42
43
  "cli-color": "2.0.4",
43
44
  "fs-extra": "11.1.0",
44
45
  "immer": "10.1.3",
@@ -0,0 +1,26 @@
1
+ /*!
2
+ * @license
3
+ * Copyright Squiz Australia Pty Ltd. All Rights Reserved.
4
+ */
5
+
6
+ import { RECOMMENDED_RUNTIME_IMAGE, getRuntimeImageOrDefault } from './RuntimeImage.constants';
7
+
8
+ describe('RuntimeImage.constants', () => {
9
+ describe('getRuntimeImageOrDefault', () => {
10
+ it('returns the recommended runtime image when image is undefined', () => {
11
+ expect(getRuntimeImageOrDefault(undefined)).toEqual(RECOMMENDED_RUNTIME_IMAGE);
12
+ });
13
+
14
+ it('returns the recommended runtime image when image is null', () => {
15
+ expect(getRuntimeImageOrDefault(null)).toEqual(RECOMMENDED_RUNTIME_IMAGE);
16
+ });
17
+
18
+ it('remaps deprecated node:20 to the recommended runtime image', () => {
19
+ expect(getRuntimeImageOrDefault('node:20')).toEqual(RECOMMENDED_RUNTIME_IMAGE);
20
+ });
21
+
22
+ it('returns node:24 unchanged', () => {
23
+ expect(getRuntimeImageOrDefault('node:24')).toEqual('node:24');
24
+ });
25
+ });
26
+ });
@@ -3,24 +3,21 @@
3
3
  * Copyright Squiz Australia Pty Ltd. All Rights Reserved.
4
4
  */
5
5
 
6
- export const SUPPORTED_RUNTIME_IMAGES = ['node:20', 'node:24'] as const;
7
-
8
- // Default for jobs that pre-date runtime image support.
9
- export const LEGACY_RUNTIME_IMAGE = 'node:20';
6
+ export const SUPPORTED_RUNTIME_IMAGES = ['node:24'] as const;
10
7
 
11
8
  export const RECOMMENDED_RUNTIME_IMAGE = 'node:24';
12
9
 
13
- // Still runnable but rejected on new uploads.
10
+ // Stored on old job records; remapped to RECOMMENDED_RUNTIME_IMAGE at read/execute time. Rejected on new uploads.
14
11
  export const DEPRECATED_RUNTIME_IMAGES: readonly string[] = ['node:20'];
15
12
 
13
+ export const isDeprecatedRuntimeImage = (image: string): boolean => DEPRECATED_RUNTIME_IMAGES.includes(image);
14
+
16
15
  export const getRuntimeImageOrDefault = (image: string | null | undefined): string =>
17
- image === null || image === undefined ? LEGACY_RUNTIME_IMAGE : image;
16
+ image === null || image === undefined || isDeprecatedRuntimeImage(image) ? RECOMMENDED_RUNTIME_IMAGE : image;
18
17
 
19
18
  export const isSupportedRuntimeImage = (image: string): boolean =>
20
19
  (SUPPORTED_RUNTIME_IMAGES as readonly string[]).includes(image);
21
20
 
22
- export const isDeprecatedRuntimeImage = (image: string): boolean => DEPRECATED_RUNTIME_IMAGES.includes(image);
23
-
24
21
  export const getRuntimeImagesMetadata = () => ({
25
22
  supportedRuntimeImages: [...SUPPORTED_RUNTIME_IMAGES],
26
23
  deprecatedRuntimeImages: [...DEPRECATED_RUNTIME_IMAGES],
@@ -0,0 +1,19 @@
1
+ /*!
2
+ * @license
3
+ * Copyright Squiz Australia Pty Ltd. All Rights Reserved.
4
+ */
5
+
6
+ import { ERRORS } from './Error.constants';
7
+ import { ErrorWithHttpStatusCode } from './ErrorWithHttpStatusCode';
8
+
9
+ /**
10
+ * Conflict error (409).
11
+ * @export
12
+ * @class ConflictError
13
+ * @extends {ErrorWithHttpStatusCode}
14
+ */
15
+ export class ConflictError extends ErrorWithHttpStatusCode {
16
+ constructor(details: string, message = ERRORS.conflict.name) {
17
+ super(ERRORS.conflict.code, message, details);
18
+ }
19
+ }
@@ -24,6 +24,10 @@ export const ERRORS = {
24
24
  code: 422,
25
25
  name: 'Unprocessable Content',
26
26
  },
27
+ conflict: {
28
+ code: 409,
29
+ name: 'Conflict',
30
+ },
27
31
  };
28
32
 
29
33
  export const ERROR_MESSAGES = {
@@ -1,4 +1,5 @@
1
1
  export * from './BadRequestError';
2
+ export * from './ConflictError';
2
3
  export * from './Error.constants';
3
4
  export * from './ErrorWithHttpStatusCode';
4
5
  export * from './InternalServerError';
@@ -5,7 +5,7 @@
5
5
 
6
6
  import { faker } from '@faker-js/faker/locale/en';
7
7
  import { ENTITY_TYPES } from '../../constants/Repository.constants';
8
- import { LEGACY_RUNTIME_IMAGE } from '../../constants/RuntimeImage.constants';
8
+ import { RECOMMENDED_RUNTIME_IMAGE } from '../../constants/RuntimeImage.constants';
9
9
  import { JobManifest } from '../JobManifestTypes';
10
10
 
11
11
  export const createMockJobManifest = (partialRecord: Partial<JobManifest> = {}): JobManifest => {
@@ -25,7 +25,7 @@ export const createMockJobManifest = (partialRecord: Partial<JobManifest> = {}):
25
25
  name: faker.lorem.word(),
26
26
  version,
27
27
  timeoutSeconds: faker.number.int({ max: 28800, min: 5 }),
28
- image: LEGACY_RUNTIME_IMAGE,
28
+ image: RECOMMENDED_RUNTIME_IMAGE,
29
29
  autoRetry: false,
30
30
  type: ENTITY_TYPES.manifest,
31
31
  ...partialRecord,
@@ -5,7 +5,7 @@
5
5
 
6
6
  import { faker } from '@faker-js/faker/locale/en';
7
7
  import { ENTITY_TYPES } from '../../constants/Repository.constants';
8
- import { LEGACY_RUNTIME_IMAGE } from '../../constants/RuntimeImage.constants';
8
+ import { RECOMMENDED_RUNTIME_IMAGE } from '../../constants/RuntimeImage.constants';
9
9
  import { JobManifestDto, JobManifestRecord } from '../JobManifestTypes';
10
10
 
11
11
  export const createMockJobManifestDto = (partialRecord: Partial<JobManifestRecord> = {}): JobManifestDto => {
@@ -40,7 +40,7 @@ export const createMockJobManifestDto = (partialRecord: Partial<JobManifestRecor
40
40
  N: (partialRecord.timeoutSeconds ?? faker.number.int({ max: 28800, min: 5 })).toString(),
41
41
  },
42
42
  image: {
43
- S: partialRecord.image ?? LEGACY_RUNTIME_IMAGE,
43
+ S: partialRecord.image ?? RECOMMENDED_RUNTIME_IMAGE,
44
44
  },
45
45
  autoRetry: {
46
46
  BOOL: partialRecord.autoRetry ?? false,
@@ -5,7 +5,7 @@
5
5
 
6
6
  import { faker } from '@faker-js/faker/locale/en';
7
7
  import { ENTITY_TYPES } from '../../constants/Repository.constants';
8
- import { LEGACY_RUNTIME_IMAGE } from '../../constants/RuntimeImage.constants';
8
+ import { RECOMMENDED_RUNTIME_IMAGE } from '../../constants/RuntimeImage.constants';
9
9
  import { JobManifestRecord } from '../JobManifestTypes';
10
10
 
11
11
  export const createMockJobManifestRecord = (partialRecord: Partial<JobManifestRecord> = {}): JobManifestRecord => {
@@ -29,7 +29,7 @@ export const createMockJobManifestRecord = (partialRecord: Partial<JobManifestRe
29
29
  name,
30
30
  version,
31
31
  timeoutSeconds: faker.number.int({ max: 28800, min: 5 }),
32
- image: LEGACY_RUNTIME_IMAGE,
32
+ image: RECOMMENDED_RUNTIME_IMAGE,
33
33
  autoRetry: false,
34
34
  ...partialRecord,
35
35
  };
@@ -4,7 +4,7 @@
4
4
  */
5
5
 
6
6
  import { faker } from '@faker-js/faker/locale/en';
7
- import { LEGACY_RUNTIME_IMAGE } from '../../constants/RuntimeImage.constants';
7
+ import { RECOMMENDED_RUNTIME_IMAGE } from '../../constants/RuntimeImage.constants';
8
8
  import { CreateJobManifestRequest } from '../JobManifestTypes';
9
9
 
10
10
  export const createMockCreateJobManifestRequest = (
@@ -26,7 +26,7 @@ export const createMockCreateJobManifestRequest = (
26
26
  name: faker.lorem.word(),
27
27
  version,
28
28
  timeoutSeconds: faker.number.int({ max: 28800, min: 5 }),
29
- image: LEGACY_RUNTIME_IMAGE,
29
+ image: RECOMMENDED_RUNTIME_IMAGE,
30
30
  autoRetry: false,
31
31
  ...partialRecord,
32
32
  };
@@ -48,3 +48,9 @@ export type ListParameters<TFilters> = {
48
48
  consistentRead?: boolean;
49
49
  limit?: number;
50
50
  };
51
+
52
+ export type UpdateOptions = {
53
+ conditionExpression?: string;
54
+ expressionAttributeValues?: Record<string, unknown>;
55
+ expressionAttributeNames?: Record<string, string>;
56
+ };
@@ -62,6 +62,13 @@ export interface JobExecution {
62
62
  */
63
63
  ecsTaskId: string | null;
64
64
 
65
+ /**
66
+ * The last version of the ECS task event which was handled.
67
+ * @type {(number | null)}
68
+ * @memberof JobExecution
69
+ */
70
+ lastEcsTaskEventVersion: number | null;
71
+
65
72
  /**
66
73
  * The time the job run was started.
67
74
  * @type {(string | null)}
@@ -184,21 +191,28 @@ export interface JobExecutionRecord {
184
191
 
185
192
  /**
186
193
  * The version of the job script to execute.
187
- * @type @type {(string)}
194
+ * @type {(string)}
188
195
  * @memberof JobExecutionRecord
189
196
  */
190
197
  version: string;
191
198
 
192
199
  /**
193
200
  * The ECS task ID of the job run.
194
- * @type @type {(string | null)}
201
+ * @type {(string | null)}
195
202
  * @memberof JobExecutionRecord
196
203
  */
197
204
  ecsTaskId: string | null;
198
205
 
206
+ /**
207
+ * The last version of the ECS task event which was handled.
208
+ * @type {(number | null)}
209
+ * @memberof JobExecution
210
+ */
211
+ lastEcsTaskEventVersion: number | null;
212
+
199
213
  /**
200
214
  * The time the job run was started.
201
- * @type @type {(string | null)}
215
+ * @type {(string | null)}
202
216
  * @memberof JobExecutionRecord
203
217
  */
204
218
  timeStarted: string | null;
@@ -24,6 +24,7 @@ export const createMockJobExecution = (partialRecord: Partial<JobExecution> = {}
24
24
  return {
25
25
  context: faker.string.uuid(),
26
26
  ecsTaskId: null,
27
+ lastEcsTaskEventVersion: null,
27
28
  id: `${timeQueued}-${faker.string.uuid()}`,
28
29
  jobName,
29
30
  status: JOB_EXECUTION_STATUS.queued,
@@ -34,6 +34,9 @@ export const createMockJobExecutionDto = (partialRecord: Partial<JobExecutionRec
34
34
  S: partialRecord.context ?? faker.string.uuid(),
35
35
  },
36
36
  ecsTaskId: partialRecord.ecsTaskId ? { S: partialRecord.ecsTaskId } : { NULL: true },
37
+ lastEcsTaskEventVersion: partialRecord.lastEcsTaskEventVersion
38
+ ? { N: partialRecord.lastEcsTaskEventVersion.toString() }
39
+ : { NULL: true },
37
40
  gsi1pk: {
38
41
  S: `${ENTITY_TYPES.jobExecution}~${status}`,
39
42
  },
@@ -28,6 +28,7 @@ export const createMockJobExecutionRecord = (partialRecord: Partial<JobExecution
28
28
  return {
29
29
  context: faker.string.uuid(),
30
30
  ecsTaskId: null,
31
+ lastEcsTaskEventVersion: null,
31
32
  gsi1pk: `${ENTITY_TYPES.jobExecution}~${status}`,
32
33
  id: id,
33
34
  jobName: jobName,
@@ -207,6 +207,7 @@ describe('JobExecutionRepository', (): void => {
207
207
  it(`should return job execution by timeQueued and id and handle any null values`, async (): Promise<void> => {
208
208
  const mockJobExecutionRecord = createMockJobExecutionDto({
209
209
  ecsTaskId: 'ecs-task-id',
210
+ lastEcsTaskEventVersion: 1,
210
211
  timeFinished: new Date().toISOString(),
211
212
  timeStarted: new Date().toISOString(),
212
213
  version: '1.0.0',
@@ -225,6 +226,7 @@ describe('JobExecutionRepository', (): void => {
225
226
  expect(response).toStrictEqual({
226
227
  context: mockJobExecutionRecord.context.S as string,
227
228
  ecsTaskId: mockJobExecutionRecord.ecsTaskId.S as string,
229
+ lastEcsTaskEventVersion: parseInt(mockJobExecutionRecord.lastEcsTaskEventVersion.N as string),
228
230
  id: mockJobExecutionRecord.id.S as string,
229
231
  jobName: mockJobExecutionRecord.jobName.S as string,
230
232
  status: mockJobExecutionRecord.status.S as string,
@@ -452,6 +454,7 @@ describe('JobExecutionRepository', (): void => {
452
454
  const updatedAttributes: UpdateJobExecutionParameters = {
453
455
  input: faker.lorem.words(),
454
456
  ecsTaskId: faker.string.uuid(),
457
+ lastEcsTaskEventVersion: faker.number.int(),
455
458
  version: faker.system.semver(),
456
459
  timeStarted: faker.date.recent().toISOString(),
457
460
  timeFinished: null,
@@ -471,20 +474,22 @@ describe('JobExecutionRepository', (): void => {
471
474
  expect(result).toEqual(updatedJobExecution);
472
475
  expect(dynamoClient).toHaveReceivedCommandWith(UpdateItemCommand, {
473
476
  ExpressionAttributeNames: {
474
- '#context': 'context',
475
- '#ecsTaskId': 'ecsTaskId',
476
- '#input': 'input',
477
- '#timeFinished': 'timeFinished',
478
- '#timeStarted': 'timeStarted',
479
- '#version': 'version',
477
+ '#updateExpr0': 'input',
478
+ '#updateExpr1': 'ecsTaskId',
479
+ '#updateExpr2': 'lastEcsTaskEventVersion',
480
+ '#updateExpr3': 'version',
481
+ '#updateExpr4': 'timeStarted',
482
+ '#updateExpr5': 'timeFinished',
483
+ '#updateExpr6': 'context',
480
484
  },
481
485
  ExpressionAttributeValues: {
482
- ':context': { S: updatedAttributes.context! },
483
- ':ecsTaskId': { S: updatedAttributes.ecsTaskId! },
484
- ':input': { S: updatedAttributes.input },
485
- ':timeFinished': { NULL: true },
486
- ':timeStarted': { S: updatedAttributes.timeStarted! },
487
- ':version': { S: updatedAttributes.version! },
486
+ ':updateExpr0': { S: updatedAttributes.input },
487
+ ':updateExpr1': { S: updatedAttributes.ecsTaskId! },
488
+ ':updateExpr2': { N: `${updatedAttributes.lastEcsTaskEventVersion}` },
489
+ ':updateExpr3': { S: updatedAttributes.version! },
490
+ ':updateExpr4': { S: updatedAttributes.timeStarted! },
491
+ ':updateExpr5': { NULL: true },
492
+ ':updateExpr6': { S: updatedAttributes.context! },
488
493
  },
489
494
  Key: {
490
495
  pk: { S: `jobExecution~${tenant}` },
@@ -493,7 +498,7 @@ describe('JobExecutionRepository', (): void => {
493
498
  ReturnValues: 'ALL_NEW',
494
499
  TableName: tableName,
495
500
  UpdateExpression:
496
- 'SET #input = :input, #ecsTaskId = :ecsTaskId, #version = :version, #timeStarted = :timeStarted, #timeFinished = :timeFinished, #context = :context',
501
+ 'SET #updateExpr0 = :updateExpr0, #updateExpr1 = :updateExpr1, #updateExpr2 = :updateExpr2, #updateExpr3 = :updateExpr3, #updateExpr4 = :updateExpr4, #updateExpr5 = :updateExpr5, #updateExpr6 = :updateExpr6',
497
502
  });
498
503
  });
499
504
 
@@ -517,12 +522,68 @@ describe('JobExecutionRepository', (): void => {
517
522
  ExpressionAttributeNames: {
518
523
  '#gsi1pk': 'gsi1pk',
519
524
  '#lsi1sk': 'lsi1sk',
520
- '#status': 'status',
525
+ '#updateExpr0': 'status',
521
526
  },
522
527
  ExpressionAttributeValues: {
523
528
  ':gsi1pk': { S: `jobExecution~${updatedJobExecution.status}` },
524
529
  ':lsi1sk': { S: `${updatedJobExecution.status}~${updatedJobExecution.jobName}` },
525
- ':status': { S: updatedJobExecution.status },
530
+ ':updateExpr0': { S: updatedJobExecution.status },
531
+ },
532
+ Key: {
533
+ pk: { S: `jobExecution~${tenant}` },
534
+ sk: { S: `${jobExecution.jobName}~${jobExecution.id}` },
535
+ },
536
+ ReturnValues: 'ALL_NEW',
537
+ TableName: tableName,
538
+ UpdateExpression: 'SET #updateExpr0 = :updateExpr0, #lsi1sk = :lsi1sk, #gsi1pk = :gsi1pk',
539
+ });
540
+ });
541
+
542
+ it('Marshalls expression attribute values provided for conditional updates', async () => {
543
+ const jobExecution = createMockJobExecution({ status: JOB_EXECUTION_STATUS.queued });
544
+ const updatedJobExecution = createMockJobExecution({
545
+ ...jobExecution,
546
+ status: JOB_EXECUTION_STATUS.running,
547
+ });
548
+
549
+ dynamoClient.on(UpdateItemCommand).resolvesOnce({
550
+ Attributes: createMockJobExecutionDto(updatedJobExecution),
551
+ });
552
+
553
+ const result = await jobExecutionRepository.update(
554
+ jobExecution,
555
+ {
556
+ status: updatedJobExecution.status,
557
+ },
558
+ {
559
+ conditionExpression: '#ecsTaskId = :ecsTaskId AND #status = :status',
560
+ expressionAttributeNames: {
561
+ '#status': 'status',
562
+ '#ecsTaskId': 'ecsTaskId',
563
+ },
564
+ expressionAttributeValues: {
565
+ ecsTaskId: null,
566
+ status: JOB_EXECUTION_STATUS.queued,
567
+ },
568
+ },
569
+ );
570
+
571
+ expect(result).toEqual(updatedJobExecution);
572
+ expect(dynamoClient).toHaveReceivedCommandWith(UpdateItemCommand, {
573
+ ConditionExpression: '#ecsTaskId = :ecsTaskId AND #status = :status',
574
+ ExpressionAttributeNames: {
575
+ '#ecsTaskId': 'ecsTaskId',
576
+ '#gsi1pk': 'gsi1pk',
577
+ '#lsi1sk': 'lsi1sk',
578
+ '#status': 'status',
579
+ '#updateExpr0': 'status',
580
+ },
581
+ ExpressionAttributeValues: {
582
+ ':gsi1pk': { S: 'jobExecution~running' },
583
+ ':lsi1sk': { S: `running~${jobExecution.jobName}` },
584
+ ':updateExpr0': { S: 'running' },
585
+ ecsTaskId: { NULL: true },
586
+ status: { S: 'queued' },
526
587
  },
527
588
  Key: {
528
589
  pk: { S: `jobExecution~${tenant}` },
@@ -530,7 +591,7 @@ describe('JobExecutionRepository', (): void => {
530
591
  },
531
592
  ReturnValues: 'ALL_NEW',
532
593
  TableName: tableName,
533
- UpdateExpression: 'SET #status = :status, #lsi1sk = :lsi1sk, #gsi1pk = :gsi1pk',
594
+ UpdateExpression: 'SET #updateExpr0 = :updateExpr0, #lsi1sk = :lsi1sk, #gsi1pk = :gsi1pk',
534
595
  });
535
596
  });
536
597
  });
@@ -35,6 +35,8 @@ import { provideTransient } from '../../transientProvider';
35
35
  import { Repository } from '../AbstractRepository';
36
36
  import { InjectTokens } from '../../constants/InjectTokens';
37
37
  import { convertDynamoDbItemToJobExecution } from './utils';
38
+ import { UpdateOptions } from '../../model/Base';
39
+ import { marshall } from '@aws-sdk/util-dynamodb';
38
40
 
39
41
  @provideTransient(JobExecutionRepository)
40
42
  export class JobExecutionRepository extends Repository<JobExecution, JobExecutionDto, JobExecutionSk> {
@@ -52,6 +54,7 @@ export class JobExecutionRepository extends Repository<JobExecution, JobExecutio
52
54
  const newJobExecution: JobExecution = {
53
55
  type: `${ENTITY_TYPES.jobExecution}-${createParams.jobName}`,
54
56
  ecsTaskId: null,
57
+ lastEcsTaskEventVersion: null,
55
58
  timeStarted: null,
56
59
  timeFinished: null,
57
60
  input: null,
@@ -240,11 +243,7 @@ export class JobExecutionRepository extends Repository<JobExecution, JobExecutio
240
243
  async update(
241
244
  sk: JobExecutionSk,
242
245
  input: UpdateJobExecutionParameters,
243
- options?: {
244
- ConditionExpression?: string;
245
- ExpressionAttributeValues?: Record<string, AttributeValue>;
246
- ExpressionAttributeNames?: Record<string, string>;
247
- },
246
+ options?: UpdateOptions,
248
247
  ): Promise<JobExecution> {
249
248
  // Initialise update command
250
249
  const updateParams: UpdateItemCommandInput = {
@@ -259,11 +258,13 @@ export class JobExecutionRepository extends Repository<JobExecution, JobExecutio
259
258
  const expressionAttributeNames: Record<string, string> = {};
260
259
  const expressionAttributeValues: Record<string, AttributeValue> = {};
261
260
  const updateExpressionPairs: Array<string> = [];
261
+ let index = 0;
262
262
 
263
263
  // To avoid conflict with reserved keywords, refer to attributes with an additional '#' prefix
264
- for (const key of Object.keys(input)) {
265
- const val = input[key as keyof UpdateJobExecutionParameters];
266
- expressionAttributeNames[`#${key}`] = key;
264
+ for (const property of Object.keys(input)) {
265
+ const key = `updateExpr${index++}`;
266
+ const val = input[property as keyof UpdateJobExecutionParameters];
267
+ expressionAttributeNames[`#${key}`] = property;
267
268
 
268
269
  if (val === null) {
269
270
  expressionAttributeValues[`:${key}`] = { NULL: true };
@@ -275,7 +276,7 @@ export class JobExecutionRepository extends Repository<JobExecution, JobExecutio
275
276
 
276
277
  updateExpressionPairs.push(`#${key} = :${key}`);
277
278
 
278
- if (key === 'status') {
279
+ if (property === 'status') {
279
280
  expressionAttributeNames[`#lsi1sk`] = 'lsi1sk';
280
281
  expressionAttributeValues[`:lsi1sk`] = { S: `${val || ''}~${sk.jobName}` };
281
282
  expressionAttributeNames[`#gsi1pk`] = 'gsi1pk';
@@ -290,23 +291,23 @@ export class JobExecutionRepository extends Repository<JobExecution, JobExecutio
290
291
  updateParams.UpdateExpression = `SET ${updateExpressionPairs.join(', ')}`;
291
292
 
292
293
  // Add condition expression if provided
293
- if (options?.ConditionExpression) {
294
- updateParams.ConditionExpression = options.ConditionExpression;
294
+ if (options?.conditionExpression) {
295
+ updateParams.ConditionExpression = options.conditionExpression;
295
296
  }
296
297
 
297
- if (options?.ExpressionAttributeValues) {
298
+ if (options?.expressionAttributeValues) {
298
299
  // Merge existing expression attribute values with the ones from options
299
300
  updateParams.ExpressionAttributeValues = {
300
301
  ...updateParams.ExpressionAttributeValues,
301
- ...options.ExpressionAttributeValues,
302
+ ...marshall(options.expressionAttributeValues),
302
303
  };
303
304
  }
304
305
 
305
306
  // Add expression attribute names if provided
306
- if (options?.ExpressionAttributeNames) {
307
+ if (options?.expressionAttributeNames) {
307
308
  updateParams.ExpressionAttributeNames = {
308
309
  ...updateParams.ExpressionAttributeNames,
309
- ...options.ExpressionAttributeNames,
310
+ ...options.expressionAttributeNames,
310
311
  };
311
312
  }
312
313
 
@@ -325,6 +326,9 @@ export class JobExecutionRepository extends Repository<JobExecution, JobExecutio
325
326
  return {
326
327
  context: { S: item.context },
327
328
  ecsTaskId: item.ecsTaskId ? { S: item.ecsTaskId } : { NULL: true },
329
+ lastEcsTaskEventVersion: item.lastEcsTaskEventVersion
330
+ ? { N: item.lastEcsTaskEventVersion.toString() }
331
+ : { NULL: true },
328
332
  gsi1pk: { S: `${ENTITY_TYPES.jobExecution}~${item.status}` },
329
333
  id: { S: item.id },
330
334
  jobName: { S: item.jobName },
@@ -6,6 +6,7 @@ export const convertDynamoDbItemToJobExecution = (dbItem: JobExecutionDto): JobE
6
6
  return {
7
7
  context: dbItem.context.S as string,
8
8
  ecsTaskId: dbItem.ecsTaskId.S ? (dbItem.ecsTaskId.S as string) : null,
9
+ lastEcsTaskEventVersion: dbItem.lastEcsTaskEventVersion.N ? parseInt(dbItem.lastEcsTaskEventVersion.N, 10) : null,
9
10
  id: dbItem.id.S as string,
10
11
  jobName: dbItem.jobName.S as string,
11
12
  status: dbItem.status.S as JOB_EXECUTION_STATUS,
@@ -13,7 +13,7 @@ import {
13
13
  } from '@aws-sdk/client-dynamodb';
14
14
  import { initIocContainer, iocContainer } from '../../../../mocks/mockIoc';
15
15
  import { ENTITY_TYPES } from '../../constants/Repository.constants';
16
- import { LEGACY_RUNTIME_IMAGE } from '../../constants/RuntimeImage.constants';
16
+ import { RECOMMENDED_RUNTIME_IMAGE } from '../../constants/RuntimeImage.constants';
17
17
  import {
18
18
  createMockClientGetResponse,
19
19
  createMockClientPutResponse,
@@ -126,7 +126,7 @@ describe('JobManifestRepository', (): void => {
126
126
  expect(mockSend).toBeCalledTimes(1);
127
127
  });
128
128
 
129
- it(`should default missing image to legacy runtime`, async (): Promise<void> => {
129
+ it(`should default missing image to the recommended runtime`, async (): Promise<void> => {
130
130
  const mockJobManifestItem = createMockJobManifestDto();
131
131
  delete (mockJobManifestItem as Partial<typeof mockJobManifestItem>).image;
132
132
 
@@ -143,12 +143,33 @@ describe('JobManifestRepository', (): void => {
143
143
  });
144
144
  expect(response).toEqual(
145
145
  expect.objectContaining({
146
- image: LEGACY_RUNTIME_IMAGE,
146
+ image: RECOMMENDED_RUNTIME_IMAGE,
147
147
  autoRetry: false,
148
148
  }),
149
149
  );
150
150
  });
151
151
 
152
+ it('should remap deprecated node:20 runtime image to node:24', async (): Promise<void> => {
153
+ const mockJobManifestItem = createMockJobManifestDto({ image: 'node:20' });
154
+
155
+ mockSend.mockImplementationOnce(
156
+ (): GetItemCommandOutput =>
157
+ createMockClientGetResponse({
158
+ Item: mockJobManifestItem,
159
+ }),
160
+ );
161
+
162
+ const response = await jobManifestRepository.findOne({
163
+ name: mockJobManifestItem.name.S as string,
164
+ version: mockJobManifestItem.version.S as string,
165
+ });
166
+ expect(response).toEqual(
167
+ expect.objectContaining({
168
+ image: RECOMMENDED_RUNTIME_IMAGE,
169
+ }),
170
+ );
171
+ });
172
+
152
173
  it(`should default missing autoRetry to false`, async (): Promise<void> => {
153
174
  const mockJobManifestItem = createMockJobManifestDto();
154
175
  delete (mockJobManifestItem as Partial<typeof mockJobManifestItem>).autoRetry;
@@ -16,7 +16,7 @@ import {
16
16
  import { initIocContainer, iocContainer } from '../../../../mocks/mockIoc';
17
17
  import { InjectTokens } from '../../constants/InjectTokens';
18
18
  import { ENTITY_TYPES } from '../../constants/Repository.constants';
19
- import { LEGACY_RUNTIME_IMAGE } from '../../constants/RuntimeImage.constants';
19
+ import { RECOMMENDED_RUNTIME_IMAGE } from '../../constants/RuntimeImage.constants';
20
20
  import {
21
21
  createMockClientGetResponse,
22
22
  createMockClientPutResponse,
@@ -176,7 +176,7 @@ describe('JobRepository', (): void => {
176
176
  expect(mockSend).toBeCalledTimes(1);
177
177
  });
178
178
 
179
- it(`should default missing image to legacy runtime`, async (): Promise<void> => {
179
+ it(`should default missing image to the recommended runtime`, async (): Promise<void> => {
180
180
  const mockJobRecord = createMockJobDto();
181
181
  delete (mockJobRecord as Partial<typeof mockJobRecord>).image;
182
182
  mockSend.mockImplementationOnce(
@@ -193,7 +193,28 @@ describe('JobRepository', (): void => {
193
193
 
194
194
  expect(response).toEqual(
195
195
  expect.objectContaining({
196
- image: LEGACY_RUNTIME_IMAGE,
196
+ image: RECOMMENDED_RUNTIME_IMAGE,
197
+ }),
198
+ );
199
+ });
200
+
201
+ it('should remap deprecated node:20 runtime image to node:24', async (): Promise<void> => {
202
+ const mockJobRecord = createMockJobDto({ image: 'node:20' });
203
+ mockSend.mockImplementationOnce(
204
+ (): GetItemCommandOutput =>
205
+ createMockClientGetResponse({
206
+ Item: mockJobRecord,
207
+ }),
208
+ );
209
+
210
+ const response = await jobRepository.findOne({
211
+ name: mockJobRecord.name.S as string,
212
+ version: mockJobRecord.version.S as string,
213
+ });
214
+
215
+ expect(response).toEqual(
216
+ expect.objectContaining({
217
+ image: RECOMMENDED_RUNTIME_IMAGE,
197
218
  }),
198
219
  );
199
220
  });