Skip to content

Commit 4ef4e5c

Browse files
Alexander Schuerendreamorosi
Alexander Schueren
andauthored
test(idempotency): add e2e tests for idempotency (#1442)
* add flag (default true) to include index, so we can pass same payload multiple times * split interface for decorator and function wrap * tests * fix record return for sequential calls * change to get command * fix unit tests based on new handler implementation * extend signature to pass array values to functions * add e2e and fix unit test for new signature * refactor tests * get back tear down * cleanup test * fixed IdempotencyOption name * fix relative import * use same uuid across the test * refactored idempotency options type and fix tests * refactor IdempotencyHandler constructor --------- Co-authored-by: Andrea Amorosi <[email protected]>
1 parent cec48ae commit 4ef4e5c

15 files changed

+717
-90
lines changed

Diff for: packages/commons/tests/utils/e2eUtils.ts

+14-14
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import {
99
} from 'aws-cdk-lib/aws-lambda-nodejs';
1010
import { Runtime, Tracing } from 'aws-cdk-lib/aws-lambda';
1111
import { RetentionDays } from 'aws-cdk-lib/aws-logs';
12-
import { LambdaClient, InvokeCommand } from '@aws-sdk/client-lambda';
12+
import { InvokeCommand, LambdaClient } from '@aws-sdk/client-lambda';
1313
import { fromUtf8 } from '@aws-sdk/util-utf8-node';
1414

1515
import { InvocationLogs } from './InvocationLogs';
@@ -38,7 +38,7 @@ export type StackWithLambdaFunctionOptions = {
3838
timeout?: Duration;
3939
};
4040

41-
type FunctionPayload = { [key: string]: string | boolean | number };
41+
type FunctionPayload = { [key: string]: string | boolean | number | Array<Record<string, unknown>> };
4242

4343
export const isValidRuntimeKey = (
4444
runtime: string
@@ -82,27 +82,26 @@ export const generateUniqueName = (
8282

8383
export const invokeFunction = async (
8484
functionName: string,
85-
times = 1,
85+
times: number = 1,
8686
invocationMode: 'PARALLEL' | 'SEQUENTIAL' = 'PARALLEL',
87-
payload: FunctionPayload = {}
87+
payload: FunctionPayload = {},
88+
includeIndex = true
8889
): Promise<InvocationLogs[]> => {
8990
const invocationLogs: InvocationLogs[] = [];
9091

91-
const promiseFactory = (index?: number): Promise<void> => {
92+
const promiseFactory = (index?: number, includeIndex?: boolean): Promise<void> => {
93+
94+
// in some cases we need to send a payload without the index, i.e. idempotency tests
95+
const payloadToSend = includeIndex ? { invocation: index, ...payload } : { ...payload };
96+
9297
const invokePromise = lambdaClient
9398
.send(
9499
new InvokeCommand({
95100
FunctionName: functionName,
96101
InvocationType: 'RequestResponse',
97102
LogType: 'Tail', // Wait until execution completes and return all logs
98-
Payload: fromUtf8(
99-
JSON.stringify({
100-
invocation: index,
101-
...payload,
102-
})
103-
),
104-
})
105-
)
103+
Payload: fromUtf8(JSON.stringify(payloadToSend)),
104+
}))
106105
.then((response) => {
107106
if (response?.LogResult) {
108107
invocationLogs.push(new InvocationLogs(response?.LogResult));
@@ -117,9 +116,10 @@ export const invokeFunction = async (
117116
};
118117

119118
const promiseFactories = Array.from({ length: times }, () => promiseFactory);
119+
120120
const invocation =
121121
invocationMode == 'PARALLEL'
122-
? Promise.all(promiseFactories.map((factory, index) => factory(index)))
122+
? Promise.all(promiseFactories.map((factory, index) => factory(index, includeIndex)))
123123
: chainPromises(promiseFactories);
124124
await invocation;
125125

Diff for: packages/idempotency/package.json

+5-4
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,10 @@
1313
"commit": "commit",
1414
"test": "npm run test:unit",
1515
"test:unit": "jest --group=unit --detectOpenHandles --coverage --verbose",
16-
"test:e2e:nodejs14x": "echo \"Not implemented\"",
17-
"test:e2e:nodejs16x": "echo \"Not implemented\"",
18-
"test:e2e:nodejs18x": "echo \"Not implemented\"",
19-
"test:e2e": "echo \"Not implemented\"",
16+
"test:e2e:nodejs14x": "RUNTIME=nodejs14x jest --group=e2e",
17+
"test:e2e:nodejs16x": "RUNTIME=nodejs16x jest --group=e2e",
18+
"test:e2e:nodejs18x": "RUNTIME=nodejs18x jest --group=e2e",
19+
"test:e2e": "jest --group=e2e --detectOpenHandles",
2020
"watch": "jest --watch --group=unit",
2121
"build": "tsc",
2222
"lint": "eslint --ext .ts --no-error-on-unmatched-pattern src tests",
@@ -57,6 +57,7 @@
5757
],
5858
"devDependencies": {
5959
"@types/jmespath": "^0.15.0",
60+
"@aws-sdk/client-dynamodb": "^3.231.0",
6061
"aws-sdk-client-mock": "^2.0.1",
6162
"aws-sdk-client-mock-jest": "^2.0.1"
6263
}

Diff for: packages/idempotency/src/IdempotencyHandler.ts

+56-17
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,42 @@
1-
import type { AnyFunctionWithRecord, IdempotencyOptions } from './types';
1+
import type { AnyFunctionWithRecord, IdempotencyHandlerOptions } from './types';
22
import { IdempotencyRecordStatus } from './types';
33
import {
44
IdempotencyAlreadyInProgressError,
55
IdempotencyInconsistentStateError,
66
IdempotencyItemAlreadyExistsError,
77
IdempotencyPersistenceLayerError,
88
} from './Exceptions';
9-
import { IdempotencyRecord } from './persistence';
9+
import { BasePersistenceLayer, IdempotencyRecord } from './persistence';
10+
import { IdempotencyConfig } from './IdempotencyConfig';
1011

1112
export class IdempotencyHandler<U> {
12-
public constructor(
13-
private functionToMakeIdempotent: AnyFunctionWithRecord<U>,
14-
private functionPayloadToBeHashed: Record<string, unknown>,
15-
private idempotencyOptions: IdempotencyOptions,
16-
private fullFunctionPayload: Record<string, unknown>,
17-
) {
13+
private readonly fullFunctionPayload: Record<string, unknown>;
14+
private readonly functionPayloadToBeHashed: Record<string, unknown>;
15+
private readonly functionToMakeIdempotent: AnyFunctionWithRecord<U>;
16+
private readonly idempotencyConfig: IdempotencyConfig;
17+
private readonly persistenceStore: BasePersistenceLayer;
18+
19+
public constructor(options: IdempotencyHandlerOptions<U>) {
20+
const {
21+
functionToMakeIdempotent,
22+
functionPayloadToBeHashed,
23+
idempotencyConfig,
24+
fullFunctionPayload,
25+
persistenceStore
26+
} = options;
27+
this.functionToMakeIdempotent = functionToMakeIdempotent;
28+
this.functionPayloadToBeHashed = functionPayloadToBeHashed;
29+
this.idempotencyConfig = idempotencyConfig;
30+
this.fullFunctionPayload = fullFunctionPayload;
31+
32+
this.persistenceStore = persistenceStore;
33+
34+
this.persistenceStore.configure({
35+
config: this.idempotencyConfig
36+
});
1837
}
1938

20-
public determineResultFromIdempotencyRecord(
21-
idempotencyRecord: IdempotencyRecord
22-
): Promise<U> | U {
39+
public determineResultFromIdempotencyRecord(idempotencyRecord: IdempotencyRecord): Promise<U> | U {
2340
if (idempotencyRecord.getStatus() === IdempotencyRecordStatus.EXPIRED) {
2441
throw new IdempotencyInconsistentStateError(
2542
'Item has expired during processing and may not longer be valid.'
@@ -40,10 +57,31 @@ export class IdempotencyHandler<U> {
4057
`There is already an execution in progress with idempotency key: ${idempotencyRecord.idempotencyKey}`
4158
);
4259
}
43-
} else {
44-
// Currently recalling the method as this fulfills FR1. FR3 will address using the previously stored value https://github.com/awslabs/aws-lambda-powertools-typescript/issues/447
45-
return this.functionToMakeIdempotent(this.fullFunctionPayload);
4660
}
61+
62+
return idempotencyRecord.getResponse() as U;
63+
}
64+
65+
public async getFunctionResult(): Promise<U> {
66+
let result: U;
67+
try {
68+
result = await this.functionToMakeIdempotent(this.fullFunctionPayload);
69+
70+
} catch (e) {
71+
try {
72+
await this.persistenceStore.deleteRecord(this.functionPayloadToBeHashed);
73+
} catch (e) {
74+
throw new IdempotencyPersistenceLayerError('Failed to delete record from idempotency store');
75+
}
76+
throw e;
77+
}
78+
try {
79+
await this.persistenceStore.saveSuccess(this.functionPayloadToBeHashed, result as Record<string, unknown>);
80+
} catch (e) {
81+
throw new IdempotencyPersistenceLayerError('Failed to update success record to idempotency store');
82+
}
83+
84+
return result;
4785
}
4886

4987
/**
@@ -70,13 +108,13 @@ export class IdempotencyHandler<U> {
70108

71109
public async processIdempotency(): Promise<U> {
72110
try {
73-
await this.idempotencyOptions.persistenceStore.saveInProgress(
111+
await this.persistenceStore.saveInProgress(
74112
this.functionPayloadToBeHashed,
75113
);
76114
} catch (e) {
77115
if (e instanceof IdempotencyItemAlreadyExistsError) {
78116
const idempotencyRecord: IdempotencyRecord =
79-
await this.idempotencyOptions.persistenceStore.getRecord(
117+
await this.persistenceStore.getRecord(
80118
this.functionPayloadToBeHashed
81119
);
82120

@@ -86,6 +124,7 @@ export class IdempotencyHandler<U> {
86124
}
87125
}
88126

89-
return this.functionToMakeIdempotent(this.fullFunctionPayload);
127+
return this.getFunctionResult();
90128
}
129+
91130
}

Diff for: packages/idempotency/src/idempotentDecorator.ts

+27-9
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,26 @@
1-
import {
2-
GenericTempRecord,
3-
IdempotencyOptions,
4-
} from './types';
1+
import { GenericTempRecord, IdempotencyFunctionOptions, IdempotencyLambdaHandlerOptions, } from './types';
52
import { IdempotencyHandler } from './IdempotencyHandler';
3+
import { IdempotencyConfig } from './IdempotencyConfig';
64

7-
const idempotent = function (options: IdempotencyOptions) {
5+
/**
6+
* use this function to narrow the type of options between IdempotencyHandlerOptions and IdempotencyFunctionOptions
7+
* @param options
8+
*/
9+
const isFunctionOption = (options: IdempotencyLambdaHandlerOptions | IdempotencyFunctionOptions): boolean => (options as IdempotencyFunctionOptions).dataKeywordArgument !== undefined;
10+
11+
const idempotent = function (options: IdempotencyLambdaHandlerOptions | IdempotencyFunctionOptions): (target: unknown, propertyKey: string, descriptor: PropertyDescriptor) => PropertyDescriptor {
812
return function (_target: unknown, _propertyKey: string, descriptor: PropertyDescriptor) {
913
const childFunction = descriptor.value;
10-
// TODO: sort out the type for this
11-
descriptor.value = function(record: GenericTempRecord){
12-
const idempotencyHandler = new IdempotencyHandler<GenericTempRecord>(childFunction, record[options.dataKeywordArgument], options, record);
14+
descriptor.value = function (record: GenericTempRecord) {
15+
const functionPayloadtoBeHashed = isFunctionOption(options) ? record[(options as IdempotencyFunctionOptions).dataKeywordArgument] : record;
16+
const idempotencyConfig = options.config ? options.config : new IdempotencyConfig({});
17+
const idempotencyHandler = new IdempotencyHandler<GenericTempRecord>({
18+
functionToMakeIdempotent: childFunction,
19+
functionPayloadToBeHashed: functionPayloadtoBeHashed,
20+
persistenceStore: options.persistenceStore,
21+
idempotencyConfig: idempotencyConfig,
22+
fullFunctionPayload: record
23+
});
1324

1425
return idempotencyHandler.handle();
1526
};
@@ -18,4 +29,11 @@ const idempotent = function (options: IdempotencyOptions) {
1829
};
1930
};
2031

21-
export { idempotent };
32+
const idempotentLambdaHandler = function (options: IdempotencyLambdaHandlerOptions): (target: unknown, propertyKey: string, descriptor: PropertyDescriptor) => PropertyDescriptor {
33+
return idempotent(options);
34+
};
35+
const idempotentFunction = function (options: IdempotencyFunctionOptions): (target: unknown, propertyKey: string, descriptor: PropertyDescriptor) => PropertyDescriptor {
36+
return idempotent(options);
37+
};
38+
39+
export { idempotentLambdaHandler, idempotentFunction };

Diff for: packages/idempotency/src/makeFunctionIdempotent.ts

+15-4
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,28 @@
11
import type {
2-
GenericTempRecord,
3-
IdempotencyOptions,
42
AnyFunctionWithRecord,
53
AnyIdempotentFunction,
4+
GenericTempRecord,
5+
IdempotencyFunctionOptions,
66
} from './types';
77
import { IdempotencyHandler } from './IdempotencyHandler';
8+
import { IdempotencyConfig } from './IdempotencyConfig';
89

910
const makeFunctionIdempotent = function <U>(
1011
fn: AnyFunctionWithRecord<U>,
11-
options: IdempotencyOptions
12+
options: IdempotencyFunctionOptions,
1213
): AnyIdempotentFunction<U> {
1314
const wrappedFn: AnyIdempotentFunction<U> = function (record: GenericTempRecord): Promise<U> {
14-
const idempotencyHandler: IdempotencyHandler<U> = new IdempotencyHandler<U>(fn, record[options.dataKeywordArgument], options, record);
15+
if (options.dataKeywordArgument === undefined) {
16+
throw new Error(`Missing data keyword argument ${options.dataKeywordArgument}`);
17+
}
18+
const idempotencyConfig = options.config ? options.config : new IdempotencyConfig({});
19+
const idempotencyHandler: IdempotencyHandler<U> = new IdempotencyHandler<U>({
20+
functionToMakeIdempotent: fn,
21+
functionPayloadToBeHashed: record[options.dataKeywordArgument],
22+
idempotencyConfig: idempotencyConfig,
23+
persistenceStore: options.persistenceStore,
24+
fullFunctionPayload: record
25+
});
1526

1627
return idempotencyHandler.handle();
1728
};

Diff for: packages/idempotency/src/types/IdempotencyOptions.ts

+19-3
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,23 @@
11
import type { Context } from 'aws-lambda';
22
import { BasePersistenceLayer } from '../persistence/BasePersistenceLayer';
3+
import { AnyFunctionWithRecord } from 'types/AnyFunction';
4+
import { IdempotencyConfig } from '../IdempotencyConfig';
35

4-
type IdempotencyOptions = {
6+
type IdempotencyLambdaHandlerOptions = {
7+
persistenceStore: BasePersistenceLayer
8+
config?: IdempotencyConfig
9+
};
10+
11+
type IdempotencyFunctionOptions = IdempotencyLambdaHandlerOptions & {
512
dataKeywordArgument: string
13+
};
14+
15+
type IdempotencyHandlerOptions<U> = {
16+
functionToMakeIdempotent: AnyFunctionWithRecord<U>
17+
functionPayloadToBeHashed: Record<string, unknown>
618
persistenceStore: BasePersistenceLayer
19+
idempotencyConfig: IdempotencyConfig
20+
fullFunctionPayload: Record<string, unknown>
721
};
822

923
/**
@@ -45,6 +59,8 @@ type IdempotencyConfigOptions = {
4559
};
4660

4761
export {
48-
IdempotencyOptions,
49-
IdempotencyConfigOptions
62+
IdempotencyConfigOptions,
63+
IdempotencyFunctionOptions,
64+
IdempotencyLambdaHandlerOptions,
65+
IdempotencyHandlerOptions
5066
};

Diff for: packages/idempotency/tests/e2e/constants.ts

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
export const RESOURCE_NAME_PREFIX = 'Idempotency-E2E';
2+
3+
export const ONE_MINUTE = 60 * 1_000;
4+
export const TEARDOWN_TIMEOUT = 5 * ONE_MINUTE;
5+
export const SETUP_TIMEOUT = 5 * ONE_MINUTE;
6+
export const TEST_CASE_TIMEOUT = 5 * ONE_MINUTE;

0 commit comments

Comments
 (0)