UNPKG

@clipwhisperer/common

Version:

ClipWhisperer Common - Shared library providing core utilities, database schemas, authentication, bucket management, and common functionality across all ClipWhisperer microservices

756 lines (630 loc) 23.8 kB
import axios, { AxiosError } from 'axios'; import { spawn } from 'child_process'; import { EventEmitter } from 'events'; import { ServiceConfig } from '../../schemas/services'; import { DependencyError, HealthCheckError, HealthCheckResult, IEventBus, IHealthChecker, IProcessManager, IServiceOrchestrator, IServiceRegistry, ProcessError, ProcessInfo, ServiceError, ServiceEvent, ServiceInfo, ServiceStatus } from '../../types/services'; import { Logger } from '../services/Logger'; /** * Enterprise Service Manager - Core orchestration component * * This class provides enterprise-grade service management capabilities including: * - Dependency-aware startup/shutdown * - Health monitoring with circuit breaker patterns * - Event-driven architecture * - Auto-recovery and restart policies * - Process lifecycle management * - Service discovery and registry * * @example * ```typescript * const serviceManager = new EnterpriseServiceManager(); * await serviceManager.start(); * ``` */ /** * Event Bus Implementation * Handles inter-service communication and event propagation */ class EventBus implements IEventBus { private emitter = new EventEmitter(); private eventHistory: ServiceEvent[] = []; private logger = Logger.getInstance().child({ component: 'EventBus' }); emit(event: ServiceEvent): void { this.eventHistory.push({ ...event, timestamp: new Date() }); // Keep only last 1000 events to prevent memory leaks if (this.eventHistory.length > 1000) { this.eventHistory = this.eventHistory.slice(-1000); } this.logger.info('Event emitted', { type: event.type, serviceName: event.serviceName, data: event.data }); this.emitter.emit(event.type, event); this.emitter.emit('*', event); // Wildcard listener } on(eventType: string, listener: (event: ServiceEvent) => void): void { this.emitter.on(eventType, listener); } off(eventType: string, listener: (event: ServiceEvent) => void): void { this.emitter.off(eventType, listener); } getEventHistory(): ServiceEvent[] { return [...this.eventHistory]; } clearHistory(): void { this.eventHistory = []; this.logger.info('Event history cleared'); } } /** * Service Registry Implementation * Manages service discovery and configuration */ class ServiceRegistry implements IServiceRegistry { private services = new Map<string, ServiceInfo>(); private logger = Logger.getInstance().child({ component: 'ServiceRegistry' }); register(service: ServiceInfo): void { this.services.set(service.name, { ...service, registeredAt: new Date() }); this.logger.info('Service registered', { serviceName: service.name, port: service.port, dependencies: service.dependencies }); } get(serviceName: string): ServiceInfo | undefined { return this.services.get(serviceName); } getAll(): ServiceInfo[] { return Array.from(this.services.values()); } unregister(serviceName: string): boolean { const removed = this.services.delete(serviceName); if (removed) { this.logger.info('Service unregistered', { serviceName }); } return removed; } /** * Get services in dependency order using topological sorting */ getDependencyOrder(): string[] { const services = Array.from(this.services.values()); const visited = new Set<string>(); const visiting = new Set<string>(); const result: string[] = []; const visit = (serviceName: string): void => { if (visited.has(serviceName)) return; if (visiting.has(serviceName)) { throw new DependencyError(`Circular dependency detected involving ${serviceName}`); } visiting.add(serviceName); const service = this.services.get(serviceName); if (service?.dependencies) { for (const dep of service.dependencies) { visit(dep); } } visiting.delete(serviceName); visited.add(serviceName); result.push(serviceName); }; for (const service of services) { visit(service.name); } return result; } } /** * Health Checker Implementation * Monitors service health with circuit breaker patterns */ class HealthChecker implements IHealthChecker { private healthStatus = new Map<string, HealthCheckResult>(); private checkIntervals = new Map<string, NodeJS.Timeout>(); private circuitBreakers = new Map<string, { failures: number; lastFailure: Date; isOpen: boolean }>(); private logger = Logger.getInstance().child({ component: 'HealthChecker' }); private readonly CIRCUIT_BREAKER_THRESHOLD = 3; private readonly CIRCUIT_BREAKER_TIMEOUT = 30000; // 30 seconds async checkHealth(serviceName: string, url: string): Promise<HealthCheckResult> { const startTime = Date.now(); const correlationId = Logger.generateCorrelationId(); try { // Check circuit breaker const breaker = this.circuitBreakers.get(serviceName); if (breaker?.isOpen) { const timeSinceLastFailure = Date.now() - breaker.lastFailure.getTime(); if (timeSinceLastFailure < this.CIRCUIT_BREAKER_TIMEOUT) { const result: HealthCheckResult = { serviceName, status: 'unhealthy', timestamp: new Date(), responseTime: 0, error: 'Circuit breaker is open' }; this.healthStatus.set(serviceName, result); return result; } else { // Reset circuit breaker this.circuitBreakers.set(serviceName, { failures: 0, lastFailure: new Date(), isOpen: false }); } } const response = await axios.get(url, { timeout: 5000, headers: { 'X-Correlation-ID': correlationId } }); const responseTime = Date.now() - startTime; const result: HealthCheckResult = { serviceName, status: response.status === 200 ? 'healthy' : 'unhealthy', timestamp: new Date(), responseTime, details: response.data }; // Reset circuit breaker on success this.circuitBreakers.set(serviceName, { failures: 0, lastFailure: new Date(), isOpen: false }); this.healthStatus.set(serviceName, result); this.logger.info('Health check completed', { serviceName, status: result.status, responseTime, correlationId }); return result; } catch (error) { const responseTime = Date.now() - startTime; // Update circuit breaker const currentBreaker = this.circuitBreakers.get(serviceName) || { failures: 0, lastFailure: new Date(), isOpen: false }; currentBreaker.failures++; currentBreaker.lastFailure = new Date(); if (currentBreaker.failures >= this.CIRCUIT_BREAKER_THRESHOLD) { currentBreaker.isOpen = true; this.logger.warn('Circuit breaker opened', { serviceName, failures: currentBreaker.failures }); } this.circuitBreakers.set(serviceName, currentBreaker); const errorMessage = error instanceof AxiosError ? `HTTP ${error.response?.status}: ${error.message}` : error instanceof Error ? error.message : String(error); const result: HealthCheckResult = { serviceName, status: 'unhealthy', timestamp: new Date(), responseTime, error: errorMessage }; this.healthStatus.set(serviceName, result); this.logger.error('Health check failed', { serviceName, error: errorMessage, responseTime, correlationId }); return result; } } startMonitoring(serviceName: string, url: string, intervalMs: number = 5000): void { this.stopMonitoring(serviceName); // Stop any existing monitoring const interval = setInterval(async () => { await this.checkHealth(serviceName, url); }, intervalMs); this.checkIntervals.set(serviceName, interval); this.logger.info('Health monitoring started', { serviceName, intervalMs }); } stopMonitoring(serviceName: string): void { const interval = this.checkIntervals.get(serviceName); if (interval) { clearInterval(interval); this.checkIntervals.delete(serviceName); this.logger.info('Health monitoring stopped', { serviceName }); } } getStatus(serviceName: string): HealthCheckResult | undefined { return this.healthStatus.get(serviceName); } getAllStatus(): Map<string, HealthCheckResult> { return new Map(this.healthStatus); } resetCircuitBreaker(serviceName: string): void { this.circuitBreakers.set(serviceName, { failures: 0, lastFailure: new Date(), isOpen: false }); this.logger.info('Circuit breaker reset', { serviceName }); } } /** * Process Manager Implementation * Handles process lifecycle management */ class ProcessManager implements IProcessManager { private processes = new Map<string, ProcessInfo>(); private logger = Logger.getInstance().child({ component: 'ProcessManager' }); async start(serviceName: string, command: string, args: string[], cwd?: string): Promise<ProcessInfo> { try { // Kill existing process if running await this.stop(serviceName); const childProcess = spawn(command, args, { cwd: cwd || process.cwd(), stdio: ['pipe', 'pipe', 'pipe'], shell: true }); const processInfo: ProcessInfo = { serviceName, pid: childProcess.pid!, command, args, startTime: new Date(), status: 'running', process: childProcess }; // Setup process event handlers childProcess.on('error', (error) => { this.logger.error('Process error', { serviceName, error: error.message }); processInfo.status = 'failed'; processInfo.exitCode = -1; processInfo.exitTime = new Date(); }); childProcess.on('exit', (code, signal) => { this.logger.info('Process exited', { serviceName, code, signal }); processInfo.status = code === 0 ? 'stopped' : 'failed'; processInfo.exitCode = code ?? undefined; processInfo.exitSignal = signal ?? undefined; processInfo.exitTime = new Date(); }); // Log output childProcess.stdout?.on('data', (data) => { const output = data.toString().trim(); if (output) { this.logger.info(`[${serviceName}] ${output}`); } }); childProcess.stderr?.on('data', (data) => { const output = data.toString().trim(); if (output) { this.logger.error(`[${serviceName}] ${output}`); } }); this.processes.set(serviceName, processInfo); this.logger.info('Process started', { serviceName, pid: childProcess.pid, command }); return processInfo; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); this.logger.error('Failed to start process', { serviceName, error: errorMessage }); throw new ProcessError(`Failed to start ${serviceName}: ${errorMessage}`, serviceName); } } async stop(serviceName: string, signal: NodeJS.Signals = 'SIGTERM'): Promise<boolean> { const processInfo = this.processes.get(serviceName); if (!processInfo || !processInfo.process) { return false; } try { return new Promise<boolean>((resolve) => { const childProcess = processInfo.process!; let resolved = false; const cleanup = () => { if (!resolved) { resolved = true; processInfo.status = 'stopped'; processInfo.exitTime = new Date(); this.logger.info('Process stopped', { serviceName, signal }); resolve(true); } }; childProcess.on('exit', cleanup); // Force kill after timeout const forceKillTimer = setTimeout(() => { if (!resolved && !childProcess.killed) { this.logger.warn('Force killing process', { serviceName }); childProcess.kill('SIGKILL'); cleanup(); } }, 10000); childProcess.kill(signal); // Clean up timer if process exits normally childProcess.on('exit', () => clearTimeout(forceKillTimer)); }); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); this.logger.error('Failed to stop process', { serviceName, error: errorMessage }); return false; } } getProcess(serviceName: string): ProcessInfo | undefined { return this.processes.get(serviceName); } getAllProcesses(): ProcessInfo[] { return Array.from(this.processes.values()); } isRunning(serviceName: string): boolean { const processInfo = this.processes.get(serviceName); return !!(processInfo?.status === 'running' && processInfo.process && !(processInfo.process.killed ?? false)); } async restart(serviceName: string): Promise<ProcessInfo> { const processInfo = this.processes.get(serviceName); if (!processInfo) { throw new ProcessError(`Service ${serviceName} not found`, serviceName); } await this.stop(serviceName); return this.start(serviceName, processInfo.command, processInfo.args); } } /** * Enterprise Service Manager * Main orchestrator implementing dependency injection and enterprise patterns */ export class EnterpriseServiceManager implements IServiceOrchestrator { private eventBus: IEventBus; private serviceRegistry: IServiceRegistry; private healthChecker: IHealthChecker; private processManager: IProcessManager; private logger = Logger.getInstance().child({ component: 'EnterpriseServiceManager' }); private isRunning = false; private config: ServiceConfig; constructor(config?: Partial<any>) { this.config = new ServiceConfig(config); this.eventBus = new EventBus(); this.serviceRegistry = new ServiceRegistry(); this.healthChecker = new HealthChecker(); this.processManager = new ProcessManager(); // Setup event listeners this.setupEventListeners(); this.logger.info('EnterpriseServiceManager initialized', { environment: this.config.getEnvironment(), servicesCount: this.config.getServices().length }); } private setupEventListeners(): void { this.eventBus.on('service:unhealthy', (event) => { this.handleUnhealthyService(event); }); this.eventBus.on('service:failed', (event) => { this.handleFailedService(event); }); this.eventBus.on('process:exit', (event) => { this.handleProcessExit(event); }); } private async handleUnhealthyService(event: ServiceEvent): Promise<void> { const serviceName = event.serviceName; const serviceConfig = this.config.getService(serviceName); if (serviceConfig?.restartPolicy?.enabled) { this.logger.warn('Attempting service recovery', { serviceName }); try { await this.processManager.restart(serviceName); this.eventBus.emit({ type: 'service:recovered', serviceName, data: { reason: 'unhealthy_restart' } }); } catch (error) { this.logger.error('Service recovery failed', { serviceName, error: error instanceof Error ? error.message : String(error) }); } } } private async handleFailedService(event: ServiceEvent): Promise<void> { this.logger.error('Service failed', { serviceName: event.serviceName, data: event.data }); } private async handleProcessExit(event: ServiceEvent): Promise<void> { this.logger.info('Process exited', { serviceName: event.serviceName, data: event.data }); } async start(): Promise<void> { if (this.isRunning) { throw new ServiceError('Service manager is already running'); } this.logger.info('Starting Enterprise Service Manager'); try { // Register all services const services = this.config.getServices(); for (const service of services) { this.serviceRegistry.register({ name: service.name, port: service.port, healthEndpoint: service.healthEndpoint, dependencies: service.dependencies, command: service.startCommand }); } // Start services in dependency order const startOrder = this.serviceRegistry.getDependencyOrder(); this.logger.info('Starting services in order', { order: startOrder }); for (const serviceName of startOrder) { await this.startService(serviceName); // Wait a bit between service starts await new Promise(resolve => setTimeout(resolve, 2000)); } // Start health monitoring this.startHealthMonitoring(); this.isRunning = true; this.eventBus.emit({ type: 'manager:started', serviceName: 'enterprise-manager', data: { servicesCount: services.length } }); this.logger.info('Enterprise Service Manager started successfully'); } catch (error) { this.logger.error('Failed to start Enterprise Service Manager', { error: error instanceof Error ? error.message : String(error) }); throw error; } } async stop(): Promise<void> { if (!this.isRunning) { return; } this.logger.info('Stopping Enterprise Service Manager'); try { // Stop health monitoring this.stopHealthMonitoring(); // Stop services in reverse dependency order const stopOrder = this.serviceRegistry.getDependencyOrder().reverse(); this.logger.info('Stopping services in order', { order: stopOrder }); for (const serviceName of stopOrder) { await this.stopService(serviceName); } this.isRunning = false; this.eventBus.emit({ type: 'manager:stopped', serviceName: 'enterprise-manager', data: {} }); this.logger.info('Enterprise Service Manager stopped successfully'); } catch (error) { this.logger.error('Error during shutdown', { error: error instanceof Error ? error.message : String(error) }); throw error; } } async startService(serviceName: string): Promise<void> { const serviceInfo = this.serviceRegistry.get(serviceName); if (!serviceInfo) { throw new ServiceError(`Service ${serviceName} not found in registry`, serviceName); } const serviceConfig = this.config.getService(serviceName); if (!serviceConfig) { throw new ServiceError(`Service configuration for ${serviceName} not found`, serviceName); } try { this.logger.info('Starting service', { serviceName }); // Check dependencies are healthy if (serviceInfo.dependencies) { for (const dep of serviceInfo.dependencies) { const depHealth = this.healthChecker.getStatus(dep); if (!depHealth || depHealth.status !== 'healthy') { throw new DependencyError(`Dependency ${dep} is not healthy`, serviceName, dep); } } } // Start the process const [command, ...args] = serviceConfig.startCommand.split(' '); await this.processManager.start(serviceName, command, args, serviceConfig.workingDirectory); // Wait for service to be ready await this.waitForServiceReady(serviceName, serviceInfo.healthEndpoint); this.eventBus.emit({ type: 'service:started', serviceName, data: { port: serviceInfo.port } }); this.logger.info('Service started successfully', { serviceName }); } catch (error) { this.logger.error('Failed to start service', { serviceName, error: error instanceof Error ? error.message : String(error) }); throw error; } } async stopService(serviceName: string): Promise<void> { try { this.logger.info('Stopping service', { serviceName }); // Stop health monitoring for this service this.healthChecker.stopMonitoring(serviceName); // Stop the process await this.processManager.stop(serviceName); this.eventBus.emit({ type: 'service:stopped', serviceName, data: {} }); this.logger.info('Service stopped successfully', { serviceName }); } catch (error) { this.logger.error('Failed to stop service', { serviceName, error: error instanceof Error ? error.message : String(error) }); throw error; } } private async waitForServiceReady(serviceName: string, healthEndpoint: string, maxAttempts: number = 30): Promise<void> { for (let attempt = 1; attempt <= maxAttempts; attempt++) { try { const result = await this.healthChecker.checkHealth(serviceName, healthEndpoint); if (result.status === 'healthy') { return; } } catch (error) { // Expected during startup } if (attempt === maxAttempts) { throw new HealthCheckError(`Service ${serviceName} failed to become healthy after ${maxAttempts} attempts`, serviceName, healthEndpoint); } await new Promise(resolve => setTimeout(resolve, 2000)); } } private startHealthMonitoring(): void { const services = this.serviceRegistry.getAll(); for (const service of services) { this.healthChecker.startMonitoring(service.name, service.healthEndpoint); } } private stopHealthMonitoring(): void { const services = this.serviceRegistry.getAll(); for (const service of services) { this.healthChecker.stopMonitoring(service.name); } } getStatus(): { [serviceName: string]: ServiceStatus } { const status: { [serviceName: string]: ServiceStatus } = {}; const services = this.serviceRegistry.getAll(); for (const service of services) { const processInfo = this.processManager.getProcess(service.name); const healthInfo = this.healthChecker.getStatus(service.name); if (!processInfo) { status[service.name] = ServiceStatus.STOPPED; } else if (processInfo.status === 'failed') { status[service.name] = ServiceStatus.FAILED; } else if (healthInfo?.status === 'healthy') { status[service.name] = ServiceStatus.RUNNING; } else { status[service.name] = ServiceStatus.STARTING; } } return status; } getServiceInfo(): ServiceInfo[] { return this.serviceRegistry.getAll(); } getEventHistory(): ServiceEvent[] { return this.eventBus.getEventHistory(); } // Dependency injection getters getEventBus(): IEventBus { return this.eventBus; } getServiceRegistry(): IServiceRegistry { return this.serviceRegistry; } getHealthChecker(): IHealthChecker { return this.healthChecker; } getProcessManager(): IProcessManager { return this.processManager; } getConfig(): ServiceConfig { return this.config; } } export default EnterpriseServiceManager;