import * as plugins from './taskbuffer.plugins.js'; import { logger } from './taskbuffer.logging.js'; import type { TServiceState, TServiceCriticality, IServiceEvent, IServiceStatus, IRetryConfig, IHealthCheckConfig, IServiceOptions, } from './taskbuffer.interfaces.js'; /** * Service represents a long-running component with start/stop lifecycle, * health checking, and retry capabilities. * * Use via builder pattern: * new Service('MyService') * .critical() * .dependsOn('Database') * .withStart(async () => { ... }) * .withStop(async () => { ... }) * * Or extend for complex services: * class MyService extends Service { * protected async serviceStart() { ... } * protected async serviceStop() { ... } * } */ export class Service { public readonly name: string; public readonly eventSubject = new plugins.smartrx.rxjs.Subject(); // ── Internal state ───────────────────────────────── private _state: TServiceState = 'stopped'; private _criticality: TServiceCriticality = 'optional'; private _dependencies: string[] = []; private _retryConfig: IRetryConfig | undefined; private _healthCheckConfig: IHealthCheckConfig | undefined; // Builder-provided functions private _startFn: (() => Promise) | undefined; private _stopFn: (() => Promise) | undefined; private _healthCheckFn: (() => Promise) | undefined; // Runtime tracking private _startedAt: number | undefined; private _stoppedAt: number | undefined; private _errorCount = 0; private _lastError: string | undefined; private _retryCount = 0; // Health check tracking private _healthCheckTimer: ReturnType | undefined; private _lastHealthCheck: number | undefined; private _healthCheckOk: boolean | undefined; private _consecutiveHealthFailures = 0; constructor(nameOrOptions: string | IServiceOptions) { if (typeof nameOrOptions === 'string') { this.name = nameOrOptions; } else { this.name = nameOrOptions.name; this._startFn = nameOrOptions.start; this._stopFn = nameOrOptions.stop; this._healthCheckFn = nameOrOptions.healthCheck; this._criticality = nameOrOptions.criticality || 'optional'; this._dependencies = nameOrOptions.dependencies || []; this._retryConfig = nameOrOptions.retry; this._healthCheckConfig = nameOrOptions.healthCheckConfig; } } // ── Builder methods ────────────────────────────────── public critical(): this { this._criticality = 'critical'; return this; } public optional(): this { this._criticality = 'optional'; return this; } public dependsOn(...serviceNames: string[]): this { this._dependencies.push(...serviceNames); return this; } public withStart(fn: () => Promise): this { this._startFn = fn; return this; } public withStop(fn: () => Promise): this { this._stopFn = fn; return this; } public withHealthCheck(fn: () => Promise, config?: IHealthCheckConfig): this { this._healthCheckFn = fn; if (config) { this._healthCheckConfig = config; } return this; } public withRetry(config: IRetryConfig): this { this._retryConfig = config; return this; } // ── Overridable hooks (for subclassing) ────────────── protected async serviceStart(): Promise { if (this._startFn) { return this._startFn(); } throw new Error(`Service '${this.name}': no start function provided. Use withStart() or override serviceStart().`); } protected async serviceStop(): Promise { if (this._stopFn) { return this._stopFn(); } // Default: no-op stop is fine (some services don't need explicit cleanup) } protected async serviceHealthCheck(): Promise { if (this._healthCheckFn) { return this._healthCheckFn(); } // No health check configured — assume healthy if running return this._state === 'running'; } // ── Lifecycle (called by ServiceManager) ───────────── public async start(): Promise { if (this._state === 'running') { return undefined as T; } this.setState('starting'); try { const result = await this.serviceStart(); this._startedAt = Date.now(); this._stoppedAt = undefined; this._consecutiveHealthFailures = 0; this._healthCheckOk = true; this.setState('running'); this.emitEvent('started'); this.startHealthCheckTimer(); return result; } catch (err) { this._errorCount++; this._lastError = err instanceof Error ? err.message : String(err); this.setState('failed'); this.emitEvent('failed', { error: this._lastError }); throw err; } } public async stop(): Promise { if (this._state === 'stopped' || this._state === 'stopping') { return; } this.stopHealthCheckTimer(); this.setState('stopping'); try { await this.serviceStop(); } catch (err) { logger.log('warn', `Service '${this.name}' error during stop: ${err instanceof Error ? err.message : String(err)}`); } this._stoppedAt = Date.now(); this.setState('stopped'); this.emitEvent('stopped'); } public async checkHealth(): Promise { if (!this._healthCheckFn && !this.hasOverriddenHealthCheck()) { return undefined; } try { const config = this._healthCheckConfig; const timeoutMs = config?.timeoutMs ?? 5000; const result = await Promise.race([ this.serviceHealthCheck(), new Promise((_, reject) => setTimeout(() => reject(new Error('Health check timed out')), timeoutMs) ), ]); this._lastHealthCheck = Date.now(); this._healthCheckOk = result; if (result) { this._consecutiveHealthFailures = 0; if (this._state === 'degraded') { this.setState('running'); this.emitEvent('recovered'); } } else { this._consecutiveHealthFailures++; this.handleHealthFailure(); } this.emitEvent('healthCheck'); return result; } catch (err) { this._lastHealthCheck = Date.now(); this._healthCheckOk = false; this._consecutiveHealthFailures++; this.handleHealthFailure(); this.emitEvent('healthCheck'); return false; } } // ── State ──────────────────────────────────────────── public get state(): TServiceState { return this._state; } public get criticality(): TServiceCriticality { return this._criticality; } public get dependencies(): string[] { return [...this._dependencies]; } public get retryConfig(): IRetryConfig | undefined { return this._retryConfig; } public get errorCount(): number { return this._errorCount; } public get retryCount(): number { return this._retryCount; } public set retryCount(value: number) { this._retryCount = value; } public getStatus(): IServiceStatus { return { name: this.name, state: this._state, criticality: this._criticality, startedAt: this._startedAt, stoppedAt: this._stoppedAt, lastHealthCheck: this._lastHealthCheck, healthCheckOk: this._healthCheckOk, uptime: this._startedAt && this._state === 'running' ? Date.now() - this._startedAt : undefined, errorCount: this._errorCount, lastError: this._lastError, retryCount: this._retryCount, dependencies: [...this._dependencies], }; } // ── Internal helpers ───────────────────────────────── private setState(state: TServiceState): void { this._state = state; } private emitEvent(type: IServiceEvent['type'], extra?: Partial): void { this.eventSubject.next({ type, serviceName: this.name, state: this._state, timestamp: Date.now(), ...extra, }); } private handleHealthFailure(): void { const config = this._healthCheckConfig; const failuresBeforeDegraded = config?.failuresBeforeDegraded ?? 3; const failuresBeforeFailed = config?.failuresBeforeFailed ?? 5; if (this._state === 'running' && this._consecutiveHealthFailures >= failuresBeforeDegraded) { this.setState('degraded'); this.emitEvent('degraded'); } if (this._consecutiveHealthFailures >= failuresBeforeFailed) { this.setState('failed'); this._lastError = `Health check failed ${this._consecutiveHealthFailures} consecutive times`; this.emitEvent('failed', { error: this._lastError }); this.stopHealthCheckTimer(); } } private startHealthCheckTimer(): void { if (!this._healthCheckFn && !this.hasOverriddenHealthCheck()) { return; } const config = this._healthCheckConfig; const intervalMs = config?.intervalMs ?? 30000; this.stopHealthCheckTimer(); const tick = () => { if (this._state !== 'running' && this._state !== 'degraded') { return; } this.checkHealth().catch(() => {}); this._healthCheckTimer = setTimeout(tick, intervalMs); if (this._healthCheckTimer && typeof this._healthCheckTimer === 'object' && 'unref' in this._healthCheckTimer) { (this._healthCheckTimer as any).unref(); } }; this._healthCheckTimer = setTimeout(tick, intervalMs); if (this._healthCheckTimer && typeof this._healthCheckTimer === 'object' && 'unref' in this._healthCheckTimer) { (this._healthCheckTimer as any).unref(); } } private stopHealthCheckTimer(): void { if (this._healthCheckTimer) { clearTimeout(this._healthCheckTimer); this._healthCheckTimer = undefined; } } private hasOverriddenHealthCheck(): boolean { return this.serviceHealthCheck !== Service.prototype.serviceHealthCheck; } }