feat(service): add Service and ServiceManager for component lifecycle management
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
This commit is contained in:
346
ts/taskbuffer.classes.service.ts
Normal file
346
ts/taskbuffer.classes.service.ts
Normal file
@@ -0,0 +1,346 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user