UNPKG

lifion-kinesis

Version:

Lifion client for Amazon Kinesis Data streams

916 lines (858 loc) 31 kB
/** * Module that maintains the state of the consumer in a DynamoDB table. * * @module state-store * @private */ 'use strict'; const projectName = require('project-name'); const { generate } = require('short-uuid'); const { hostname } = require('os'); const DynamoDbClient = require('./dynamodb-client'); const { confirmTableTags, ensureTableExists } = require('./table'); const { name: moduleName } = require('../package.json'); const appName = projectName(process.cwd()); const host = hostname(); const privateData = new WeakMap(); const { pid, uptime } = process; /** * Provides access to the private data of the specified instance. * * @param {Object} instance - The private data's owner. * @returns {Object} The private data. * @private */ function internal(instance) { if (!privateData.has(instance)) privateData.set(instance, {}); return privateData.get(instance); } /** * Retrieves the stream state. * * @param {Object} instance - The instance of the state store to get the private data from. * @returns {Promise} * @private */ async function getStreamState(instance) { const privateProps = internal(instance); const { client, consumerGroup, streamName } = privateProps; const { Item } = await client.get({ ConsistentRead: true, Key: { consumerGroup, streamName } }); return Item; } /** * Ensures there's an entry for the current stream in the state database. * * @param {Object} instance - The instance of the state store to get the private data from. * @returns {Promise} * @private */ async function initStreamState(instance) { const privateProps = internal(instance); const { client, consumerGroup, logger, streamCreatedOn, streamName } = privateProps; const Key = { consumerGroup, streamName }; const { Item } = await client.get({ Key }); if (Item && Item.streamCreatedOn !== streamCreatedOn) { await client.delete({ Key }); logger.warn('Stream state has been reset. Non-matching stream creation timestamp.'); } try { await client.put({ ConditionExpression: 'attribute_not_exists(streamName)', Item: { consumerGroup, consumers: {}, enhancedConsumers: {}, shards: {}, streamCreatedOn, streamName, version: generate() } }); logger.debug('Initial state has been recorded for the stream.'); } catch (err) { if (err.code !== 'ConditionalCheckFailedException') { logger.error(err); throw err; } } } /** * Updates the "is active" flag of a consumer. This is useful for when a consumer is using * enhanced fan-out but is unable to use one of the registered enhanced consumers. When non * active, a consumer isn't used when calculating the maximum active number of leases. * * @param {Object} instance - The instance of the state store to get the private data from. * @param {boolean} isActive - The value to set the "is active" flag to. * @fulfil {undefined} * @returns {Promise} */ async function updateConsumerIsActive(instance, isActive) { const { client, consumerGroup, consumerId, logger, streamName } = internal(instance); try { await client.update({ ExpressionAttributeNames: { '#a': 'consumers', '#b': consumerId, '#c': 'isActive' }, ExpressionAttributeValues: { ':z': isActive }, Key: { consumerGroup, streamName }, UpdateExpression: 'SET #a.#b.#c = :z' }); } catch { logger.debug("Can't update the is consumer active flag."); } } /** * Tries to lock an enhanced fan-out consumer to this consumer. * * @param {Object} instance - The instance of the state store to get the private data from. * @param {string} consumerName - The name of the enhanced fan-out consumer to lock. * @param {string} version - The known version number of the enhanced consumer state entry. * @fulfil {boolean} - `true` if the enhanced consumer was locked, `false` otherwise. * @returns {Promise} */ async function lockEnhancedConsumer(instance, consumerName, version) { const { client, consumerGroup, consumerId, logger, streamName, useAutoShardAssignment } = internal(instance); try { await client.update({ ConditionExpression: `#a.#b.#d = :z AND #a.#b.#c = :v`, ExpressionAttributeNames: { '#a': 'enhancedConsumers', '#b': consumerName, '#c': 'isUsedBy', '#d': 'version', ...(!useAutoShardAssignment && { '#e': 'shards' }) }, ExpressionAttributeValues: { ':v': null, ':x': consumerId, ':y': generate(), ':z': version, ...(!useAutoShardAssignment && { ':w': {} }) }, Key: { consumerGroup, streamName }, UpdateExpression: `SET #a.#b.#c = :x, #a.#b.#d = :y${ !useAutoShardAssignment ? ', #a.#b.#e = if_not_exists(#a.#b.#e, :w)' : '' }` }); return true; } catch (err) { if (err.code !== 'ConditionalCheckFailedException') { logger.error(err); throw err; } return false; } } /** * Class that encapsulates the DynamoDB table where the shared state for the stream is stored. * * @alias module:state-store */ class StateStore { /** * Initializes an instance of the state store. * * @param {Object} options - The initialization options. * @param {string} options.consumerGroup - The name of the group of consumers in which shards * will be distributed and checkpoints will be shared. * @param {string} options.consumerId - An unique ID representing the instance of this consumer. * @param {Object} options.dynamoDb - The initialization options passed to the Kinesis * client module, specific for the DynamoDB state data table. This object can also * contain any of the [`AWS.DynamoDB` options]{@link external:AwsJsSdkDynamoDb}. * @param {Object} [options.dynamoDb.provisionedThroughput] - The provisioned throughput for the * state table. If not provided, pay-per-request is used. * @param {string} [options.dynamoDb.tableName=lifion-kinesis-state] - The name of the * table where the shared state is stored. * @param {Object} [options.dynamoDb.tags={}] - If specified, the module will ensure * the table has these tags during start. * @param {Object} options.logger - An instance of a logger. * @param {string} options.streamCreatedOn - The creation timestamp for the stream. It's used * to confirm the stored state corresponds to the same stream with the given name. * @param {string} options.streamName - The name of the stream to keep state for. * @param {boolean} options.useAutoShardAssignment - Wheter if the stream shards should be * automatically assigned to the active consumers in the same group or not. * @param {boolean} options.useEnhancedFanOut - Whether if the consumer is using enhanced * fan-out consumers or not. */ constructor(options) { const { consumerGroup, consumerId, dynamoDb: { provisionedThroughput, tableName, tags, ...awsOptions }, logger, streamCreatedOn, streamName, useAutoShardAssignment, useEnhancedFanOut } = options; Object.assign(internal(this), { awsOptions, consumerGroup, consumerId, logger, provisionedThroughput, streamCreatedOn, streamName, tableName: tableName || `${moduleName}-state`, tags, useAutoShardAssignment, useEnhancedFanOut }); } /** * Clears out consumers that are considered to be gone as they have failed to record a * hearbeat in a given timeout period. In addition to clearing out the consumers, any shard * with an active lease for consumers that are gone will be released. Any enhanced fan-out * consumers in use by gone consumers will also be released. * * @param {number} heartbeatFailureTimeout - The number of milliseconds after a heartbeat when * a consumer should be considered as gone. * @fulfil {undefined} * @returns {Promise} */ async clearOldConsumers(heartbeatFailureTimeout) { const privateProps = internal(this); const { client, consumerGroup, logger, streamName } = privateProps; const { consumers, enhancedConsumers, version } = await getStreamState(this); const consumerIds = Object.keys(consumers); const oldConsumers = consumerIds.filter((id) => { const { heartbeat } = consumers[id]; return Date.now() - new Date(heartbeat).getTime() > heartbeatFailureTimeout; }); if (oldConsumers.length > 0) { try { await client.update({ ConditionExpression: `#b = :y`, ExpressionAttributeNames: { '#a': 'consumers', '#b': 'version', ...oldConsumers.reduce((obj, id, index) => ({ ...obj, [`#${index}`]: id }), {}) }, ExpressionAttributeValues: { ':x': generate(), ':y': version }, Key: { consumerGroup, streamName }, UpdateExpression: `REMOVE ${oldConsumers .map((id, index) => `#a.#${index}`) .join(', ')} SET #b = :x` }); logger.debug(`Cleared ${oldConsumers.length} old consumer(s).`); } catch (err) { if (err.code !== 'ConditionalCheckFailedException') { logger.error(err); throw err; } } } const usagesToClear = Object.keys(enhancedConsumers).filter((consumerName) => { const { isUsedBy } = enhancedConsumers[consumerName]; if (isUsedBy == null) { return false; } if (oldConsumers.includes(isUsedBy)) { logger.debug(`Enhanced consumer "${consumerName}" can be released, missed heartbeat.`); return true; } if (!consumerIds.includes(isUsedBy)) { logger.debug(`Enhanced consumer "${consumerName}" can be released, unknown owner.`); return true; } return false; }); await Promise.all( usagesToClear.map(async (consumerName) => { const { isUsedBy, version: ver } = enhancedConsumers[consumerName]; try { await client.update({ ConditionExpression: '#a.#b.#c = :w AND #a.#b.#d = :x', ExpressionAttributeNames: { '#a': 'enhancedConsumers', '#b': consumerName, '#c': 'isUsedBy', '#d': 'version' }, ExpressionAttributeValues: { ':w': isUsedBy, ':x': ver, ':y': null, ':z': generate() }, Key: { consumerGroup, streamName }, UpdateExpression: 'SET #a.#b.#c = :y, #a.#b.#d = :z' }); logger.debug(`Enhanced consumer "${consumerName}" has been released.`); } catch (err) { if (err.code !== 'ConditionalCheckFailedException') { logger.error(err); throw err; } logger.debug(`Enhanced consumer "${consumerName}" can't be released.`); } }) ); } /** * Removes an enhanced fan-out consumer from the stream state. * * @param {string} name - The name of the enhanced consumer to remove. * @fulfil {undefined} * @returns {Promise} */ async deregisterEnhancedConsumer(name) { const { client, consumerGroup, logger, streamName } = internal(this); try { await client.update({ ConditionExpression: 'attribute_exists(#a.#b)', ExpressionAttributeNames: { '#a': 'enhancedConsumers', '#b': name }, Key: { consumerGroup, streamName }, UpdateExpression: 'REMOVE #a.#b' }); logger.debug(`The enhanced consumer "${name}" is now de-registered.`); } catch (err) { if (err.code !== 'ConditionalCheckFailedException') { logger.error(err); throw err; } } } /** * Ensures there's an entry for the given shard ID and data in the stream state. * * @param {string} shardId - The ID of the stream shard. * @param {Object} shardData - The data describing the shard as returned by the AWS Kinesis API. * @param {Object} [streamState] - The current stream state, if known. * @fulfil {undefined} * @returns {Promise} */ async ensureShardStateExists(shardId, shardData, streamState) { const privateProps = internal(this); const { shardsPath, shardsPathNames } = await this.getShardsData(streamState); const { client, consumerGroup, logger, streamName } = privateProps; const { parent } = shardData; try { await client.update({ ConditionExpression: `attribute_not_exists(${shardsPath}.#b)`, ExpressionAttributeNames: { ...shardsPathNames, '#b': shardId }, ExpressionAttributeValues: { ':x': { checkpoint: null, depleted: false, leaseExpiration: null, leaseOwner: null, parent, version: generate() } }, Key: { consumerGroup, streamName }, UpdateExpression: `SET ${shardsPath}.#b = :x` }); } catch (err) { if (err.code !== 'ConditionalCheckFailedException') { logger.error(err); throw err; } } } /** * Returns the ARN of the enhanced fan-out consumer assigned to this consumer. It will try to * lock one if the consumer is using enhanced fan-out but hasn't been assigned one before. * * @fulfil {string} - The ARN of the assigned enhanced fan-out consumer, `null` otherwise. * @returns {Promise} */ async getAssignedEnhancedConsumer() { const { consumerId, logger } = internal(this); let consumerArn; let consumerName; const enhancedConsumers = await this.getEnhancedConsumers(); const consumerNames = Object.keys(enhancedConsumers); // Find out an enhanced consumer was already assigned. consumerNames.find((name) => { const { arn, isUsedBy } = enhancedConsumers[name]; if (isUsedBy === consumerId) { consumerName = name; consumerArn = arn; return true; } return false; }); // Try to assign an enhanced consumer from the available ones. if (!consumerArn) { const availableConsumers = consumerNames.filter((name) => !enhancedConsumers[name].isUsedBy); for (let i = 0; i < availableConsumers.length; i += 1) { const name = availableConsumers[i]; const { arn, version } = enhancedConsumers[name]; if (await lockEnhancedConsumer(this, name, version)) { consumerArn = arn; consumerName = name; break; } } } if (!consumerArn) { logger.warn(`All enhanced fan-out consumers are assigned. Waiting until one is available…`); await updateConsumerIsActive(this, false); return null; } await updateConsumerIsActive(this, true); logger.debug(`Using the "${consumerName}" enhanced fan-out consumer.`); return consumerArn; } /** * Returns the data for the enhanced fan-out consumers stored in the state. * * @fulfil {Object} - The enhanced consumers. * @returns {Promise} */ async getEnhancedConsumers() { const { enhancedConsumers } = await getStreamState(this); return enhancedConsumers; } /** * Returns an object with the state of the shards for which this consumer has an active lease. * * @fulfil {Object} - An object with the state of the owned shards. * @returns {Promise} */ async getOwnedShards() { const { consumerId } = internal(this); const streamState = await getStreamState(this); const { shards } = await this.getShardsData(streamState); return Object.keys(shards) .filter((shardId) => shards[shardId].leaseOwner === consumerId) .reduce((obj, shardId) => { const { checkpoint, depleted, leaseExpiration, version } = shards[shardId]; if (new Date(leaseExpiration).getTime() - Date.now() > 0 && !depleted) return { ...obj, [shardId]: { checkpoint, leaseExpiration, version } }; return obj; }, {}); } /** * Returns an object with the current states for a given shard and the entire stream. * * @param {string} shardId - The ID of the stream shard to get the state for. * @param {Object} shardData - The data describing the shard as provided by the AWS Kinesis API. * @fulfil {Object} - An object containing `shardState` (the state of the shard) and * `streamState` (the stream state). * @returns {Promise} */ async getShardAndStreamState(shardId, shardData) { const getState = async () => { const streamState = await getStreamState(this); const { shards } = await this.getShardsData(streamState); const shardState = shards[shardId]; return { shardState, streamState }; }; const states = await getState(); if (states.shardState !== undefined) return states; await this.ensureShardStateExists(shardId, shardData, states.streamState); return getState(); } /** * Returns the current state of the stream shards and pointers that can be used to update the * stream shard state in subsequent calls. This is useful as the shards state is stored in * different locations depending on the consumer usage scenario. * * - When using automatic shard distribution, the shards state is stored in `.shards`. * - When reading from all shards, the shards state is stored in `.consumers[].shards`. * - When reading from all the shards and using enhanced fan-out consumers, the shards state * is stored in `.enhancedConsumers[].shards`. * * @param {Object} [streamState] - The current state for the entire stream, if not provided, * the stream state is fetched. This parameter is useful to avoid repeated calls for * the stream state retrieval if that information is already present. * @fulfil {Object} - An object containing `shards` (the current shards state), `shardsPath` * (a string pointing to the path to where the shards state is stored), and * `shardsPathNames` (an object with the value for the path attributes that point to the * place where the shards state is stored). Both `shardsPath` and `shardsPathNames` are * to be used in `update` expressions. * @returns {Promise} */ async getShardsData(streamState) { const { consumerId, useAutoShardAssignment, useEnhancedFanOut } = internal(this); const normStreamState = !streamState ? await getStreamState(this) : streamState; const { consumers, enhancedConsumers } = normStreamState; if (!useAutoShardAssignment) { if (useEnhancedFanOut) { const enhancedConsumerState = Object.entries(enhancedConsumers).find( ([, value]) => value.isUsedBy === consumerId ); if (!enhancedConsumerState) { throw new Error('The enhanced consumer state is not where expected.'); } const [consumerName, { shards }] = enhancedConsumerState; return { shards, shardsPath: '#a0.#a1.#a2', shardsPathNames: { '#a0': 'enhancedConsumers', '#a1': consumerName, '#a2': 'shards' } }; } const { shards } = consumers[consumerId]; return { shards, shardsPath: '#a0.#a1.#a2', shardsPathNames: { '#a0': 'consumers', '#a1': consumerId, '#a2': 'shards' } }; } const { shards } = normStreamState; return { shards, shardsPath: '#a', shardsPathNames: { '#a': 'shards' } }; } /** * Tries to lock the lease of a given shard. * * @param {string} shardId - The ID of the shard to lock a lease for. * @param {number} leaseTermTimeout - The duration of the lease in milliseconds. * @param {string} version - The known version number of the shard state entry. * @param {Object} streamState - The known stream state. * @fulfil {boolean} - `true` if the lease was successfuly locked, `false` otherwise. * @returns {Promise} */ async lockShardLease(shardId, leaseTermTimeout, version, streamState) { const { client, consumerGroup, consumerId, logger, streamName } = internal(this); const { shardsPath, shardsPathNames } = await this.getShardsData(streamState); try { await client.update({ ConditionExpression: `${shardsPath}.#b.#e = :z`, ExpressionAttributeNames: { ...shardsPathNames, '#b': shardId, '#c': 'leaseOwner', '#d': 'leaseExpiration', '#e': 'version' }, ExpressionAttributeValues: { ':w': consumerId, ':x': new Date(Date.now() + leaseTermTimeout).toISOString(), ':y': generate(), ':z': version }, Key: { consumerGroup, streamName }, UpdateExpression: `SET ${[ `${shardsPath}.#b.#c = :w`, `${shardsPath}.#b.#d = :x`, `${shardsPath}.#b.#e = :y` ].join(', ')}` }); return true; } catch (err) { if (err.code !== 'ConditionalCheckFailedException') { logger.error(err); throw err; } return false; } } /** * Marks a shard as depleted in the stream state so children shards can be leased. * * @param {Object} shardsData - The current shards state. * @param {string} parentShardId - The ID of the shard to mark as depleted. * @fulfil {undefined} * @returns {Promise} */ async markShardAsDepleted(shardsData, parentShardId) { const { client, consumerGroup, streamName } = internal(this); const streamState = await getStreamState(this); const { shards, shardsPath, shardsPathNames } = await this.getShardsData(streamState); const parentShard = shards[parentShardId]; const childrenShards = parentShard.checkpoint ? Object.keys(shardsData) .filter((shardId) => shardsData[shardId].parent === parentShardId) .map((shardId) => { const { startingSequenceNumber } = shardsData[shardId]; return { shardId, startingSequenceNumber }; }) : []; await Promise.all( childrenShards.map((childrenShard) => { return this.ensureShardStateExists( childrenShard.shardId, shardsData[childrenShard.shardId], streamState ); }) ); await client.update({ ExpressionAttributeNames: { ...shardsPathNames, '#b': parentShardId, '#c': 'depleted', '#d': 'version', ...(childrenShards.length > 0 && { '#e': 'checkpoint' }), ...childrenShards.reduce( (obj, childShard, index) => ({ ...obj, [`#${index}`]: childShard.shardId }), {} ) }, ExpressionAttributeValues: { ':x': true, ':y': generate(), ...childrenShards.reduce( (obj, childShard, index) => ({ ...obj, [`:${index * 2}`]: childShard.startingSequenceNumber, [`:${index * 2 + 1}`]: generate() }), {} ) }, Key: { consumerGroup, streamName }, UpdateExpression: `SET ${[ `${shardsPath}.#b.#c = :x`, `${shardsPath}.#b.#d = :y`, ...childrenShards.map((childShard, index) => [ `${shardsPath}.#${index}.#e = :${index * 2}`, `${shardsPath}.#${index}.#d = :${index * 2 + 1}` ].join(', ') ) ].join(', ')}` }); } /** * Registers the current consumer in the state if not present there yet. If present, * it updates the consumer hearbeat. * * @fulfil {undefined} * @returns {Promise} */ async registerConsumer() { const { client, consumerGroup, consumerId, logger, streamName, useAutoShardAssignment, useEnhancedFanOut } = internal(this); try { await client.update({ ConditionExpression: 'attribute_not_exists(#a.#b)', ExpressionAttributeNames: { '#a': 'consumers', '#b': consumerId }, ExpressionAttributeValues: { ':x': { appName, heartbeat: new Date().toISOString(), host, isActive: true, isStandalone: !useAutoShardAssignment, pid, startedOn: new Date(Date.now() - uptime() * 1000).toISOString(), ...(!useAutoShardAssignment && !useEnhancedFanOut && { shards: {} }) } }, Key: { consumerGroup, streamName }, UpdateExpression: 'SET #a.#b = :x' }); logger.debug(`The consumer "${consumerId}" is now registered.`); } catch (err) { if (err.code === 'ConditionalCheckFailedException') { await client .update({ ExpressionAttributeNames: { '#a': 'consumers', '#b': consumerId, '#c': 'heartbeat' }, ExpressionAttributeValues: { ':x': new Date().toISOString() }, Key: { consumerGroup, streamName }, UpdateExpression: 'SET #a.#b.#c = :x' }) .catch(() => { logger.debug(`Missed heartbeat for "${consumerId}".`); }); return; } logger.error(err); throw err; } } /** * Makes sure that an enhanced fan-out consumer is present in the stream state. * * @param {string} name - The name of the enhanced fan-out consumer. * @param {string} arn - The ARN of the enhanced fan-out consumer as given by AWS. * @fulfil {undefined} * @returns {Promise} */ async registerEnhancedConsumer(name, arn) { const { client, consumerGroup, logger, streamName, useAutoShardAssignment } = internal(this); try { await client.update({ ConditionExpression: 'attribute_not_exists(#a.#b)', ExpressionAttributeNames: { '#a': 'enhancedConsumers', '#b': name }, ExpressionAttributeValues: { ':x': { arn, isStandalone: !useAutoShardAssignment, isUsedBy: null, version: generate(), ...(!useAutoShardAssignment && { shards: {} }) } }, Key: { consumerGroup, streamName }, UpdateExpression: 'SET #a.#b = :x' }); logger.debug(`The enhanced consumer "${name}" is now registered.`); } catch (err) { if (err.code !== 'ConditionalCheckFailedException') { logger.error(err); throw err; } } } /** * Tries to release the lease of a shard. * * @param {string} shardId - The ID of the shard to release a lease for. * @param {string} version - The known version number of the shard state entry. * @param {Object} streamState - The known stream state. * @fulfil {string} - The new version number if the lease is released, `null` otherwise. * @returns {Promise} */ async releaseShardLease(shardId, version, streamState) { const privateProps = internal(this); const { client, consumerGroup, logger, streamName } = privateProps; const { shardsPath, shardsPathNames } = await this.getShardsData(streamState); const releasedVersion = generate(); try { await client.update({ ConditionExpression: `${shardsPath}.#b.#e = :z`, ExpressionAttributeNames: { ...shardsPathNames, '#b': shardId, '#c': 'leaseOwner', '#d': 'leaseExpiration', '#e': 'version' }, ExpressionAttributeValues: { ':w': null, ':x': null, ':y': releasedVersion, ':z': version }, Key: { consumerGroup, streamName }, UpdateExpression: `SET ${[ `${shardsPath}.#b.#c = :w`, `${shardsPath}.#b.#d = :x`, `${shardsPath}.#b.#e = :y` ].join(', ')}` }); return releasedVersion; } catch (err) { if (err.code !== 'ConditionalCheckFailedException') { logger.error(err); throw err; } return null; } } /** * Starts the state store by initializing a DynamoDB client and a document client. Then, * it will ensure the table exists, that is tagged as required, and there's an entry for * the stream state. * * @fulfil {undefined} * @returns {Promise} */ async start() { const privateProps = internal(this); const { tags } = privateProps; const client = new DynamoDbClient(privateProps); privateProps.client = client; privateProps.tableArn = await ensureTableExists(privateProps); if (tags) await confirmTableTags(privateProps); await initStreamState(this); } /** * Store a shard checkpoint. * * @param {string} shardId - The ID of the shard to store a checkpoint for. * @param {string} checkpoint - The sequence number to store as the recovery point. * @param {string} shardsPath - The path pointing to where the shards state is stored. * @param {Object} shardsPathNames - The values of the attribute names in the path. * @param {Date} approximateArrivalTimestamp - The approximate arrival timestamp. * @fulfil {undefined} * @returns {Promise} */ async storeShardCheckpoint( shardId, checkpoint, shardsPath, shardsPathNames, approximateArrivalTimestamp ) { const privateProps = internal(this); const { logger } = privateProps; if (typeof checkpoint !== 'string') throw new TypeError('The sequence number is required.'); let approximateArrivalTimestampString = null; if (approximateArrivalTimestamp instanceof Date) { approximateArrivalTimestampString = approximateArrivalTimestamp.toISOString(); } else if (approximateArrivalTimestamp) { logger.warn( `Will ignore approx arrival timestamp provided, it was not a date: ${JSON.stringify( approximateArrivalTimestamp )}` ); } else { logger.warn('Will ignore approx arrival timestamp provided, it was null'); } const { client, consumerGroup, streamName } = internal(this); await client.update({ ExpressionAttributeNames: { ...shardsPathNames, '#b': shardId, '#c': 'approximateArrivalTimestamp', '#d': 'checkpoint', '#e': 'version' }, ExpressionAttributeValues: { ':x': approximateArrivalTimestampString, ':y': checkpoint, ':z': generate() }, Key: { consumerGroup, streamName }, UpdateExpression: `SET ${shardsPath}.#b.#c = :x, ${shardsPath}.#b.#d = :y, ${shardsPath}.#b.#e = :z` }); } } /** * @external AwsJsSdkDynamoDb * @see https://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/DynamoDB.html#constructor-property */ module.exports = StateStore;