Skip to content

Commit d47c3ec

Browse files
author
Alexander Schueren
authoredJun 22, 2023
fix(idempotency): pass lambda context remaining time to save inprogress (#1540)
* fix in_progress_expiration timestamp in idempotency handler * add lambda context mock to tests * adjust handler signature with context * revert tests to create config, if not passed, undefined temporary * decorator can now register context too
1 parent 855976d commit d47c3ec

7 files changed

+141
-21
lines changed
 

‎packages/idempotency/src/IdempotencyHandler.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,8 @@ export class IdempotencyHandler<U> {
141141

142142
try {
143143
await this.persistenceStore.saveInProgress(
144-
this.functionPayloadToBeHashed
144+
this.functionPayloadToBeHashed,
145+
this.idempotencyConfig.lambdaContext?.getRemainingTimeInMillis()
145146
);
146147
} catch (e) {
147148
if (e instanceof IdempotencyItemAlreadyExistsError) {

‎packages/idempotency/src/idempotentDecorator.ts

+18-1
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,16 @@ import {
55
} from './types';
66
import { IdempotencyHandler } from './IdempotencyHandler';
77
import { IdempotencyConfig } from './IdempotencyConfig';
8+
import { Context } from 'aws-lambda';
9+
10+
const isContext = (arg: unknown): arg is Context => {
11+
return (
12+
arg !== undefined &&
13+
arg !== null &&
14+
typeof arg === 'object' &&
15+
'getRemainingTimeInMillis' in arg
16+
);
17+
};
818

919
/**
1020
* use this function to narrow the type of options between IdempotencyHandlerOptions and IdempotencyFunctionOptions
@@ -28,13 +38,20 @@ const idempotent = function (
2838
descriptor: PropertyDescriptor
2939
) {
3040
const childFunction = descriptor.value;
31-
descriptor.value = function (record: GenericTempRecord) {
41+
descriptor.value = function (
42+
record: GenericTempRecord,
43+
...args: unknown[]
44+
) {
3245
const functionPayloadtoBeHashed = isFunctionOption(options)
3346
? record[(options as IdempotencyFunctionOptions).dataKeywordArgument]
3447
: record;
3548
const idempotencyConfig = options.config
3649
? options.config
3750
: new IdempotencyConfig({});
51+
const context = args[0];
52+
if (isContext(context)) {
53+
idempotencyConfig.registerLambdaContext(context);
54+
}
3855
const idempotencyHandler = new IdempotencyHandler<GenericTempRecord>({
3956
functionToMakeIdempotent: childFunction,
4057
functionPayloadToBeHashed: functionPayloadtoBeHashed,

‎packages/idempotency/tests/e2e/makeFunctionIdempotent.test.FunctionCode.ts

+7
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { Context } from 'aws-lambda';
22
import { DynamoDBPersistenceLayer } from '../../src/persistence/DynamoDBPersistenceLayer';
33
import { makeFunctionIdempotent } from '../../src';
44
import { Logger } from '@aws-lambda-powertools/logger';
5+
import { IdempotencyConfig } from '../../src';
56

67
const IDEMPOTENCY_TABLE_NAME =
78
process.env.IDEMPOTENCY_TABLE_NAME || 'table_name';
@@ -32,15 +33,19 @@ const processRecord = (record: Record<string, unknown>): string => {
3233
return 'Processing done: ' + record['foo'];
3334
};
3435

36+
const idempotencyConfig = new IdempotencyConfig({});
37+
3538
const processIdempotently = makeFunctionIdempotent(processRecord, {
3639
persistenceStore: dynamoDBPersistenceLayer,
3740
dataKeywordArgument: 'foo',
41+
config: idempotencyConfig,
3842
});
3943

4044
export const handler = async (
4145
_event: EventRecords,
4246
_context: Context
4347
): Promise<void> => {
48+
idempotencyConfig.registerLambdaContext(_context);
4449
for (const record of _event.records) {
4550
const result = await processIdempotently(record);
4651
logger.info(result.toString());
@@ -52,12 +57,14 @@ export const handler = async (
5257
const processIdempotentlyCustomized = makeFunctionIdempotent(processRecord, {
5358
persistenceStore: ddbPersistenceLayerCustomized,
5459
dataKeywordArgument: 'foo',
60+
config: idempotencyConfig,
5561
});
5662

5763
export const handlerCustomized = async (
5864
_event: EventRecords,
5965
_context: Context
6066
): Promise<void> => {
67+
idempotencyConfig.registerLambdaContext(_context);
6168
for (const record of _event.records) {
6269
const result = await processIdempotentlyCustomized(record);
6370
logger.info(result.toString());

‎packages/idempotency/tests/e2e/makeFunctionIdempotent.test.ts

+9
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ describe('Idempotency e2e test function wrapper, default settings', () => {
109109
{ id: 3, foo: 'bar' },
110110
],
111111
};
112+
const invokeStart = Date.now();
112113
await invokeFunction(
113114
functionNameDefault,
114115
2,
@@ -136,6 +137,10 @@ describe('Idempotency e2e test function wrapper, default settings', () => {
136137
})
137138
);
138139
expect(resultFirst?.Item?.data).toEqual('Processing done: bar');
140+
expect(resultFirst?.Item?.expiration).toBeGreaterThan(Date.now() / 1000);
141+
expect(resultFirst?.Item?.in_progress_expiration).toBeGreaterThan(
142+
invokeStart
143+
);
139144
expect(resultFirst?.Item?.status).toEqual('COMPLETED');
140145

141146
const resultSecond = await ddb.send(
@@ -145,6 +150,10 @@ describe('Idempotency e2e test function wrapper, default settings', () => {
145150
})
146151
);
147152
expect(resultSecond?.Item?.data).toEqual('Processing done: baz');
153+
expect(resultSecond?.Item?.expiration).toBeGreaterThan(Date.now() / 1000);
154+
expect(resultSecond?.Item?.in_progress_expiration).toBeGreaterThan(
155+
invokeStart
156+
);
148157
expect(resultSecond?.Item?.status).toEqual('COMPLETED');
149158
},
150159
TEST_CASE_TIMEOUT

‎packages/idempotency/tests/unit/IdempotencyHandler.test.ts

+34
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { BasePersistenceLayer, IdempotencyRecord } from '../../src/persistence';
1414
import { IdempotencyHandler } from '../../src/IdempotencyHandler';
1515
import { IdempotencyConfig } from '../../src/';
1616
import { MAX_RETRIES } from '../../src/constants';
17+
import { Context } from 'aws-lambda';
1718

1819
class PersistenceLayerTestClass extends BasePersistenceLayer {
1920
protected _deleteRecord = jest.fn();
@@ -247,6 +248,39 @@ describe('Class IdempotencyHandler', () => {
247248
expect(mockGetRecord).toHaveBeenCalledTimes(0);
248249
expect(mockSaveSuccessfulResult).toHaveBeenCalledTimes(0);
249250
});
251+
252+
test('when lambdaContext is registered, we pass it to saveInProgress', async () => {
253+
const mockSaveInProgress = jest.spyOn(
254+
mockIdempotencyOptions.persistenceStore,
255+
'saveInProgress'
256+
);
257+
258+
const mockLambaContext: Context = {
259+
getRemainingTimeInMillis(): number {
260+
return 1000; // we expect this number to be passed to saveInProgress
261+
},
262+
} as Context;
263+
const idempotencyHandlerWithContext = new IdempotencyHandler({
264+
functionToMakeIdempotent: mockFunctionToMakeIdempotent,
265+
functionPayloadToBeHashed: mockFunctionPayloadToBeHashed,
266+
persistenceStore: mockIdempotencyOptions.persistenceStore,
267+
fullFunctionPayload: mockFullFunctionPayload,
268+
idempotencyConfig: new IdempotencyConfig({
269+
lambdaContext: mockLambaContext,
270+
}),
271+
});
272+
273+
mockFunctionToMakeIdempotent.mockImplementation(() =>
274+
Promise.resolve('result')
275+
);
276+
277+
await expect(idempotencyHandlerWithContext.processIdempotency()).resolves;
278+
279+
expect(mockSaveInProgress).toBeCalledWith(
280+
mockFunctionPayloadToBeHashed,
281+
mockLambaContext.getRemainingTimeInMillis()
282+
);
283+
});
250284
});
251285

252286
describe('Method: getFunctionResult', () => {

‎packages/idempotency/tests/unit/idempotentDecorator.test.ts

+42-14
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ import {
1515
IdempotencyPersistenceLayerError,
1616
} from '../../src/Exceptions';
1717
import { IdempotencyConfig } from '../../src';
18+
import { Context } from 'aws-lambda';
19+
import { helloworldContext } from '@aws-lambda-powertools/commons/lib/samples/resources/contexts';
1820

1921
const mockSaveInProgress = jest
2022
.spyOn(BasePersistenceLayer.prototype, 'saveInProgress')
@@ -26,6 +28,10 @@ const mockGetRecord = jest
2628
.spyOn(BasePersistenceLayer.prototype, 'getRecord')
2729
.mockImplementation();
2830

31+
const dummyContext = helloworldContext;
32+
33+
const mockConfig: IdempotencyConfig = new IdempotencyConfig({});
34+
2935
class PersistenceLayerTestClass extends BasePersistenceLayer {
3036
protected _deleteRecord = jest.fn();
3137
protected _getRecord = jest.fn();
@@ -39,21 +45,25 @@ class TestinClassWithLambdaHandler {
3945
@idempotentLambdaHandler({
4046
persistenceStore: new PersistenceLayerTestClass(),
4147
})
42-
public testing(record: Record<string, unknown>): string {
48+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
49+
public testing(record: Record<string, unknown>, context: Context): string {
4350
functionalityToDecorate(record);
4451

4552
return 'Hi';
4653
}
4754
}
4855

4956
class TestingClassWithFunctionDecorator {
50-
public handler(record: Record<string, unknown>): string {
57+
public handler(record: Record<string, unknown>, context: Context): string {
58+
mockConfig.registerLambdaContext(context);
59+
5160
return this.proccessRecord(record);
5261
}
5362

5463
@idempotentFunction({
5564
persistenceStore: new PersistenceLayerTestClass(),
5665
dataKeywordArgument: 'testingKey',
66+
config: mockConfig,
5767
})
5868
public proccessRecord(record: Record<string, unknown>): string {
5969
functionalityToDecorate(record);
@@ -72,11 +82,14 @@ describe('Given a class with a function to decorate', (classWithLambdaHandler =
7282

7383
describe('When wrapping a function with no previous executions', () => {
7484
beforeEach(async () => {
75-
await classWithFunctionDecorator.handler(inputRecord);
85+
await classWithFunctionDecorator.handler(inputRecord, dummyContext);
7686
});
7787

7888
test('Then it will save the record to INPROGRESS', () => {
79-
expect(mockSaveInProgress).toBeCalledWith(keyValueToBeSaved);
89+
expect(mockSaveInProgress).toBeCalledWith(
90+
keyValueToBeSaved,
91+
dummyContext.getRemainingTimeInMillis()
92+
);
8093
});
8194

8295
test('Then it will call the function that was decorated', () => {
@@ -92,11 +105,14 @@ describe('Given a class with a function to decorate', (classWithLambdaHandler =
92105
});
93106
describe('When wrapping a function with no previous executions', () => {
94107
beforeEach(async () => {
95-
await classWithLambdaHandler.testing(inputRecord);
108+
await classWithLambdaHandler.testing(inputRecord, dummyContext);
96109
});
97110

98111
test('Then it will save the record to INPROGRESS', () => {
99-
expect(mockSaveInProgress).toBeCalledWith(inputRecord);
112+
expect(mockSaveInProgress).toBeCalledWith(
113+
inputRecord,
114+
dummyContext.getRemainingTimeInMillis()
115+
);
100116
});
101117

102118
test('Then it will call the function that was decorated', () => {
@@ -122,14 +138,17 @@ describe('Given a class with a function to decorate', (classWithLambdaHandler =
122138
new IdempotencyRecord(idempotencyOptions)
123139
);
124140
try {
125-
await classWithLambdaHandler.testing(inputRecord);
141+
await classWithLambdaHandler.testing(inputRecord, dummyContext);
126142
} catch (e) {
127143
resultingError = e as Error;
128144
}
129145
});
130146

131147
test('Then it will attempt to save the record to INPROGRESS', () => {
132-
expect(mockSaveInProgress).toBeCalledWith(inputRecord);
148+
expect(mockSaveInProgress).toBeCalledWith(
149+
inputRecord,
150+
dummyContext.getRemainingTimeInMillis()
151+
);
133152
});
134153

135154
test('Then it will get the previous execution record', () => {
@@ -159,14 +178,17 @@ describe('Given a class with a function to decorate', (classWithLambdaHandler =
159178
new IdempotencyRecord(idempotencyOptions)
160179
);
161180
try {
162-
await classWithLambdaHandler.testing(inputRecord);
181+
await classWithLambdaHandler.testing(inputRecord, dummyContext);
163182
} catch (e) {
164183
resultingError = e as Error;
165184
}
166185
});
167186

168187
test('Then it will attempt to save the record to INPROGRESS', () => {
169-
expect(mockSaveInProgress).toBeCalledWith(inputRecord);
188+
expect(mockSaveInProgress).toBeCalledWith(
189+
inputRecord,
190+
dummyContext.getRemainingTimeInMillis()
191+
);
170192
});
171193

172194
test('Then it will get the previous execution record', () => {
@@ -195,11 +217,14 @@ describe('Given a class with a function to decorate', (classWithLambdaHandler =
195217
mockGetRecord.mockResolvedValue(
196218
new IdempotencyRecord(idempotencyOptions)
197219
);
198-
await classWithLambdaHandler.testing(inputRecord);
220+
await classWithLambdaHandler.testing(inputRecord, dummyContext);
199221
});
200222

201223
test('Then it will attempt to save the record to INPROGRESS', () => {
202-
expect(mockSaveInProgress).toBeCalledWith(inputRecord);
224+
expect(mockSaveInProgress).toBeCalledWith(
225+
inputRecord,
226+
dummyContext.getRemainingTimeInMillis()
227+
);
203228
});
204229

205230
test('Then it will get the previous execution record', () => {
@@ -215,7 +240,7 @@ describe('Given a class with a function to decorate', (classWithLambdaHandler =
215240
class TestinClassWithLambdaHandlerWithConfig {
216241
@idempotentLambdaHandler({
217242
persistenceStore: new PersistenceLayerTestClass(),
218-
config: new IdempotencyConfig({}),
243+
config: new IdempotencyConfig({ lambdaContext: dummyContext }),
219244
})
220245
public testing(record: Record<string, unknown>): string {
221246
functionalityToDecorate(record);
@@ -237,7 +262,10 @@ describe('Given a class with a function to decorate', (classWithLambdaHandler =
237262
});
238263

239264
test('Then it will attempt to save the record to INPROGRESS', () => {
240-
expect(mockSaveInProgress).toBeCalledWith(inputRecord);
265+
expect(mockSaveInProgress).toBeCalledWith(
266+
inputRecord,
267+
dummyContext.getRemainingTimeInMillis()
268+
);
241269
});
242270

243271
test('Then an IdempotencyPersistenceLayerError is thrown', () => {

‎packages/idempotency/tests/unit/makeFunctionIdempotent.test.ts

+29-5
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ import {
1717
IdempotencyItemAlreadyExistsError,
1818
IdempotencyPersistenceLayerError,
1919
} from '../../src/Exceptions';
20+
import { IdempotencyConfig } from '../../src';
21+
import { Context } from 'aws-lambda';
2022

2123
const mockSaveInProgress = jest
2224
.spyOn(BasePersistenceLayer.prototype, 'saveInProgress')
@@ -25,6 +27,12 @@ const mockGetRecord = jest
2527
.spyOn(BasePersistenceLayer.prototype, 'getRecord')
2628
.mockImplementation();
2729

30+
const mockLambaContext: Context = {
31+
getRemainingTimeInMillis(): number {
32+
return 1000; // we expect this number to be passed to saveInProgress
33+
},
34+
} as Context;
35+
2836
class PersistenceLayerTestClass extends BasePersistenceLayer {
2937
protected _deleteRecord = jest.fn();
3038
protected _getRecord = jest.fn();
@@ -37,6 +45,7 @@ describe('Given a function to wrap', (functionToWrap = jest.fn()) => {
3745
describe('Given options for idempotency', (options: IdempotencyFunctionOptions = {
3846
persistenceStore: new PersistenceLayerTestClass(),
3947
dataKeywordArgument: 'testingKey',
48+
config: new IdempotencyConfig({ lambdaContext: mockLambaContext }),
4049
}) => {
4150
const keyValueToBeSaved = 'thisWillBeSaved';
4251
const inputRecord = {
@@ -51,7 +60,10 @@ describe('Given a function to wrap', (functionToWrap = jest.fn()) => {
5160
});
5261

5362
test('Then it will save the record to INPROGRESS', () => {
54-
expect(mockSaveInProgress).toBeCalledWith(keyValueToBeSaved);
63+
expect(mockSaveInProgress).toBeCalledWith(
64+
keyValueToBeSaved,
65+
mockLambaContext.getRemainingTimeInMillis()
66+
);
5567
});
5668

5769
test('Then it will call the function that was wrapped with the whole input record', () => {
@@ -82,7 +94,10 @@ describe('Given a function to wrap', (functionToWrap = jest.fn()) => {
8294
});
8395

8496
test('Then it will attempt to save the record to INPROGRESS', () => {
85-
expect(mockSaveInProgress).toBeCalledWith(keyValueToBeSaved);
97+
expect(mockSaveInProgress).toBeCalledWith(
98+
keyValueToBeSaved,
99+
mockLambaContext.getRemainingTimeInMillis()
100+
);
86101
});
87102

88103
test('Then it will get the previous execution record', () => {
@@ -123,7 +138,10 @@ describe('Given a function to wrap', (functionToWrap = jest.fn()) => {
123138
});
124139

125140
test('Then it will attempt to save the record to INPROGRESS', () => {
126-
expect(mockSaveInProgress).toBeCalledWith(keyValueToBeSaved);
141+
expect(mockSaveInProgress).toBeCalledWith(
142+
keyValueToBeSaved,
143+
mockLambaContext.getRemainingTimeInMillis()
144+
);
127145
});
128146

129147
test('Then it will get the previous execution record', () => {
@@ -159,7 +177,10 @@ describe('Given a function to wrap', (functionToWrap = jest.fn()) => {
159177
});
160178

161179
test('Then it will attempt to save the record to INPROGRESS', () => {
162-
expect(mockSaveInProgress).toBeCalledWith(keyValueToBeSaved);
180+
expect(mockSaveInProgress).toBeCalledWith(
181+
keyValueToBeSaved,
182+
mockLambaContext.getRemainingTimeInMillis()
183+
);
163184
});
164185

165186
test('Then it will get the previous execution record', () => {
@@ -185,7 +206,10 @@ describe('Given a function to wrap', (functionToWrap = jest.fn()) => {
185206
});
186207

187208
test('Then it will attempt to save the record to INPROGRESS', () => {
188-
expect(mockSaveInProgress).toBeCalledWith(keyValueToBeSaved);
209+
expect(mockSaveInProgress).toBeCalledWith(
210+
keyValueToBeSaved,
211+
mockLambaContext.getRemainingTimeInMillis()
212+
);
189213
});
190214

191215
test('Then an IdempotencyPersistenceLayerError is thrown', () => {

0 commit comments

Comments
 (0)
Please sign in to comment.