Skip to content

Commit d0b4cf2

Browse files
dreamorosiam29d
andauthored
improv(idempotency): handle functions with no return value (#2521)
Co-authored-by: Alexander Schueren <[email protected]>
1 parent d350b95 commit d0b4cf2

File tree

5 files changed

+99
-43
lines changed

5 files changed

+99
-43
lines changed

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

+52-35
Original file line numberDiff line numberDiff line change
@@ -155,8 +155,9 @@ export class IdempotencyHandler<Func extends AnyFunction> {
155155
let e;
156156
for (let retryNo = 0; retryNo <= MAX_RETRIES; retryNo++) {
157157
try {
158-
const result = await this.#saveInProgressOrReturnExistingResult();
159-
if (result) return result as ReturnType<Func>;
158+
const { isIdempotent, result } =
159+
await this.#saveInProgressOrReturnExistingResult();
160+
if (isIdempotent) return result as ReturnType<Func>;
160161

161162
return await this.getFunctionResult();
162163
} catch (error) {
@@ -215,8 +216,9 @@ export class IdempotencyHandler<Func extends AnyFunction> {
215216
): Promise<ReturnType<Func> | void> {
216217
for (let retryNo = 0; retryNo <= MAX_RETRIES; retryNo++) {
217218
try {
218-
const result = await this.#saveInProgressOrReturnExistingResult();
219-
if (result) {
219+
const { isIdempotent, result } =
220+
await this.#saveInProgressOrReturnExistingResult();
221+
if (isIdempotent) {
220222
await callback(request);
221223

222224
return result as ReturnType<Func>;
@@ -310,43 +312,58 @@ export class IdempotencyHandler<Func extends AnyFunction> {
310312
* Before returning a result, we might neede to look up the idempotency record
311313
* and validate it to ensure that it is consistent with the payload to be hashed.
312314
*/
313-
#saveInProgressOrReturnExistingResult =
314-
async (): Promise<JSONValue | void> => {
315-
try {
316-
await this.#persistenceStore.saveInProgress(
317-
this.#functionPayloadToBeHashed,
318-
this.#idempotencyConfig.lambdaContext?.getRemainingTimeInMillis()
319-
);
320-
} catch (e) {
321-
if (e instanceof IdempotencyItemAlreadyExistsError) {
322-
let idempotencyRecord = e.existingRecord;
323-
if (idempotencyRecord !== undefined) {
324-
// If the error includes the existing record, we can use it to validate
325-
// the record being processed and cache it in memory.
326-
idempotencyRecord = this.#persistenceStore.processExistingRecord(
327-
idempotencyRecord,
328-
this.#functionPayloadToBeHashed
329-
);
330-
// If the error doesn't include the existing record, we need to fetch
331-
// it from the persistence layer. In doing so, we also call the processExistingRecord
332-
// method to validate the record and cache it in memory.
333-
} else {
334-
idempotencyRecord = await this.#persistenceStore.getRecord(
335-
this.#functionPayloadToBeHashed
336-
);
337-
}
315+
#saveInProgressOrReturnExistingResult = async (): Promise<{
316+
isIdempotent: boolean;
317+
result: JSONValue;
318+
}> => {
319+
const returnValue: {
320+
isIdempotent: boolean;
321+
result: JSONValue;
322+
} = {
323+
isIdempotent: false,
324+
result: undefined,
325+
};
326+
try {
327+
await this.#persistenceStore.saveInProgress(
328+
this.#functionPayloadToBeHashed,
329+
this.#idempotencyConfig.lambdaContext?.getRemainingTimeInMillis()
330+
);
338331

339-
return IdempotencyHandler.determineResultFromIdempotencyRecord(
340-
idempotencyRecord
332+
return returnValue;
333+
} catch (e) {
334+
if (e instanceof IdempotencyItemAlreadyExistsError) {
335+
let idempotencyRecord = e.existingRecord;
336+
if (idempotencyRecord !== undefined) {
337+
// If the error includes the existing record, we can use it to validate
338+
// the record being processed and cache it in memory.
339+
idempotencyRecord = this.#persistenceStore.processExistingRecord(
340+
idempotencyRecord,
341+
this.#functionPayloadToBeHashed
341342
);
343+
// If the error doesn't include the existing record, we need to fetch
344+
// it from the persistence layer. In doing so, we also call the processExistingRecord
345+
// method to validate the record and cache it in memory.
342346
} else {
343-
throw new IdempotencyPersistenceLayerError(
344-
'Failed to save in progress record to idempotency store',
345-
e as Error
347+
idempotencyRecord = await this.#persistenceStore.getRecord(
348+
this.#functionPayloadToBeHashed
346349
);
347350
}
351+
352+
returnValue.isIdempotent = true;
353+
returnValue.result =
354+
IdempotencyHandler.determineResultFromIdempotencyRecord(
355+
idempotencyRecord
356+
);
357+
358+
return returnValue;
359+
} else {
360+
throw new IdempotencyPersistenceLayerError(
361+
'Failed to save in progress record to idempotency store',
362+
e as Error
363+
);
348364
}
349-
};
365+
}
366+
};
350367

351368
/**
352369
* Save a successful result to the idempotency store.

Diff for: packages/idempotency/src/persistence/DynamoDBPersistenceLayer.ts

+6-3
Original file line numberDiff line numberDiff line change
@@ -226,21 +226,24 @@ class DynamoDBPersistenceLayer extends BasePersistenceLayer {
226226

227227
protected async _updateRecord(record: IdempotencyRecord): Promise<void> {
228228
const updateExpressionFields: string[] = [
229-
'#response_data = :response_data',
230229
'#expiry = :expiry',
231230
'#status = :status',
232231
];
233232
const expressionAttributeNames: Record<string, string> = {
234-
'#response_data': this.dataAttr,
235233
'#expiry': this.expiryAttr,
236234
'#status': this.statusAttr,
237235
};
238236
const expressionAttributeValues: Record<string, unknown> = {
239-
':response_data': record.responseData,
240237
':expiry': record.expiryTimestamp,
241238
':status': record.getStatus(),
242239
};
243240

241+
if (record.responseData !== undefined) {
242+
updateExpressionFields.push('#response_data = :response_data');
243+
expressionAttributeNames['#response_data'] = this.dataAttr;
244+
expressionAttributeValues[':response_data'] = record.responseData;
245+
}
246+
244247
if (this.isPayloadValidationEnabled()) {
245248
updateExpressionFields.push('#validation_key = :validation_key');
246249
expressionAttributeNames['#validation_key'] = this.validationKeyAttr;

Diff for: packages/idempotency/tests/e2e/idempotentDecorator.test.FunctionCode.ts

+3-2
Original file line numberDiff line numberDiff line change
@@ -29,12 +29,13 @@ class DefaultLambda implements LambdaInterface {
2929
public async handler(
3030
_event: Record<string, unknown>,
3131
_context: Context
32-
): Promise<string> {
32+
): Promise<void> {
3333
logger.info(`Got test event: ${JSON.stringify(_event)}`);
3434
// sleep to enforce error with parallel execution
3535
await new Promise((resolve) => setTimeout(resolve, 1000));
3636

37-
return 'Hello World';
37+
// We return void to test that the utility handles it correctly
38+
return;
3839
}
3940

4041
@idempotent({

Diff for: packages/idempotency/tests/e2e/idempotentDecorator.test.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -193,7 +193,7 @@ describe('Idempotency e2e test decorator, default settings', () => {
193193
expect(idempotencyRecord.Items?.[0].id).toEqual(
194194
`${functionNameDefault}#${payloadHash}`
195195
);
196-
expect(idempotencyRecord.Items?.[0].data).toEqual('Hello World');
196+
expect(idempotencyRecord.Items?.[0].data).toBeUndefined();
197197
expect(idempotencyRecord.Items?.[0].status).toEqual('COMPLETED');
198198
// During the first invocation the handler should be called, so the logs should contain 1 log
199199
expect(functionLogs[0]).toHaveLength(1);

Diff for: packages/idempotency/tests/unit/persistence/DynamoDbPersistenceLayer.test.ts

+37-2
Original file line numberDiff line numberDiff line change
@@ -604,7 +604,7 @@ describe('Class: DynamoDBPersistenceLayer', () => {
604604
id: dummyKey,
605605
}),
606606
UpdateExpression:
607-
'SET #response_data = :response_data, #expiry = :expiry, #status = :status',
607+
'SET #expiry = :expiry, #status = :status, #response_data = :response_data',
608608
ExpressionAttributeNames: {
609609
'#status': 'status',
610610
'#expiry': 'expiration',
@@ -618,6 +618,41 @@ describe('Class: DynamoDBPersistenceLayer', () => {
618618
});
619619
});
620620

621+
it('updates the item when the response_data is undefined', async () => {
622+
// Prepare
623+
const persistenceLayer = new TestDynamoDBPersistenceLayer({
624+
tableName: dummyTableName,
625+
});
626+
const status = IdempotencyRecordStatus.EXPIRED;
627+
const expiryTimestamp = Date.now();
628+
const record = new IdempotencyRecord({
629+
idempotencyKey: dummyKey,
630+
status,
631+
expiryTimestamp,
632+
responseData: undefined,
633+
});
634+
635+
// Act
636+
persistenceLayer._updateRecord(record);
637+
638+
// Assess
639+
expect(client).toReceiveCommandWith(UpdateItemCommand, {
640+
TableName: dummyTableName,
641+
Key: marshall({
642+
id: dummyKey,
643+
}),
644+
UpdateExpression: 'SET #expiry = :expiry, #status = :status',
645+
ExpressionAttributeNames: {
646+
'#status': 'status',
647+
'#expiry': 'expiration',
648+
},
649+
ExpressionAttributeValues: marshall({
650+
':status': IdempotencyRecordStatus.EXPIRED,
651+
':expiry': expiryTimestamp,
652+
}),
653+
});
654+
});
655+
621656
test('when called to update a record and payload validation is enabled, it adds the payload hash to the update expression', async () => {
622657
// Prepare
623658
const persistenceLayer = new TestDynamoDBPersistenceLayer({
@@ -646,7 +681,7 @@ describe('Class: DynamoDBPersistenceLayer', () => {
646681
id: dummyKey,
647682
}),
648683
UpdateExpression:
649-
'SET #response_data = :response_data, #expiry = :expiry, #status = :status, #validation_key = :validation_key',
684+
'SET #expiry = :expiry, #status = :status, #response_data = :response_data, #validation_key = :validation_key',
650685
ExpressionAttributeNames: {
651686
'#status': 'status',
652687
'#expiry': 'expiration',

0 commit comments

Comments
 (0)