@mbc-cqrs-serverless/core
Version:
CQRS and event base core
376 lines • 19 kB
JavaScript
"use strict";
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
var __metadata = (this && this.__metadata) || function (k, v) {
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
};
var __param = (this && this.__param) || function (paramIndex, decorator) {
return function (target, key) { decorator(target, key, paramIndex); }
};
var CommandEventHandler_1;
Object.defineProperty(exports, "__esModule", { value: true });
exports.CommandEventHandler = void 0;
const common_1 = require("@nestjs/common");
const config_1 = require("@nestjs/config");
const sfn_name_enum_1 = require("../command-events/sfn-name.enum");
const data_store_1 = require("../data-store");
const key_1 = require("../helpers/key");
const queue_1 = require("../queue");
const step_function_service_1 = require("../step-func/step-function.service");
const command_module_definition_1 = require("./command.module-definition");
const command_service_1 = require("./command.service");
const data_service_1 = require("./data.service");
const status_enum_1 = require("./enums/status.enum");
const history_service_1 = require("./history.service");
let CommandEventHandler = CommandEventHandler_1 = class CommandEventHandler {
constructor(options, commandService, dataService, historyService, s3Service, snsService, config, sfnService) {
this.options = options;
this.commandService = commandService;
this.dataService = dataService;
this.historyService = historyService;
this.s3Service = s3Service;
this.snsService = snsService;
this.config = config;
this.sfnService = sfnService;
this.logger = new common_1.Logger(`${CommandEventHandler_1.name}:${this.options.tableName}`);
this.alarmTopicArn = this.config.get('SNS_ALARM_TOPIC_ARN');
}
async execute(event) {
this.logger.debug('executing::', event);
await this.commandService.updateStatus(event.commandKey, (0, status_enum_1.getCommandStatus)(event.stepStateName, status_enum_1.CommandStatus.STATUS_STARTED), event.commandRecord.requestId);
try {
const ret = await this.handleStepState(event);
await this.commandService.updateStatus(event.commandKey, (0, status_enum_1.getCommandStatus)(event.stepStateName, status_enum_1.CommandStatus.STATUS_FINISHED), event.commandRecord.requestId);
return ret;
}
catch (error) {
await this.commandService.updateStatus(event.commandKey, (0, status_enum_1.getCommandStatus)(event.stepStateName, status_enum_1.CommandStatus.STATUS_FAILED), event.commandRecord.requestId);
await this.publishAlarmSafely(event, error.stack);
throw error;
}
}
async handleStepState(event) {
switch (event.stepStateName) {
case sfn_name_enum_1.DataSyncCommandSfnName.CHECK_VERSION:
return await this.checkVersion(event);
case sfn_name_enum_1.DataSyncCommandSfnName.WAIT_PREV_COMMAND:
return await this.waitConfirmToken(event);
case sfn_name_enum_1.DataSyncCommandSfnName.SET_TTL_COMMAND:
return await this.setTtlCommand(event);
case sfn_name_enum_1.DataSyncCommandSfnName.HISTORY_COPY:
return await this.historyCopy(event);
case sfn_name_enum_1.DataSyncCommandSfnName.TRANSFORM_DATA:
return await this.transformData(event);
case sfn_name_enum_1.DataSyncCommandSfnName.SYNC_DATA:
return await this.syncData(event);
case sfn_name_enum_1.DataSyncCommandSfnName.FINISH:
return await this.checkNextToken(event);
default:
throw new Error('step function state not found!');
}
}
async waitConfirmToken(event) {
this.logger.debug('waitConfirmToken::', event);
await this.commandService.updateTaskToken(event.commandKey, event.taskToken);
if (event.commandRecord.version > 1) {
const prevSk = (0, key_1.addSortKeyVersion)((0, key_1.removeSortKeyVersion)(event.commandRecord.sk), event.commandRecord.version - 1);
let prevCommand;
let prevReadFailed = false;
try {
// consistentRead: true — predecessor status across independent SFN
// executions must not be a stale eventually-consistent read.
//
// Bounded retry (3 attempts, exponential backoff baseDelayMs * 2^(n-1)):
// best-effort check — updateTaskToken already succeeded; checkNextToken
// on the predecessor remains the primary resume path.
prevCommand = await this.getItemWithRetry({ pk: event.commandRecord.pk, sk: prevSk }, { consistentRead: true }, 3, 100);
}
catch (e) {
// After app + SDK retries, treat as persistent degradation of the
// self-resume backstop — do not fail the step (token already stored).
prevReadFailed = true;
this.logger.error(`[${event.commandKey.pk}] Could not read predecessor status for command v${event.commandRecord.version} after retries, self-resume backstop degraded: ` +
`${e instanceof Error ? e.message : 'Unknown error'}`, e instanceof Error ? e.stack : undefined);
await this.publishAlarmSafely(event, {
self_resume_predecessor_read_failed: true,
cause: e instanceof Error ? e.message : String(e),
});
}
// A successful read that returns no row is not the same as "predecessor
// has not reached finish yet" — the command chain is append-only, so for
// version > 1 the predecessor row must exist. Surface it instead of
// letting it fall through the same silent path as normal waiting.
if (!prevReadFailed && !prevCommand) {
this.logger.warn(`[${event.commandKey.pk}] Predecessor command v${event.commandRecord.version - 1} not found (sk: ${prevSk}) — ` +
`self-resume backstop cannot evaluate predecessor status`);
}
// Limitation: self-resume only when predecessor status is finish:STARTED
// or finish:FINISHED. Any predecessor exit before FINISH (wait_prev_command
// 24h timeout Pass→Fail with no Lambda/DDB update, version-mismatch fail,
// or *:FAILED mid-pipeline) leaves a non-finish status (often with a stale
// taskToken), so this check will not self-resume and this version may wait
// out its own 24h timeout (cascade).
const finishStarted = (0, status_enum_1.getCommandStatus)(sfn_name_enum_1.DataSyncCommandSfnName.FINISH, status_enum_1.CommandStatus.STATUS_STARTED);
const finishFinished = (0, status_enum_1.getCommandStatus)(sfn_name_enum_1.DataSyncCommandSfnName.FINISH, status_enum_1.CommandStatus.STATUS_FINISHED);
const prevEnteredFinish = prevCommand?.status === finishStarted ||
prevCommand?.status === finishFinished;
if (prevEnteredFinish) {
this.logger.log(`[${event.commandKey.pk}] Prev command already in finish step — self-resuming v${event.commandRecord.version}`);
try {
await this.sfnService.resumeExecution(event.taskToken, {
result: 'resumed_by_prev_version',
prevVersion: event.commandRecord.version - 1,
});
}
catch (e) {
await this.handleResumeExecutionError(event, e, {
benignNames: new Set(['TaskDoesNotExist', 'TaskTimedOut']),
logContext: `[${event.commandKey.pk}] Self-resume for v${event.commandRecord.version}`,
alarmPayload: { self_resume_failed: true },
});
}
}
}
return {
result: {
token: event.taskToken,
},
};
}
/**
* Retry wrapper around commandService.getItem for the cross-execution
* predecessor-status check in waitConfirmToken. Bounded and short —
* smooths a single transient DDB failure; does not wait out an outage.
*/
async getItemWithRetry(key, options, maxAttempts, baseDelayMs) {
return await this.withRetry(() => this.commandService.getItem(key, options), maxAttempts, baseDelayMs);
}
/**
* Bounded retry with exponential backoff (baseDelayMs * 2^(attempt - 1)),
* rethrowing the last error once every attempt is exhausted. Shared by both
* sides of the version handshake so the pull path (waitConfirmToken) and the
* push path (checkNextToken) tolerate transient DynamoDB failures equally.
*/
async withRetry(fn, maxAttempts, baseDelayMs) {
let lastError;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await fn();
}
catch (e) {
lastError = e;
if (attempt < maxAttempts) {
const delayMs = baseDelayMs * Math.pow(2, attempt - 1);
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
}
}
throw lastError;
}
async checkVersion(event) {
this.logger.debug('Checking version::', event.commandRecord);
const sk = (0, key_1.removeSortKeyVersion)(event.commandRecord.sk);
const data = await this.dataService.getItem({
pk: event.commandRecord.pk,
sk,
});
this.logger.debug('Checking version for data::', data);
const commandVersion = event.commandRecord.version;
const nextVersion = 1 + (data?.version || 0);
// consistentRead: true — the predecessor row is written by an independent
// SFN execution. A stale eventually-consistent miss makes oldCommand look
// absent, which returns result: 0 and routes straight to set_ttl_command,
// skipping wait_prev_command and letting a later version's sync_data land
// out of order. Matches the predecessor reads in waitConfirmToken and
// getNextCommand.
const oldCommand = await this.commandService.getItem({
pk: event.commandRecord.pk,
sk: (0, key_1.addSortKeyVersion)(sk, commandVersion - 1),
}, { consistentRead: true });
if (nextVersion === commandVersion) {
return {
result: 0,
};
}
if (nextVersion < commandVersion) {
if (!oldCommand) {
return {
result: 0,
};
}
// wait for previous version is stable
return {
result: 1,
};
}
const errorDetails = {
result: -1,
error: 'version is not match',
cause: 'next version must be ' + nextVersion + ' but got ' + commandVersion,
};
await this.publishAlarmSafely(event, errorDetails);
return errorDetails;
}
async setTtlCommand(event) {
this.logger.debug('setTtlCommand:: ', event.commandRecord);
await this.commandService.updateTtl({
pk: event.commandRecord.pk,
sk: event.commandRecord.sk,
});
return {
result: 'ok',
};
}
async historyCopy(event) {
this.logger.debug('historyCopy:: ', event.commandRecord);
await this.historyService.publish({
pk: event.commandRecord.pk,
sk: (0, key_1.removeSortKeyVersion)(event.commandRecord.sk),
});
return {
result: 'ok',
};
}
async transformData(event) {
this.logger.debug('transformData:: ', event.commandRecord);
const handlers = this.commandService.dataSyncHandlers;
if (handlers.length === 0) {
this.logger.warn(`[${this.options.tableName}] transformData: no DataSyncHandlers registered — ` +
`no sync will occur for ${this.options.tableName}`);
}
return handlers.map((cls) => ({
prevStateName: event.stepStateName,
result: cls.constructor.name,
}));
}
async syncData(event) {
this.logger.debug('syncData:: ', event.commandRecord);
const handlerName = event.input?.result;
if (!handlerName) {
throw new Error('SyncDataHandler not found!');
}
const handler = this.commandService.getDataSyncHandler(handlerName);
if (!handler) {
throw new Error('SyncDataHandler empty!');
}
const commandModel = await event.getFullCommandRecord(this.s3Service);
return handler.up(commandModel);
}
async checkNextToken(event) {
this.logger.debug('checkNextToken:: ', event.commandRecord);
let nextCommand;
try {
// Same bounded retry as the pull-side predecessor read in
// waitConfirmToken — without it a transient DDB failure here takes out
// both halves of the handshake at once.
nextCommand = await this.withRetry(() => this.commandService.getNextCommand(event.commandKey), 3, 100);
}
catch (e) {
// Deliberately does not rethrow. Failing this step makes execute() write
// finish:FAILED, and the successor's self-resume backstop only reacts to
// finish:STARTED|FINISHED — so the push path and the pull path would
// collapse together and the chain would stall until the 24h timeout.
// Returning normally lets execute() write finish:FINISHED, keeping the
// successor's self-resume armed for its own token-store pass. The step is
// reported as finished even though the push resume never ran; the alarm
// below is what records that, and the successor is the recovery path.
this.logger.error(`[${event.commandKey.pk}] Could not read next command after retries, ` +
`push resume skipped (successor self-resume remains armed): ` +
`${e instanceof Error ? e.message : 'Unknown error'}`, e instanceof Error ? e.stack : undefined);
await this.publishAlarmSafely(event, {
next_command_read_failed: true,
cause: e instanceof Error ? e.message : String(e),
});
return null;
}
if (!nextCommand) {
this.logger.debug('No next command version found. Chain ends.');
return null;
}
if (nextCommand.taskToken) {
this.logger.log(`Found waiting command v${nextCommand.version}. Resuming...`);
try {
await this.sfnService.resumeExecution(nextCommand.taskToken, {
result: 'resumed_by_prev_version',
prevVersion: event.commandRecord.version,
});
}
catch (e) {
await this.handleResumeExecutionError(event, e, {
benignNames: new Set(['TaskDoesNotExist']),
logContext: `[${event.commandKey.pk}] Resume for v${nextCommand.version} (sk: ${nextCommand.sk})`,
alarmPayload: {
push_resume_failed: true,
nextVersion: nextCommand.version,
nextSk: nextCommand.sk,
},
});
}
}
else {
this.logger.warn(`Next command v${nextCommand.version} found but no token. Status: ${nextCommand.status}`);
}
return null;
}
async handleResumeExecutionError(event, e, options) {
const name = e instanceof Error ? e.name : undefined;
if (name && options.benignNames.has(name)) {
this.logger.warn(`${options.logContext} already consumed (${name})`);
return;
}
this.logger.error(`${options.logContext} failed unexpectedly (${name ?? 'unknown'}): ` +
`${e instanceof Error ? e.message : 'Unknown error'}`, e instanceof Error ? e.stack : undefined);
await this.publishAlarmSafely(event, {
...options.alarmPayload,
errorName: name ?? 'unknown',
cause: e instanceof Error ? e.message : String(e),
});
}
/**
* Best-effort alarm publish — SNS failure must not fail the SFN step
* (e.g. after updateTaskToken already succeeded).
*/
async publishAlarmSafely(event, errorDetails) {
try {
await this.publishAlarm(event, errorDetails);
}
catch (alarmError) {
this.logger.error(`[${event.commandKey.pk}] publishAlarm failed: ` +
`${alarmError instanceof Error ? alarmError.message : 'Unknown error'}`, alarmError instanceof Error ? alarmError.stack : undefined);
}
}
async publishAlarm(event, errorDetails) {
this.logger.debug('event', event);
const alarm = {
action: 'sfn-alarm',
id: `${event.commandKey.pk}#${event.commandKey.sk}`,
table: this.options.tableName,
pk: event.commandKey.pk,
sk: event.commandKey.sk,
tenantCode: event.commandKey.pk.substring(event.commandKey.pk.indexOf('#') + 1),
content: {
errorMessage: errorDetails,
sfnId: event.context.Execution.Id,
},
};
this.logger.error('alarm:::', alarm);
await this.snsService.publish(alarm, this.alarmTopicArn);
}
};
exports.CommandEventHandler = CommandEventHandler;
exports.CommandEventHandler = CommandEventHandler = CommandEventHandler_1 = __decorate([
(0, common_1.Injectable)(),
__param(0, (0, common_1.Inject)(command_module_definition_1.MODULE_OPTIONS_TOKEN)),
__metadata("design:paramtypes", [Object, command_service_1.CommandService,
data_service_1.DataService,
history_service_1.HistoryService,
data_store_1.S3Service,
queue_1.SnsService,
config_1.ConfigService,
step_function_service_1.StepFunctionService])
], CommandEventHandler);
//# sourceMappingURL=command.event.handler.js.map