UNPKG

@mbc-cqrs-serverless/core

Version:
376 lines 19 kB
"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