BREAKING CHANGE(service): expand service lifecycle management with instance-aware hooks, startup timeouts, labels, readiness waits, and auto-restart support
This commit is contained in:
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@push.rocks/taskbuffer',
|
||||
version: '6.1.2',
|
||||
version: '8.0.0',
|
||||
description: 'A flexible task management library supporting TypeScript, allowing for task buffering, scheduling, and execution with dependency management.'
|
||||
}
|
||||
|
||||
@@ -19,7 +19,8 @@ import type {
|
||||
* .critical()
|
||||
* .dependsOn('Database')
|
||||
* .withStart(async () => { ... })
|
||||
* .withStop(async () => { ... })
|
||||
* .withStop(async (instance) => { ... })
|
||||
* .withHealthCheck(async (instance) => { ... })
|
||||
*
|
||||
* Or extend for complex services:
|
||||
* class MyService extends Service {
|
||||
@@ -37,11 +38,18 @@ export class Service<T = any> {
|
||||
private _dependencies: string[] = [];
|
||||
private _retryConfig: IRetryConfig | undefined;
|
||||
private _healthCheckConfig: IHealthCheckConfig | undefined;
|
||||
private _startupTimeoutMs: number | undefined;
|
||||
|
||||
// Builder-provided functions
|
||||
private _startFn: (() => Promise<T>) | undefined;
|
||||
private _stopFn: (() => Promise<void>) | undefined;
|
||||
private _healthCheckFn: (() => Promise<boolean>) | undefined;
|
||||
private _stopFn: ((instance: T) => Promise<void>) | undefined;
|
||||
private _healthCheckFn: ((instance: T) => Promise<boolean>) | undefined;
|
||||
|
||||
// Instance: the resolved start result
|
||||
private _instance: T | undefined;
|
||||
|
||||
// Labels
|
||||
public labels: Record<string, string> = {};
|
||||
|
||||
// Runtime tracking
|
||||
private _startedAt: number | undefined;
|
||||
@@ -56,6 +64,10 @@ export class Service<T = any> {
|
||||
private _healthCheckOk: boolean | undefined;
|
||||
private _consecutiveHealthFailures = 0;
|
||||
|
||||
// Auto-restart tracking
|
||||
private _autoRestartCount = 0;
|
||||
private _autoRestartTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
constructor(nameOrOptions: string | IServiceOptions<T>) {
|
||||
if (typeof nameOrOptions === 'string') {
|
||||
this.name = nameOrOptions;
|
||||
@@ -68,6 +80,10 @@ export class Service<T = any> {
|
||||
this._dependencies = nameOrOptions.dependencies || [];
|
||||
this._retryConfig = nameOrOptions.retry;
|
||||
this._healthCheckConfig = nameOrOptions.healthCheckConfig;
|
||||
this._startupTimeoutMs = nameOrOptions.startupTimeoutMs;
|
||||
if (nameOrOptions.labels) {
|
||||
this.labels = { ...nameOrOptions.labels };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,12 +109,12 @@ export class Service<T = any> {
|
||||
return this;
|
||||
}
|
||||
|
||||
public withStop(fn: () => Promise<void>): this {
|
||||
public withStop(fn: (instance: T) => Promise<void>): this {
|
||||
this._stopFn = fn;
|
||||
return this;
|
||||
}
|
||||
|
||||
public withHealthCheck(fn: () => Promise<boolean>, config?: IHealthCheckConfig): this {
|
||||
public withHealthCheck(fn: (instance: T) => Promise<boolean>, config?: IHealthCheckConfig): this {
|
||||
this._healthCheckFn = fn;
|
||||
if (config) {
|
||||
this._healthCheckConfig = config;
|
||||
@@ -111,6 +127,41 @@ export class Service<T = any> {
|
||||
return this;
|
||||
}
|
||||
|
||||
public withStartupTimeout(ms: number): this {
|
||||
this._startupTimeoutMs = ms;
|
||||
return this;
|
||||
}
|
||||
|
||||
public withLabels(labelsArg: Record<string, string>): this {
|
||||
Object.assign(this.labels, labelsArg);
|
||||
return this;
|
||||
}
|
||||
|
||||
// ── Label helpers ──────────────────────────────────
|
||||
|
||||
public setLabel(key: string, value: string): void {
|
||||
this.labels[key] = value;
|
||||
}
|
||||
|
||||
public getLabel(key: string): string | undefined {
|
||||
return this.labels[key];
|
||||
}
|
||||
|
||||
public removeLabel(key: string): boolean {
|
||||
if (key in this.labels) {
|
||||
delete this.labels[key];
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public hasLabel(key: string, value?: string): boolean {
|
||||
if (value !== undefined) {
|
||||
return this.labels[key] === value;
|
||||
}
|
||||
return key in this.labels;
|
||||
}
|
||||
|
||||
// ── Overridable hooks (for subclassing) ──────────────
|
||||
|
||||
protected async serviceStart(): Promise<T> {
|
||||
@@ -122,14 +173,14 @@ export class Service<T = any> {
|
||||
|
||||
protected async serviceStop(): Promise<void> {
|
||||
if (this._stopFn) {
|
||||
return this._stopFn();
|
||||
return this._stopFn(this._instance as T);
|
||||
}
|
||||
// Default: no-op stop is fine (some services don't need explicit cleanup)
|
||||
}
|
||||
|
||||
protected async serviceHealthCheck(): Promise<boolean> {
|
||||
if (this._healthCheckFn) {
|
||||
return this._healthCheckFn();
|
||||
return this._healthCheckFn(this._instance as T);
|
||||
}
|
||||
// No health check configured — assume healthy if running
|
||||
return this._state === 'running';
|
||||
@@ -139,17 +190,29 @@ export class Service<T = any> {
|
||||
|
||||
public async start(): Promise<T> {
|
||||
if (this._state === 'running') {
|
||||
return undefined as T;
|
||||
return this._instance as T;
|
||||
}
|
||||
|
||||
this.setState('starting');
|
||||
|
||||
try {
|
||||
const result = await this.serviceStart();
|
||||
let result: T;
|
||||
if (this._startupTimeoutMs) {
|
||||
result = await Promise.race([
|
||||
this.serviceStart(),
|
||||
new Promise<never>((_, reject) =>
|
||||
setTimeout(() => reject(new Error(`Service '${this.name}': startup timed out after ${this._startupTimeoutMs}ms`)), this._startupTimeoutMs)
|
||||
),
|
||||
]);
|
||||
} else {
|
||||
result = await this.serviceStart();
|
||||
}
|
||||
this._instance = result;
|
||||
this._startedAt = Date.now();
|
||||
this._stoppedAt = undefined;
|
||||
this._consecutiveHealthFailures = 0;
|
||||
this._healthCheckOk = true;
|
||||
this._autoRestartCount = 0;
|
||||
this.setState('running');
|
||||
this.emitEvent('started');
|
||||
this.startHealthCheckTimer();
|
||||
@@ -169,6 +232,7 @@ export class Service<T = any> {
|
||||
}
|
||||
|
||||
this.stopHealthCheckTimer();
|
||||
this.clearAutoRestartTimer();
|
||||
this.setState('stopping');
|
||||
|
||||
try {
|
||||
@@ -177,6 +241,7 @@ export class Service<T = any> {
|
||||
logger.log('warn', `Service '${this.name}' error during stop: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
|
||||
this._instance = undefined;
|
||||
this._stoppedAt = Date.now();
|
||||
this.setState('stopped');
|
||||
this.emitEvent('stopped');
|
||||
@@ -224,6 +289,65 @@ export class Service<T = any> {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Wait / readiness ──────────────────────────────────
|
||||
|
||||
public async waitForState(
|
||||
targetState: TServiceState | TServiceState[],
|
||||
timeoutMs?: number,
|
||||
): Promise<void> {
|
||||
const states = Array.isArray(targetState) ? targetState : [targetState];
|
||||
|
||||
// Already in target state
|
||||
if (states.includes(this._state)) {
|
||||
return;
|
||||
}
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
let settled = false;
|
||||
|
||||
const settle = (fn: () => void) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
subscription.unsubscribe();
|
||||
if (timer) clearTimeout(timer);
|
||||
fn();
|
||||
};
|
||||
|
||||
const subscription = this.eventSubject.subscribe((event) => {
|
||||
if (states.includes(event.state)) {
|
||||
settle(resolve);
|
||||
}
|
||||
});
|
||||
|
||||
// Re-check after subscribing to close the race window
|
||||
if (states.includes(this._state)) {
|
||||
settle(resolve);
|
||||
return;
|
||||
}
|
||||
|
||||
if (timeoutMs !== undefined) {
|
||||
timer = setTimeout(() => {
|
||||
settle(() =>
|
||||
reject(
|
||||
new Error(
|
||||
`Service '${this.name}': timed out waiting for state [${states.join(', ')}] after ${timeoutMs}ms (current: ${this._state})`,
|
||||
),
|
||||
),
|
||||
);
|
||||
}, timeoutMs);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public async waitForRunning(timeoutMs?: number): Promise<void> {
|
||||
return this.waitForState('running', timeoutMs);
|
||||
}
|
||||
|
||||
public async waitForStopped(timeoutMs?: number): Promise<void> {
|
||||
return this.waitForState('stopped', timeoutMs);
|
||||
}
|
||||
|
||||
// ── State ────────────────────────────────────────────
|
||||
|
||||
public get state(): TServiceState {
|
||||
@@ -242,6 +366,14 @@ export class Service<T = any> {
|
||||
return this._retryConfig;
|
||||
}
|
||||
|
||||
public get startupTimeoutMs(): number | undefined {
|
||||
return this._startupTimeoutMs;
|
||||
}
|
||||
|
||||
public get instance(): T | undefined {
|
||||
return this._instance;
|
||||
}
|
||||
|
||||
public get errorCount(): number {
|
||||
return this._errorCount;
|
||||
}
|
||||
@@ -270,6 +402,8 @@ export class Service<T = any> {
|
||||
lastError: this._lastError,
|
||||
retryCount: this._retryCount,
|
||||
dependencies: [...this._dependencies],
|
||||
labels: { ...this.labels },
|
||||
hasInstance: this._instance !== undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -304,6 +438,54 @@ export class Service<T = any> {
|
||||
this._lastError = `Health check failed ${this._consecutiveHealthFailures} consecutive times`;
|
||||
this.emitEvent('failed', { error: this._lastError });
|
||||
this.stopHealthCheckTimer();
|
||||
|
||||
// Auto-restart if configured
|
||||
if (config?.autoRestart) {
|
||||
this.scheduleAutoRestart();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleAutoRestart(): void {
|
||||
const config = this._healthCheckConfig;
|
||||
const maxRestarts = config?.maxAutoRestarts ?? 3;
|
||||
if (maxRestarts > 0 && this._autoRestartCount >= maxRestarts) {
|
||||
logger.log('warn', `Service '${this.name}': max auto-restarts (${maxRestarts}) exceeded`);
|
||||
return;
|
||||
}
|
||||
|
||||
const baseDelay = config?.autoRestartDelayMs ?? 5000;
|
||||
const factor = config?.autoRestartBackoffFactor ?? 2;
|
||||
const delay = Math.min(baseDelay * Math.pow(factor, this._autoRestartCount), 60000);
|
||||
|
||||
this._autoRestartCount++;
|
||||
this.emitEvent('autoRestarting', { attempt: this._autoRestartCount });
|
||||
|
||||
this._autoRestartTimer = setTimeout(async () => {
|
||||
this._autoRestartTimer = undefined;
|
||||
try {
|
||||
// Stop first to clean up, then start fresh
|
||||
this._instance = undefined;
|
||||
this._stoppedAt = Date.now();
|
||||
this.setState('stopped');
|
||||
await this.start();
|
||||
// Success — reset counter
|
||||
this._autoRestartCount = 0;
|
||||
} catch (err) {
|
||||
logger.log('warn', `Service '${this.name}': auto-restart attempt ${this._autoRestartCount} failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
// Schedule another attempt
|
||||
this.scheduleAutoRestart();
|
||||
}
|
||||
}, delay);
|
||||
if (this._autoRestartTimer && typeof this._autoRestartTimer === 'object' && 'unref' in this._autoRestartTimer) {
|
||||
(this._autoRestartTimer as any).unref();
|
||||
}
|
||||
}
|
||||
|
||||
private clearAutoRestartTimer(): void {
|
||||
if (this._autoRestartTimer) {
|
||||
clearTimeout(this._autoRestartTimer);
|
||||
this._autoRestartTimer = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -77,6 +77,23 @@ export class ServiceManager {
|
||||
// Build startup order via topological sort
|
||||
this.startupOrder = this.topologicalSort();
|
||||
this._startedAt = Date.now();
|
||||
|
||||
const startupPromise = this.startAllLevels();
|
||||
|
||||
// Enforce global startup timeout
|
||||
if (this.options.startupTimeoutMs) {
|
||||
await Promise.race([
|
||||
startupPromise,
|
||||
plugins.smartdelay.delayFor(this.options.startupTimeoutMs).then(() => {
|
||||
throw new Error(`${this.name}: global startup timeout exceeded (${this.options.startupTimeoutMs}ms)`);
|
||||
}),
|
||||
]);
|
||||
} else {
|
||||
await startupPromise;
|
||||
}
|
||||
}
|
||||
|
||||
private async startAllLevels(): Promise<void> {
|
||||
const startedServices: string[] = [];
|
||||
|
||||
logger.log('info', `${this.name}: starting ${this.services.size} services in ${this.startupOrder.length} levels`);
|
||||
@@ -178,6 +195,14 @@ export class ServiceManager {
|
||||
return Array.from(this.services.values()).map((s) => s.getStatus());
|
||||
}
|
||||
|
||||
public getServicesByLabel(key: string, value: string): Service[] {
|
||||
return Array.from(this.services.values()).filter((s) => s.labels[key] === value);
|
||||
}
|
||||
|
||||
public getServicesStatusByLabel(key: string, value: string): IServiceStatus[] {
|
||||
return this.getServicesByLabel(key, value).map((s) => s.getStatus());
|
||||
}
|
||||
|
||||
public getHealth(): IServiceManagerHealth {
|
||||
const statuses = this.getAllStatuses();
|
||||
let overall: TOverallHealth = 'healthy';
|
||||
|
||||
@@ -100,7 +100,8 @@ export type TServiceEventType =
|
||||
| 'degraded'
|
||||
| 'recovered'
|
||||
| 'retrying'
|
||||
| 'healthCheck';
|
||||
| 'healthCheck'
|
||||
| 'autoRestarting';
|
||||
|
||||
export interface IServiceEvent {
|
||||
type: TServiceEventType;
|
||||
@@ -124,6 +125,8 @@ export interface IServiceStatus {
|
||||
lastError?: string;
|
||||
retryCount: number;
|
||||
dependencies: string[];
|
||||
labels?: Record<string, string>;
|
||||
hasInstance?: boolean;
|
||||
}
|
||||
|
||||
export interface IRetryConfig {
|
||||
@@ -146,17 +149,27 @@ export interface IHealthCheckConfig {
|
||||
failuresBeforeDegraded?: number;
|
||||
/** Consecutive failures before marking failed. Default: 5 */
|
||||
failuresBeforeFailed?: number;
|
||||
/** Auto-restart the service when it transitions to failed. Default: false */
|
||||
autoRestart?: boolean;
|
||||
/** Maximum number of auto-restart attempts. 0 = unlimited. Default: 3 */
|
||||
maxAutoRestarts?: number;
|
||||
/** Base delay in ms before first auto-restart. Default: 5000 */
|
||||
autoRestartDelayMs?: number;
|
||||
/** Backoff multiplier per auto-restart attempt. Default: 2 */
|
||||
autoRestartBackoffFactor?: number;
|
||||
}
|
||||
|
||||
export interface IServiceOptions<T = any> {
|
||||
name: string;
|
||||
start: () => Promise<T>;
|
||||
stop: () => Promise<void>;
|
||||
healthCheck?: () => Promise<boolean>;
|
||||
stop: (instance: T) => Promise<void>;
|
||||
healthCheck?: (instance: T) => Promise<boolean>;
|
||||
criticality?: TServiceCriticality;
|
||||
dependencies?: string[];
|
||||
retry?: IRetryConfig;
|
||||
healthCheckConfig?: IHealthCheckConfig;
|
||||
startupTimeoutMs?: number;
|
||||
labels?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface IServiceManagerOptions {
|
||||
|
||||
Reference in New Issue
Block a user