atomic-saga
Version:
A comprehensive npm package for ensuring atomic API operations in distributed Node.js applications using Saga patterns, compensating transactions, and idempotent operations
257 lines • 9.99 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.SagaOrchestrator = void 0;
const uuid_1 = require("uuid");
/**
* Saga Orchestrator - Manages distributed transactions using the orchestration pattern
* Implements the Saga pattern with explicit compensating transactions for rollback
*/
class SagaOrchestrator {
constructor(store, logger, defaultRetryPolicy = { maxAttempts: 3, backoffMs: 1000, backoffMultiplier: 2 }, defaultTimeout = 30000) {
this.store = store;
this.logger = logger;
this.defaultRetryPolicy = defaultRetryPolicy;
this.defaultTimeout = defaultTimeout;
}
/**
* Execute a saga with the given definition and context
* This is the main entry point for orchestrating distributed transactions
*/
async executeSaga(definition, context) {
const executionId = (0, uuid_1.v4)();
const execution = {
id: executionId,
sagaId: definition.id,
status: 'PENDING',
context,
startedAt: new Date(),
stepResults: []
};
this.logger.info(`Starting saga execution`, {
executionId,
sagaId: definition.id,
sagaName: definition.name,
stepCount: definition.steps.length
});
try {
// Save initial execution state
await this.store.saveExecution(execution);
// Execute all steps in sequence
const result = await this.executeSteps(definition, execution);
// Handle success
if (result.status === 'COMPLETED') {
execution.status = 'COMPLETED';
execution.completedAt = new Date();
if (definition.onSuccess) {
await definition.onSuccess(context);
}
this.logger.info(`Saga completed successfully`, {
executionId,
sagaId: definition.id,
duration: execution.completedAt.getTime() - execution.startedAt.getTime()
});
}
else {
// Handle failure and compensation
await this.compensate(definition, execution, result.error);
}
await this.store.updateExecution(execution);
return execution;
}
catch (error) {
this.logger.error(`Saga execution failed`, error, {
executionId,
sagaId: definition.id
});
execution.status = 'FAILED';
execution.error = error;
execution.completedAt = new Date();
await this.store.updateExecution(execution);
throw error;
}
}
/**
* Execute all steps in the saga sequentially
*/
async executeSteps(definition, execution) {
execution.status = 'RUNNING';
await this.store.updateExecution(execution);
for (let i = 0; i < definition.steps.length; i++) {
const step = definition.steps[i];
if (!step) {
throw new Error(`Step at index ${i} is undefined`);
}
execution.currentStep = i;
this.logger.info(`Executing step ${i + 1}/${definition.steps.length}`, {
executionId: execution.id,
stepId: step.id,
stepName: step.name
});
const stepResult = await this.executeStep(step, execution.context, i);
execution.stepResults.push(stepResult);
if (stepResult.status === 'FAILED') {
return { status: 'FAILED', error: stepResult.error };
}
await this.store.updateExecution(execution);
}
return { status: 'COMPLETED' };
}
/**
* Execute a single step with retry logic and timeout
*/
async executeStep(step, context, _stepIndex) {
const stepResult = {
stepId: step.id,
stepName: step.name,
status: 'SUCCESS',
input: context,
startedAt: new Date(),
attempts: 0
};
const retryPolicy = step.retryPolicy || this.defaultRetryPolicy;
const timeout = step.timeout || this.defaultTimeout;
for (let attempt = 1; attempt <= retryPolicy.maxAttempts; attempt++) {
stepResult.attempts = attempt;
try {
this.logger.debug(`Executing step attempt ${attempt}/${retryPolicy.maxAttempts}`, {
stepId: step.id,
stepName: step.name,
attempt
});
// Execute with timeout
const output = await this.executeWithTimeout(() => step.action(context), timeout);
stepResult.output = output;
stepResult.completedAt = new Date();
stepResult.status = 'SUCCESS';
this.logger.info(`Step completed successfully`, {
stepId: step.id,
stepName: step.name,
attempt,
duration: stepResult.completedAt.getTime() - stepResult.startedAt.getTime()
});
return stepResult;
}
catch (error) {
stepResult.error = error;
this.logger.warn(`Step attempt ${attempt} failed`, {
stepId: step.id,
stepName: step.name,
attempt,
error: error.message
});
// If this is the last attempt, mark as failed
if (attempt === retryPolicy.maxAttempts) {
stepResult.status = 'FAILED';
stepResult.completedAt = new Date();
this.logger.error(`Step failed after ${retryPolicy.maxAttempts} attempts`, error, {
stepId: step.id,
stepName: step.name
});
return stepResult;
}
// Wait before retry with exponential backoff
const backoffDelay = retryPolicy.backoffMs * Math.pow(retryPolicy.backoffMultiplier, attempt - 1);
await this.sleep(backoffDelay);
}
}
// This should never be reached, but TypeScript requires it
stepResult.status = 'FAILED';
stepResult.completedAt = new Date();
return stepResult;
}
/**
* Execute compensation for all completed steps in reverse order
*/
async compensate(definition, execution, failureError) {
execution.status = 'COMPENSATING';
await this.store.updateExecution(execution);
this.logger.info(`Starting compensation for failed saga`, {
executionId: execution.id,
sagaId: definition.id,
failedStep: execution.currentStep
});
// Find completed steps and compensate in reverse order
const completedSteps = execution.stepResults
.filter(result => result.status === 'SUCCESS')
.reverse();
for (const stepResult of completedSteps) {
const step = definition.steps.find(s => s.id === stepResult.stepId);
if (!step || !step.compensation) {
this.logger.warn(`No compensation found for step`, {
stepId: stepResult.stepId,
stepName: stepResult.stepName
});
continue;
}
try {
this.logger.info(`Executing compensation for step`, {
stepId: step.id,
stepName: step.name
});
await step.compensation(stepResult.input, stepResult.output);
stepResult.status = 'COMPENSATED';
this.logger.info(`Compensation completed successfully`, {
stepId: step.id,
stepName: step.name
});
}
catch (error) {
this.logger.error(`Compensation failed for step`, error, {
stepId: step.id,
stepName: step.name
});
// Note: In a production system, you might want to handle compensation failures differently
// For now, we continue with other compensations
}
}
execution.status = 'COMPENSATED';
execution.completedAt = new Date();
if (definition.onFailure) {
await definition.onFailure(execution.context, failureError);
}
this.logger.info(`Saga compensation completed`, {
executionId: execution.id,
sagaId: definition.id,
compensatedSteps: completedSteps.length
});
}
/**
* Execute a function with a timeout
*/
async executeWithTimeout(fn, timeoutMs) {
return new Promise((resolve, reject) => {
const timeoutId = setTimeout(() => {
reject(new Error(`Operation timed out after ${timeoutMs}ms`));
}, timeoutMs);
fn()
.then(result => {
clearTimeout(timeoutId);
resolve(result);
})
.catch(error => {
clearTimeout(timeoutId);
reject(error);
});
});
}
/**
* Sleep utility for retry delays
*/
sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
/**
* Get execution by ID
*/
async getExecution(id) {
return this.store.getExecution(id);
}
/**
* List executions with optional filters
*/
async listExecutions(sagaId, status) {
return this.store.listExecutions(sagaId, status);
}
}
exports.SagaOrchestrator = SagaOrchestrator;
//# sourceMappingURL=SagaOrchestrator.js.map