Files
taskbuffer/ts/taskbuffer.classes.service.ts

347 lines
10 KiB
TypeScript
Raw Normal View History

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;
}
}