UNPKG

peepee

Version:

Visual Programming Language Where You Connect Ports Of One EventEmitter to Ports Of Another EventEmitter

500 lines (428 loc) 14.6 kB
import { EventEmitter } from 'events'; /** * SagaOrchestrator - Industry Standard Saga Pattern Implementation * * Industry terminology: * - Saga: Long-running business process coordinating multiple services * - Orchestrator: Central coordinator that manages saga lifecycle * - Correlation: Linking related events by correlation key (orderId, userId, etc.) * - Choreography: Decentralized coordination through event reactions * - Compensation: Rollback actions when saga fails * - Saga Instance: Individual execution of a saga definition * - Saga Definition: Template defining saga steps and dependencies * - Saga State: Current progress and data of a saga instance */ export class SagaOrchestrator extends EventEmitter { constructor() { super(); this.sagaDefinitions = new Map(); // sagaType -> saga definition this.sagaInstances = new Map(); // sagaId -> saga instance this.correlationIndex = new Map(); // correlationKey -> sagaId this.eventHandlers = new Map(); // eventType -> handler functions this.compensationHandlers = new Map(); // sagaType -> compensation functions this.sequenceNumber = 0; } /** * Define a saga with its dependencies and completion conditions * @param {string} sagaType - Type/name of the saga * @param {Object} definition - Saga definition object */ defineSaga(sagaType, definition) { const { correlationKey, // Field to correlate events (e.g., 'orderId', 'userId') dependencies = [], // Required events with their filters timeout = 300000, // 5 minutes default timeout compensations = [], // Compensation actions for rollback onComplete = null, // Function to execute when saga completes onTimeout = null, // Function to execute when saga times out onError = null // Function to execute when saga fails } = definition; const sagaDefinition = { sagaType, correlationKey, dependencies: dependencies.map(dep => ({ eventType: dep.eventType, filter: dep.filter || (() => true), required: dep.required !== false, // Default to required alias: dep.alias || dep.eventType, // Alias for referencing in completion timeout: dep.timeout || timeout })), timeout, compensations, onComplete, onTimeout, onError, createdAt: new Date() }; this.sagaDefinitions.set(sagaType, sagaDefinition); // Set up event handlers for this saga's dependencies for (const dependency of sagaDefinition.dependencies) { this._setupEventHandler(sagaType, dependency); } return this; } /** * Start a new saga instance * @param {string} sagaType - Type of saga to start * @param {string} correlationValue - Value to correlate events with * @param {Object} initialData - Initial data for the saga */ startSaga(sagaType, correlationValue, initialData = {}) { const definition = this.sagaDefinitions.get(sagaType); if (!definition) { throw new Error(`Saga type '${sagaType}' not defined`); } const sagaId = `${sagaType}-${correlationValue}-${Date.now()}`; const sagaInstance = { sagaId, sagaType, correlationValue, state: 'STARTED', capturedEvents: new Map(), // alias -> captured event requiredEvents: new Set( definition.dependencies .filter(dep => dep.required) .map(dep => dep.alias) ), completedEvents: new Set(), initialData, startedAt: new Date(), lastUpdated: new Date(), sequenceNumber: ++this.sequenceNumber }; this.sagaInstances.set(sagaId, sagaInstance); this.correlationIndex.set(correlationValue, sagaId); // Set up timeout setTimeout(() => { this._handleSagaTimeout(sagaId); }, definition.timeout); this.emit('sagaStarted', { sagaId, sagaType, correlationValue, requiredEvents: Array.from(sagaInstance.requiredEvents) }); return sagaId; } /** * Internal method to set up event handlers for saga dependencies */ _setupEventHandler(sagaType, dependency) { const handlerKey = `${sagaType}-${dependency.eventType}`; if (!this.eventHandlers.has(handlerKey)) { const handler = (eventData) => { this._handleSagaEvent(sagaType, dependency, eventData); }; this.eventHandlers.set(handlerKey, handler); this.on(dependency.eventType, handler); } } /** * Handle incoming events for saga processing */ _handleSagaEvent(sagaType, dependency, eventData) { const definition = this.sagaDefinitions.get(sagaType); const correlationValue = eventData[definition.correlationKey]; if (!correlationValue) { return; // Event doesn't have correlation key } const sagaId = this.correlationIndex.get(correlationValue); if (!sagaId) { return; // No active saga for this correlation value } const sagaInstance = this.sagaInstances.get(sagaId); if (!sagaInstance || sagaInstance.state !== 'STARTED') { return; // Saga not active } // Check if event matches the dependency filter if (!dependency.filter(eventData)) { return; // Event doesn't match filter } // Capture the event const eventRecord = { eventType: dependency.eventType, eventData, capturedAt: new Date(), sequenceNumber: ++this.sequenceNumber }; sagaInstance.capturedEvents.set(dependency.alias, eventRecord); sagaInstance.completedEvents.add(dependency.alias); sagaInstance.lastUpdated = new Date(); this.emit('sagaEventCaptured', { sagaId, sagaType, eventType: dependency.eventType, alias: dependency.alias, correlationValue, completedCount: sagaInstance.completedEvents.size, requiredCount: sagaInstance.requiredEvents.size }); // Check if saga is complete this._checkSagaCompletion(sagaId); } /** * Check if saga has all required events and complete if so */ _checkSagaCompletion(sagaId) { const sagaInstance = this.sagaInstances.get(sagaId); const definition = this.sagaDefinitions.get(sagaInstance.sagaType); // Check if all required events are captured const allRequiredCaptured = Array.from(sagaInstance.requiredEvents) .every(alias => sagaInstance.completedEvents.has(alias)); if (allRequiredCaptured) { this._completeSaga(sagaId); } } /** * Complete a saga and emit the completion event */ async _completeSaga(sagaId) { const sagaInstance = this.sagaInstances.get(sagaId); const definition = this.sagaDefinitions.get(sagaInstance.sagaType); sagaInstance.state = 'COMPLETED'; sagaInstance.completedAt = new Date(); // Prepare completion data const completionData = { sagaId, sagaType: sagaInstance.sagaType, correlationValue: sagaInstance.correlationValue, initialData: sagaInstance.initialData, capturedEvents: {}, duration: sagaInstance.completedAt - sagaInstance.startedAt, completedAt: sagaInstance.completedAt }; // Include all captured events by alias for (const [alias, eventRecord] of sagaInstance.capturedEvents) { completionData.capturedEvents[alias] = eventRecord; } // Execute custom completion handler if (definition.onComplete) { try { await definition.onComplete(completionData); } catch (error) { this.emit('sagaCompletionError', { sagaId, error }); } } // Emit saga completion event this.emit('sagaCompleted', completionData); // Clean up this.correlationIndex.delete(sagaInstance.correlationValue); // Keep completed sagas for a while for debugging setTimeout(() => { this.sagaInstances.delete(sagaId); }, 60000); // Remove after 1 minute } /** * Handle saga timeout */ async _handleSagaTimeout(sagaId) { const sagaInstance = this.sagaInstances.get(sagaId); if (!sagaInstance || sagaInstance.state !== 'STARTED') { return; // Saga already completed or doesn't exist } const definition = this.sagaDefinitions.get(sagaInstance.sagaType); sagaInstance.state = 'TIMED_OUT'; sagaInstance.timedOutAt = new Date(); const timeoutData = { sagaId, sagaType: sagaInstance.sagaType, correlationValue: sagaInstance.correlationValue, capturedEvents: Object.fromEntries(sagaInstance.capturedEvents), missingEvents: Array.from(sagaInstance.requiredEvents) .filter(alias => !sagaInstance.completedEvents.has(alias)), duration: sagaInstance.timedOutAt - sagaInstance.startedAt }; // Execute timeout handler if (definition.onTimeout) { try { await definition.onTimeout(timeoutData); } catch (error) { this.emit('sagaTimeoutError', { sagaId, error }); } } // Emit timeout event this.emit('sagaTimedOut', timeoutData); // Start compensation if defined if (definition.compensations.length > 0) { await this._compensateSaga(sagaId, 'TIMEOUT'); } // Clean up this.correlationIndex.delete(sagaInstance.correlationValue); } /** * Compensate a saga (rollback) */ async _compensateSaga(sagaId, reason) { const sagaInstance = this.sagaInstances.get(sagaId); const definition = this.sagaDefinitions.get(sagaInstance.sagaType); sagaInstance.state = 'COMPENSATING'; const compensationData = { sagaId, sagaType: sagaInstance.sagaType, correlationValue: sagaInstance.correlationValue, reason, capturedEvents: Object.fromEntries(sagaInstance.capturedEvents) }; // Execute compensation actions in reverse order for (const compensation of definition.compensations.reverse()) { try { await compensation(compensationData); } catch (error) { this.emit('compensationError', { sagaId, compensation, error }); } } sagaInstance.state = 'COMPENSATED'; sagaInstance.compensatedAt = new Date(); this.emit('sagaCompensated', { ...compensationData, compensatedAt: sagaInstance.compensatedAt }); } /** * Get saga instance by correlation value */ getSagaByCorrelation(correlationValue) { const sagaId = this.correlationIndex.get(correlationValue); return sagaId ? this.sagaInstances.get(sagaId) : null; } /** * Get saga instance by saga ID */ getSagaById(sagaId) { return this.sagaInstances.get(sagaId); } /** * Get all active sagas */ getActiveSagas() { return Array.from(this.sagaInstances.values()) .filter(saga => saga.state === 'STARTED'); } /** * Get saga statistics */ getSagaStats() { const sagas = Array.from(this.sagaInstances.values()); const stats = { total: sagas.length, active: sagas.filter(s => s.state === 'STARTED').length, completed: sagas.filter(s => s.state === 'COMPLETED').length, timedOut: sagas.filter(s => s.state === 'TIMED_OUT').length, compensated: sagas.filter(s => s.state === 'COMPENSATED').length, definitions: this.sagaDefinitions.size, byType: {} }; for (const saga of sagas) { if (!stats.byType[saga.sagaType]) { stats.byType[saga.sagaType] = { total: 0, active: 0, completed: 0, timedOut: 0, compensated: 0 }; } stats.byType[saga.sagaType].total++; stats.byType[saga.sagaType][saga.state.toLowerCase()]++; } return stats; } /** * Cancel a saga manually */ async cancelSaga(sagaId, reason = 'MANUAL_CANCELLATION') { const sagaInstance = this.sagaInstances.get(sagaId); if (!sagaInstance || sagaInstance.state !== 'STARTED') { return false; } await this._compensateSaga(sagaId, reason); return true; } /** * Clean up completed and old sagas */ cleanup(maxAge = 3600000) { // 1 hour default const now = Date.now(); const sagasToDelete = []; for (const [sagaId, saga] of this.sagaInstances) { const age = now - saga.startedAt.getTime(); if (saga.state !== 'STARTED' && age > maxAge) { sagasToDelete.push(sagaId); } } for (const sagaId of sagasToDelete) { this.sagaInstances.delete(sagaId); } this.emit('sagasCleanedUp', { count: sagasToDelete.length }); return sagasToDelete.length; } } export default SagaOrchestrator; // Example usage: /* const sagaOrchestrator = new SagaOrchestrator(); // Define an order processing saga sagaOrchestrator.defineSaga('orderProcessing', { correlationKey: 'orderId', dependencies: [ { eventType: 'orderCreated', filter: (data) => data.status === 'pending', alias: 'order' }, { eventType: 'paymentProcessed', filter: (data) => data.success === true, alias: 'payment' }, { eventType: 'inventoryReserved', filter: (data) => data.reserved === true, alias: 'inventory' } ], timeout: 300000, // 5 minutes onComplete: async (completionData) => { console.log('Order processing saga completed:', completionData.sagaId); // Emit business event sagaOrchestrator.emit('orderReadyForFulfillment', { orderId: completionData.correlationValue, orderData: completionData.capturedEvents.order.eventData, paymentData: completionData.capturedEvents.payment.eventData, inventoryData: completionData.capturedEvents.inventory.eventData, processedAt: new Date() }); }, compensations: [ async (compensationData) => { // Refund payment console.log('Refunding payment for order:', compensationData.correlationValue); }, async (compensationData) => { // Release inventory console.log('Releasing inventory for order:', compensationData.correlationValue); } ] }); // Start a saga const sagaId = sagaOrchestrator.startSaga('orderProcessing', 'order-123'); // Emit events that will be captured by the saga sagaOrchestrator.emit('orderCreated', { orderId: 'order-123', customerId: 'customer-456', status: 'pending', total: 99.99 }); sagaOrchestrator.emit('paymentProcessed', { orderId: 'order-123', paymentId: 'payment-789', success: true, amount: 99.99 }); sagaOrchestrator.emit('inventoryReserved', { orderId: 'order-123', items: ['item-1', 'item-2'], reserved: true }); // The saga will automatically complete and emit 'orderReadyForFulfillment' */