189 lines
5.5 KiB
TypeScript
189 lines
5.5 KiB
TypeScript
import { getLogger } from '@stock-bot/logger';
|
|
import { SHUTDOWN_DEFAULTS } from './constants';
|
|
import type { ShutdownCallback, ShutdownOptions } from './types';
|
|
|
|
interface CallbackEntry {
|
|
callback: ShutdownCallback;
|
|
priority: number;
|
|
name?: string;
|
|
}
|
|
|
|
export class Shutdown {
|
|
private static instance: Shutdown | null = null;
|
|
private readonly logger = getLogger('shutdown');
|
|
private isShuttingDown = false;
|
|
private shutdownTimeout: number;
|
|
private callbacks: CallbackEntry[] = [];
|
|
private signalHandlersRegistered = false;
|
|
private shutdownPromise: Promise<void> | null = null;
|
|
private triggerSignal: string | null = null;
|
|
|
|
constructor(options: ShutdownOptions = {}) {
|
|
this.shutdownTimeout = options.timeout || SHUTDOWN_DEFAULTS.TIMEOUT;
|
|
if (options.autoRegister !== false) {
|
|
// Register signal handlers immediately in constructor
|
|
this.setupSignalHandlers();
|
|
}
|
|
}
|
|
|
|
static getInstance(options?: ShutdownOptions): Shutdown {
|
|
if (!Shutdown.instance) {
|
|
Shutdown.instance = new Shutdown(options);
|
|
}
|
|
return Shutdown.instance;
|
|
}
|
|
|
|
/**
|
|
* Get current shutdown status
|
|
*/
|
|
getStatus(): { isShuttingDown: boolean; signal: string | null; callbackCount: number } {
|
|
return {
|
|
isShuttingDown: this.isShuttingDown,
|
|
signal: this.triggerSignal,
|
|
callbackCount: this.callbacks.length
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Register a cleanup callback
|
|
*/
|
|
onShutdown(
|
|
callback: ShutdownCallback,
|
|
priority: number = SHUTDOWN_DEFAULTS.MEDIUM_PRIORITY,
|
|
name?: string
|
|
): void {
|
|
if (this.isShuttingDown) {
|
|
return;
|
|
}
|
|
this.callbacks.push({ callback, priority, name });
|
|
}
|
|
|
|
/**
|
|
* Initiate graceful shutdown
|
|
*/
|
|
async shutdown(signal?: string): Promise<void> {
|
|
// If already shutting down, return the existing promise
|
|
if (this.shutdownPromise) {
|
|
this.logger.warn('Shutdown already in progress, ignoring additional request', {
|
|
originalSignal: this.triggerSignal,
|
|
newSignal: signal
|
|
});
|
|
return this.shutdownPromise;
|
|
}
|
|
|
|
if (this.isShuttingDown) {
|
|
this.logger.warn('Shutdown flag already set but no promise, creating new shutdown');
|
|
}
|
|
|
|
this.isShuttingDown = true;
|
|
this.triggerSignal = signal || 'manual';
|
|
|
|
this.logger.info('Starting graceful shutdown', {
|
|
signal: this.triggerSignal,
|
|
timeout: this.shutdownTimeout,
|
|
callbackCount: this.callbacks.length
|
|
});
|
|
|
|
// Create and store the shutdown promise
|
|
this.shutdownPromise = this.performShutdown();
|
|
|
|
try {
|
|
await this.shutdownPromise;
|
|
this.logger.info('Graceful shutdown completed successfully');
|
|
} catch (error) {
|
|
this.logger.error('Shutdown failed', { error, signal: this.triggerSignal });
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
private async performShutdown(): Promise<void> {
|
|
const timeout = new Promise<never>((_, reject) =>
|
|
setTimeout(() => reject(new Error('Shutdown timeout')), this.shutdownTimeout)
|
|
);
|
|
|
|
try {
|
|
await Promise.race([this.executeCallbacks(), timeout]);
|
|
} catch (error) {
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
private async executeCallbacks(): Promise<void> {
|
|
const sorted = [...this.callbacks].sort((a, b) => a.priority - b.priority);
|
|
|
|
this.logger.debug('Executing shutdown callbacks', {
|
|
count: sorted.length,
|
|
callbacks: sorted.map(c => ({ name: c.name || 'unnamed', priority: c.priority }))
|
|
});
|
|
|
|
let completed = 0;
|
|
let failed = 0;
|
|
|
|
for (const { callback, name, priority } of sorted) {
|
|
const callbackName = name || 'unnamed';
|
|
try {
|
|
this.logger.debug(`Starting shutdown callback: ${callbackName}`, { priority });
|
|
const startTime = Date.now();
|
|
|
|
await callback();
|
|
|
|
const duration = Date.now() - startTime;
|
|
completed++;
|
|
this.logger.debug(`Completed shutdown callback: ${callbackName}`, { duration, priority });
|
|
} catch (error) {
|
|
failed++;
|
|
this.logger.error(`Shutdown callback failed: ${callbackName}`, { error, priority });
|
|
}
|
|
}
|
|
|
|
this.logger.info('Shutdown callbacks execution completed', {
|
|
total: sorted.length,
|
|
completed,
|
|
failed
|
|
});
|
|
}
|
|
|
|
private setupSignalHandlers(): void {
|
|
if (this.signalHandlersRegistered) {
|
|
this.logger.debug('Signal handlers already registered');
|
|
return;
|
|
}
|
|
|
|
const signals: NodeJS.Signals[] = ['SIGTERM', 'SIGINT'];
|
|
|
|
signals.forEach(signal => {
|
|
// Use process.on instead of process.once to handle multiple signals
|
|
process.on(signal, async () => {
|
|
this.logger.info(`Received ${signal} signal`);
|
|
|
|
// If already shutting down, ignore the signal
|
|
if (this.isShuttingDown || this.shutdownPromise) {
|
|
this.logger.info(`Already shutting down, ignoring ${signal}`);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await this.shutdown(signal);
|
|
|
|
// Add small delay to ensure logs are flushed
|
|
await new Promise(resolve => setTimeout(resolve, 100));
|
|
|
|
this.logger.info('Exiting process with code 0');
|
|
process.exit(0);
|
|
} catch (error) {
|
|
this.logger.error('Shutdown error, exiting with code 1', { error });
|
|
|
|
// Add small delay to ensure error logs are flushed
|
|
await new Promise(resolve => setTimeout(resolve, 100));
|
|
|
|
process.exit(1);
|
|
}
|
|
});
|
|
|
|
this.logger.debug(`Registered handler for ${signal}`);
|
|
});
|
|
|
|
this.signalHandlersRegistered = true;
|
|
this.logger.info('Signal handlers registered for graceful shutdown', { signals });
|
|
}
|
|
}
|