simplified a lot of stuff

This commit is contained in:
Boki 2025-06-26 15:34:48 -04:00
parent b845a8eade
commit 885b484a37
20 changed files with 360 additions and 1335 deletions

View file

@ -1,46 +1,28 @@
/**
* Shutdown management for Node.js applications
*
* Features:
* - Automatic signal handling (SIGTERM, SIGINT, etc.)
* - Configurable shutdown timeout
* - Multiple cleanup callbacks with error handling
* - Platform-specific signal support (Windows/Unix)
*/
import { getLogger } from '@stock-bot/logger';
import type {
PrioritizedShutdownCallback,
ShutdownCallback,
ShutdownOptions,
ShutdownResult,
} from './types';
import type { ShutdownCallback, ShutdownOptions } from './types';
import { SHUTDOWN_DEFAULTS } from './constants';
// Global flag that works across all processes/workers
declare global {
var __SHUTDOWN_SIGNAL_RECEIVED__: boolean | undefined;
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 signalReceived = false; // Track if shutdown signal was received
private shutdownTimeout = 30000; // 30 seconds default
private callbacks: PrioritizedShutdownCallback[] = [];
private shutdownTimeout: number;
private callbacks: CallbackEntry[] = [];
private signalHandlersRegistered = false;
constructor(options: ShutdownOptions = {}) {
this.shutdownTimeout = options.timeout || 30000;
this.shutdownTimeout = options.timeout || SHUTDOWN_DEFAULTS.TIMEOUT;
if (options.autoRegister !== false) {
this.setupSignalHandlers();
}
}
/**
* Get or create singleton instance
*/
static getInstance(options?: ShutdownOptions): Shutdown {
if (!Shutdown.instance) {
Shutdown.instance = new Shutdown(options);
@ -49,211 +31,63 @@ export class Shutdown {
}
/**
* Reset singleton instance (mainly for testing)
* Register a cleanup callback
*/
static reset(): void {
Shutdown.instance = null;
}
/**
* Register a cleanup callback with priority (lower numbers = higher priority)
*/
onShutdown(callback: ShutdownCallback, priority: number = 50, name?: string): void {
if (this.isShuttingDown) {
return;
}
onShutdown(callback: ShutdownCallback, priority: number = SHUTDOWN_DEFAULTS.MEDIUM_PRIORITY, name?: string): void {
if (this.isShuttingDown) return;
this.callbacks.push({ callback, priority, name });
}
/**
* Register a high priority shutdown callback (for queues, critical services)
*/
onShutdownHigh(callback: ShutdownCallback, name?: string): void {
this.onShutdown(callback, 10, name);
}
/**
* Register a medium priority shutdown callback (for databases, connections)
*/
onShutdownMedium(callback: ShutdownCallback, name?: string): void {
this.onShutdown(callback, 50, name);
}
/**
* Register a low priority shutdown callback (for loggers, cleanup)
*/
onShutdownLow(callback: ShutdownCallback, name?: string): void {
this.onShutdown(callback, 90, name);
}
/**
* Set shutdown timeout in milliseconds
*/
setTimeout(timeout: number): void {
if (isNaN(timeout) || timeout <= 0) {
throw new Error('Shutdown timeout must be a positive number');
}
this.shutdownTimeout = timeout;
}
/**
* Get current shutdown state
*/
isShutdownInProgress(): boolean {
return this.isShuttingDown;
}
/**
* Check if shutdown signal was received (for quick checks in running jobs)
*/
isShutdownSignalReceived(): boolean {
const globalFlag = (globalThis as any).__SHUTDOWN_SIGNAL_RECEIVED__ || false;
return globalFlag || this.signalReceived || this.isShuttingDown;
}
/**
* Get number of registered callbacks
*/
getCallbackCount(): number {
return this.callbacks.length;
}
/**
* Initiate graceful shutdown
*/
async shutdown(_signal?: string): Promise<ShutdownResult> {
if (this.isShuttingDown) {
return {
success: false,
callbacksExecuted: 0,
callbacksFailed: 0,
duration: 0,
error: 'Shutdown already in progress',
};
}
async shutdown(): Promise<void> {
if (this.isShuttingDown) return;
this.isShuttingDown = true;
const startTime = Date.now();
const shutdownPromise = this.executeCallbacks();
const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(() => reject(new Error('Shutdown timeout')), this.shutdownTimeout);
});
let result: ShutdownResult;
const timeout = new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error('Shutdown timeout')), this.shutdownTimeout)
);
try {
const callbackResult = await Promise.race([shutdownPromise, timeoutPromise]);
const duration = Date.now() - startTime;
result = {
success: callbackResult.failed === 0,
callbacksExecuted: callbackResult.executed,
callbacksFailed: callbackResult.failed,
duration,
error: callbackResult.failed > 0 ? `${callbackResult.failed} callbacks failed` : undefined,
};
await Promise.race([this.executeCallbacks(), timeout]);
} catch (error) {
const duration = Date.now() - startTime;
const errorMessage = error instanceof Error ? error.message : String(error);
result = {
success: false,
callbacksExecuted: 0,
callbacksFailed: 0,
duration,
error: errorMessage,
};
this.logger.error('Shutdown failed', error);
throw error;
}
// Don't call process.exit here - let the caller decide
return result;
}
/**
* Initiate shutdown and exit process
*/
async shutdownAndExit(signal?: string, exitCode = 0): Promise<never> {
const result = await this.shutdown(signal);
const finalExitCode = result.success ? exitCode : 1;
process.exit(finalExitCode);
}
/**
* Execute all registered callbacks in priority order
*/
private async executeCallbacks(): Promise<{ executed: number; failed: number }> {
if (this.callbacks.length === 0) {
return { executed: 0, failed: 0 };
}
// Sort callbacks by priority (lower numbers = higher priority = execute first)
const sortedCallbacks = [...this.callbacks].sort((a, b) => a.priority - b.priority);
let executed = 0;
let failed = 0;
// Execute callbacks in order by priority
for (const { callback, name, priority } of sortedCallbacks) {
executed++; // Count all attempted executions
private async executeCallbacks(): Promise<void> {
const sorted = [...this.callbacks].sort((a, b) => a.priority - b.priority);
for (const { callback, name } of sorted) {
try {
await callback();
} catch (error) {
failed++;
if (name) {
this.logger.error(`Shutdown failed: ${name} (priority: ${priority})`, error);
}
this.logger.error(`Shutdown callback failed: ${name || 'unnamed'}`, error);
}
}
return { executed, failed };
}
/**
* Setup signal handlers for graceful shutdown
*/
private setupSignalHandlers(): void {
if (this.signalHandlersRegistered) {
return;
}
// Platform-specific signals
const signals: NodeJS.Signals[] =
process.platform === 'win32' ? ['SIGINT', 'SIGTERM'] : ['SIGTERM', 'SIGINT', 'SIGUSR2'];
if (this.signalHandlersRegistered) return;
const signals: NodeJS.Signals[] = ['SIGTERM', 'SIGINT'];
signals.forEach(signal => {
process.on(signal, () => {
// Only process if not already shutting down
process.once(signal, async () => {
if (!this.isShuttingDown) {
// Set signal flag immediately for quick checks
this.signalReceived = true;
// Also set global flag for workers/other processes
globalThis.__SHUTDOWN_SIGNAL_RECEIVED__ = true;
this.shutdownAndExit(signal).catch(() => {
try {
await this.shutdown();
process.exit(0);
} catch {
process.exit(1);
});
}
}
});
});
// Handle uncaught exceptions
process.on('uncaughtException', () => {
this.signalReceived = true;
this.shutdownAndExit('uncaughtException', 1).catch(() => {
process.exit(1);
});
});
// Handle unhandled promise rejections
process.on('unhandledRejection', () => {
this.signalReceived = true;
this.shutdownAndExit('unhandledRejection', 1).catch(() => {
process.exit(1);
});
});
this.signalHandlersRegistered = true;
}
}
}