@aws-lambda-powertools/idempotency
Version:
The idempotency package for the Powertools for AWS Lambda (TypeScript) library. It provides options to make your Lambda functions idempotent and safe to retry.
243 lines (242 loc) • 11.1 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.DynamoDBPersistenceLayer = void 0;
const commons_1 = require("@aws-lambda-powertools/commons");
const client_dynamodb_1 = require("@aws-sdk/client-dynamodb");
const util_dynamodb_1 = require("@aws-sdk/util-dynamodb");
const constants_js_1 = require("../constants.js");
const errors_js_1 = require("../errors.js");
const BasePersistenceLayer_js_1 = require("./BasePersistenceLayer.js");
const IdempotencyRecord_js_1 = require("./IdempotencyRecord.js");
/**
* DynamoDB persistence layer for idempotency records.
*
* This class uses the AWS SDK for JavaScript v3 to write and read idempotency records from DynamoDB.
*
* There are various options to configure the persistence layer, such as the table name, the key attribute, the status attribute, etc.
*
* With default configuration you don't need to create the client beforehand, the persistence layer will create it for you.
* You can also bring your own AWS SDK V3 client, or configure the client with the `clientConfig` option.
*
* See the {@link https://docs.powertools.aws.dev/lambda/python/latest/features/idempotency/ Idempotency documentation} for more details
* on the IAM permissions and DynamoDB table configuration.
*
* @example
* ```ts
* import { DynamoDBPersistenceLayer } from '@aws-lambda-powertools/idempotency/dynamodb';
*
* const persistence = new DynamoDBPersistenceLayer({
* tableName: 'my-idempotency-table',
* });
* ```
*
* @see https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/clients/client-dynamodb/index.html
* @category Persistence Layer
*/
class DynamoDBPersistenceLayer extends BasePersistenceLayer_js_1.BasePersistenceLayer {
client;
dataAttr;
expiryAttr;
inProgressExpiryAttr;
keyAttr;
sortKeyAttr;
staticPkValue;
statusAttr;
tableName;
validationKeyAttr;
constructor(config) {
super();
this.tableName = config.tableName;
this.keyAttr = config.keyAttr ?? 'id';
this.statusAttr =
config.statusAttr ?? constants_js_1.PERSISTENCE_ATTRIBUTE_KEY_MAPPINGS.statusAttr;
this.expiryAttr =
config.expiryAttr ?? constants_js_1.PERSISTENCE_ATTRIBUTE_KEY_MAPPINGS.expiryAttr;
this.inProgressExpiryAttr =
config.inProgressExpiryAttr ??
constants_js_1.PERSISTENCE_ATTRIBUTE_KEY_MAPPINGS.inProgressExpiryAttr;
this.dataAttr =
config.dataAttr ?? constants_js_1.PERSISTENCE_ATTRIBUTE_KEY_MAPPINGS.dataAttr;
this.validationKeyAttr =
config.validationKeyAttr ??
constants_js_1.PERSISTENCE_ATTRIBUTE_KEY_MAPPINGS.validationKeyAttr;
if (config.sortKeyAttr === this.keyAttr) {
throw new Error(`keyAttr [${this.keyAttr}] and sortKeyAttr [${config.sortKeyAttr}] cannot be the same!`);
}
this.sortKeyAttr = config.sortKeyAttr;
this.staticPkValue =
config.staticPkValue ?? `idempotency#${this.idempotencyKeyPrefix}`;
if (config.awsSdkV3Client) {
if (!(0, commons_1.isSdkClient)(config.awsSdkV3Client)) {
console.warn('awsSdkV3Client is not an AWS SDK v3 client, using default client');
this.client = new client_dynamodb_1.DynamoDBClient(config.clientConfig ?? {});
}
else {
this.client = config.awsSdkV3Client;
}
}
else {
this.client = new client_dynamodb_1.DynamoDBClient(config.clientConfig ?? {});
}
(0, commons_1.addUserAgentMiddleware)(this.client, 'idempotency');
}
async _deleteRecord(record) {
await this.client.send(new client_dynamodb_1.DeleteItemCommand({
TableName: this.tableName,
Key: this.getKey(record.idempotencyKey),
}));
}
async _getRecord(idempotencyKey) {
const result = await this.client.send(new client_dynamodb_1.GetItemCommand({
TableName: this.tableName,
Key: this.getKey(idempotencyKey),
ConsistentRead: true,
}));
if (!result.Item) {
throw new errors_js_1.IdempotencyItemNotFoundError();
}
const item = (0, util_dynamodb_1.unmarshall)(result.Item);
return new IdempotencyRecord_js_1.IdempotencyRecord({
idempotencyKey: item[this.keyAttr],
sortKey: this.sortKeyAttr && item[this.sortKeyAttr],
status: item[this.statusAttr],
expiryTimestamp: item[this.expiryAttr],
inProgressExpiryTimestamp: item[this.inProgressExpiryAttr],
responseData: item[this.dataAttr],
payloadHash: item[this.validationKeyAttr],
});
}
async _putRecord(record) {
const item = {
...this.getKey(record.idempotencyKey),
...(0, util_dynamodb_1.marshall)({
[this.expiryAttr]: record.expiryTimestamp,
[this.statusAttr]: record.getStatus(),
}),
};
if (record.inProgressExpiryTimestamp !== undefined) {
item[this.inProgressExpiryAttr] = {
N: record.inProgressExpiryTimestamp.toString(),
};
}
if (this.isPayloadValidationEnabled() && record.payloadHash !== undefined) {
item[this.validationKeyAttr] = {
S: record.payloadHash,
};
}
try {
/**
* | LOCKED | RETRY if status = "INPROGRESS" | RETRY
* |----------------|-------------------------------------------------------|-------------> .... (time)
* | Lambda Idempotency Record
* | Timeout Timeout
* | (in_progress_expiry) (expiry)
*
* Conditions to successfully save a record:
* * The idempotency key does not exist:
* - first time that this invocation key is used
* - previous invocation with the same key was deleted due to TTL
*/
const idempotencyKeyDoesNotExist = 'attribute_not_exists(#id)';
// * The idempotency key exists but it is expired
const idempotencyKeyExpired = '#expiry < :now';
// * The status of the record is "INPROGRESS", there is an in-progress expiry timestamp, but it's expired
const inProgressExpiryExpired = [
'#status = :inprogress',
'attribute_exists(#in_progress_expiry)',
'#in_progress_expiry < :now_in_millis',
].join(' AND ');
const conditionExpression = [
idempotencyKeyDoesNotExist,
idempotencyKeyExpired,
`(${inProgressExpiryExpired})`,
].join(' OR ');
const now = Date.now();
await this.client.send(new client_dynamodb_1.PutItemCommand({
TableName: this.tableName,
Item: item,
ExpressionAttributeNames: {
'#id': this.keyAttr,
'#expiry': this.expiryAttr,
'#in_progress_expiry': this.inProgressExpiryAttr,
'#status': this.statusAttr,
},
ExpressionAttributeValues: (0, util_dynamodb_1.marshall)({
':now': now / 1000,
':now_in_millis': now,
':inprogress': constants_js_1.IdempotencyRecordStatus.INPROGRESS,
}),
ConditionExpression: conditionExpression,
ReturnValuesOnConditionCheckFailure: 'ALL_OLD',
}));
}
catch (error) {
if (error instanceof client_dynamodb_1.ConditionalCheckFailedException) {
const item = error.Item && (0, util_dynamodb_1.unmarshall)(error.Item);
const idempotencyRecord = item &&
new IdempotencyRecord_js_1.IdempotencyRecord({
idempotencyKey: item[this.keyAttr],
sortKey: this.sortKeyAttr && item[this.sortKeyAttr],
status: item[this.statusAttr],
expiryTimestamp: item[this.expiryAttr],
inProgressExpiryTimestamp: item[this.inProgressExpiryAttr],
responseData: item[this.dataAttr],
payloadHash: item[this.validationKeyAttr],
});
throw new errors_js_1.IdempotencyItemAlreadyExistsError(`Failed to put record for already existing idempotency key: ${record.idempotencyKey}${this.sortKeyAttr ? ` and sort key: ${record.sortKey}` : ''}`, idempotencyRecord);
}
throw error;
}
}
async _updateRecord(record) {
const updateExpressionFields = [
'#expiry = :expiry',
'#status = :status',
];
const expressionAttributeNames = {
'#expiry': this.expiryAttr,
'#status': this.statusAttr,
};
const expressionAttributeValues = {
':expiry': record.expiryTimestamp,
':status': record.getStatus(),
};
if (record.responseData !== undefined) {
updateExpressionFields.push('#response_data = :response_data');
expressionAttributeNames['#response_data'] = this.dataAttr;
expressionAttributeValues[':response_data'] = record.responseData;
}
if (this.isPayloadValidationEnabled()) {
updateExpressionFields.push('#validation_key = :validation_key');
expressionAttributeNames['#validation_key'] = this.validationKeyAttr;
expressionAttributeValues[':validation_key'] = record.payloadHash;
}
await this.client.send(new client_dynamodb_1.UpdateItemCommand({
TableName: this.tableName,
Key: this.getKey(record.idempotencyKey),
UpdateExpression: `SET ${updateExpressionFields.join(', ')}`,
ExpressionAttributeNames: expressionAttributeNames,
ExpressionAttributeValues: (0, util_dynamodb_1.marshall)(expressionAttributeValues),
}));
}
/**
* Build primary key attribute simple or composite based on params.
*
* When sortKeyAttr is set, we must return a composite key with staticPkValue,
* otherwise we use the idempotency key given.
*
* @param idempotencyKey
*/
getKey(idempotencyKey) {
if (this.sortKeyAttr) {
return (0, util_dynamodb_1.marshall)({
[this.keyAttr]: this.staticPkValue,
[this.sortKeyAttr]: idempotencyKey,
});
}
return (0, util_dynamodb_1.marshall)({
[this.keyAttr]: idempotencyKey,
});
}
}
exports.DynamoDBPersistenceLayer = DynamoDBPersistenceLayer;