Adds two new classes: - Service: long-running component with start/stop lifecycle, health checks, builder pattern and subclass support - ServiceManager: orchestrates multiple services with dependency-ordered startup, failure isolation, retry with backoff, and reverse-order shutdown
347 lines
10 KiB
TypeScript
347 lines
10 KiB
TypeScript
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<T = any> {
|
|
public readonly name: string;
|
|
public readonly eventSubject = new plugins.smartrx.rxjs.Subject<IServiceEvent>();
|
|
|
|
// ── 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<T>) | undefined;
|
|
private _stopFn: (() => Promise<void>) | undefined;
|
|
private _healthCheckFn: (() => Promise<boolean>) | 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<typeof setTimeout> | undefined;
|
|
private _lastHealthCheck: number | undefined;
|
|
private _healthCheckOk: boolean | undefined;
|
|
private _consecutiveHealthFailures = 0;
|
|
|
|
constructor(nameOrOptions: string | IServiceOptions<T>) {
|
|
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<T>): this {
|
|
this._startFn = fn;
|
|
return this;
|
|
}
|
|
|
|
public withStop(fn: () => Promise<void>): this {
|
|
this._stopFn = fn;
|
|
return this;
|
|
}
|
|
|
|
public withHealthCheck(fn: () => Promise<boolean>, 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<T> {
|
|
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<void> {
|
|
if (this._stopFn) {
|
|
return this._stopFn();
|
|
}
|
|
// Default: no-op stop is fine (some services don't need explicit cleanup)
|
|
}
|
|
|
|
protected async serviceHealthCheck(): Promise<boolean> {
|
|
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<T> {
|
|
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<void> {
|
|
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<boolean | undefined> {
|
|
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<boolean>((_, 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<IServiceEvent>): 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;
|
|
}
|
|
}
|