UNPKG

@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
"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;