-
Notifications
You must be signed in to change notification settings - Fork 0
operational hardening foundation #104
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
402e7f6
3a62e9f
68de808
f707af1
7fbef9b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,126 @@ | ||
| import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; | ||
| import { ShutdownService } from './shutdown.service.js'; | ||
| import type { INestApplication } from '@nestjs/common'; | ||
|
|
||
| describe('ShutdownService', () => { | ||
| let service: ShutdownService; | ||
| let processOnceSpy: ReturnType<typeof vi.spyOn>; | ||
| let processExitSpy: ReturnType<typeof vi.spyOn>; | ||
|
|
||
| beforeEach(() => { | ||
| vi.useFakeTimers(); | ||
| service = new ShutdownService(); | ||
| // Stub process.once so no real OS signal handlers are registered between tests. | ||
| // The spy still captures calls so we can extract handlers and invoke them manually. | ||
| processOnceSpy = vi | ||
| .spyOn(process, 'once') | ||
| .mockImplementation((_event, _listener) => process); | ||
| processExitSpy = vi | ||
| .spyOn(process, 'exit') | ||
| .mockImplementation(() => undefined as never); | ||
| }); | ||
|
|
||
| afterEach(() => { | ||
| vi.useRealTimers(); | ||
| vi.restoreAllMocks(); | ||
| }); | ||
|
|
||
| /** Extract the handler registered for a given signal via process.once. */ | ||
| function getHandler(signal: string): (() => void) | undefined { | ||
| const call = processOnceSpy.mock.calls.find((c) => c[0] === signal); | ||
| return call?.[1] as (() => void) | undefined; | ||
| } | ||
|
|
||
| it('enableShutdownHooks registers handlers for SIGTERM and SIGINT', () => { | ||
| const app = { close: vi.fn() } as unknown as INestApplication; | ||
|
|
||
| service.enableShutdownHooks(app); | ||
|
|
||
| const registeredSignals = processOnceSpy.mock.calls.map((c) => c[0]); | ||
| expect(registeredSignals).toContain('SIGTERM'); | ||
| expect(registeredSignals).toContain('SIGINT'); | ||
| }); | ||
|
|
||
| it('is idempotent — calling enableShutdownHooks twice only registers handlers once', () => { | ||
| const app = { close: vi.fn() } as unknown as INestApplication; | ||
|
|
||
| service.enableShutdownHooks(app); | ||
| service.enableShutdownHooks(app); | ||
|
|
||
| const sigtermCalls = processOnceSpy.mock.calls.filter( | ||
| (c) => c[0] === 'SIGTERM', | ||
| ); | ||
| expect(sigtermCalls).toHaveLength(1); | ||
|
|
||
| const sigintCalls = processOnceSpy.mock.calls.filter( | ||
| (c) => c[0] === 'SIGINT', | ||
| ); | ||
| expect(sigintCalls).toHaveLength(1); | ||
| }); | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| it('on signal, calls app.close() and then process.exit(0)', async () => { | ||
| const closeMock = vi.fn().mockResolvedValue(undefined); | ||
| const app = { close: closeMock } as unknown as INestApplication; | ||
|
|
||
| service.enableShutdownHooks(app); | ||
|
|
||
| const handler = getHandler('SIGTERM'); | ||
| expect(handler).toBeDefined(); | ||
| handler!(); | ||
|
|
||
| await vi.advanceTimersByTimeAsync(0); | ||
|
|
||
| expect(closeMock).toHaveBeenCalledOnce(); | ||
| expect(processExitSpy).toHaveBeenCalledWith(0); | ||
| }); | ||
|
|
||
| it('on signal, if app.close() rejects, calls process.exit(1)', async () => { | ||
| const closeMock = vi.fn().mockRejectedValue(new Error('close failed')); | ||
| const app = { close: closeMock } as unknown as INestApplication; | ||
|
|
||
| service.enableShutdownHooks(app); | ||
|
|
||
| const handler = getHandler('SIGTERM'); | ||
| expect(handler).toBeDefined(); | ||
| handler!(); | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| await vi.advanceTimersByTimeAsync(0); | ||
|
|
||
| expect(closeMock).toHaveBeenCalledOnce(); | ||
| expect(processExitSpy).toHaveBeenCalledWith(1); | ||
| }); | ||
|
|
||
| it('does not call app.close() a second time when a duplicate signal fires while draining', async () => { | ||
| const closeMock = vi.fn().mockReturnValue(new Promise(() => {})); | ||
| const app = { close: closeMock } as unknown as INestApplication; | ||
|
|
||
| service.enableShutdownHooks(app); | ||
|
|
||
| const handler = getHandler('SIGTERM'); | ||
| expect(handler).toBeDefined(); | ||
|
|
||
| // Fire SIGTERM twice in quick succession | ||
| handler!(); | ||
| handler!(); | ||
|
|
||
| await vi.advanceTimersByTimeAsync(0); | ||
|
|
||
| expect(closeMock).toHaveBeenCalledOnce(); | ||
| }); | ||
|
|
||
| it('hard deadline calls process.exit(1) after 30s', async () => { | ||
| const app = { | ||
| close: vi.fn().mockReturnValue(new Promise(() => {})), | ||
| } as unknown as INestApplication; | ||
|
|
||
| service.enableShutdownHooks(app); | ||
|
|
||
| const handler = getHandler('SIGTERM'); | ||
| expect(handler).toBeDefined(); | ||
| handler!(); | ||
|
|
||
| await vi.advanceTimersByTimeAsync(30_000); | ||
|
|
||
| expect(processExitSpy).toHaveBeenCalledWith(1); | ||
| }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,79 @@ | ||
| import { Injectable, Logger } from '@nestjs/common'; | ||
| import type { INestApplication } from '@nestjs/common'; | ||
|
|
||
| /** Hard drain timeout before force-exiting the process. */ | ||
| const DRAIN_TIMEOUT_MS = 30_000; | ||
|
|
||
| /** | ||
| * ShutdownService -- graceful SIGTERM/SIGINT drain. | ||
| * | ||
| * Registers OS signal handlers that call app.close(), which triggers | ||
| * OnModuleDestroy hooks on all providers (including QueueService.stopConsuming()). | ||
| * A hard 30-second deadline force-exits the process if drain stalls. | ||
| * | ||
| * Call enableShutdownHooks(app) immediately after app.listen() in main.ts. | ||
| */ | ||
| @Injectable() | ||
| export class ShutdownService { | ||
| private readonly logger = new Logger(ShutdownService.name); | ||
| /** Prevents re-entrant shutdown if multiple signals arrive while draining. */ | ||
| private shuttingDown = false; | ||
| /** Prevents duplicate listener registration if enableShutdownHooks is called more than once. */ | ||
| private shutdownHooksEnabled = false; | ||
|
|
||
| enableShutdownHooks(app: INestApplication): void { | ||
| if (this.shutdownHooksEnabled) { | ||
| this.logger.warn( | ||
| 'Shutdown hooks already registered — ignoring duplicate call', | ||
| ); | ||
| return; | ||
| } | ||
| this.shutdownHooksEnabled = true; | ||
| // process.once ensures each signal fires the handler at most once, even if | ||
| // the OS delivers duplicates before the first handler finishes executing. | ||
| for (const signal of ['SIGTERM', 'SIGINT'] as const) { | ||
| process.once(signal, () => void this.shutdown(app, signal)); | ||
| } | ||
|
Comment on lines
+34
to
+36
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
# Verify all process-level signal handlers and confirm whether shutdown is centralized.
rg -n -C2 "process\.(once|on)\('SIGTERM'|process\.(once|on)\('SIGINT'"Repository: pramodnarayana/nexiom Length of output: 540 🏁 Script executed: #!/bin/bash
# Get full context of database client initialization and signal handler registration
sed -n '39,51p' packages/database/src/client.tsRepository: pramodnarayana/nexiom Length of output: 479 🏁 Script executed: #!/bin/bash
# Get ShutdownService implementation to see how it coordinates shutdown
cat -n apps/api/src/core/shutdown.service.ts | head -80Repository: pramodnarayana/nexiom Length of output: 3342 🏁 Script executed: #!/bin/bash
# Check if there are any other competing signal handlers in the codebase
rg -n "process\.(once|on)\(" --type ts --type js | grep -E "(SIGTERM|SIGINT|SIGHUP|SIGKILL)" | head -20Repository: pramodnarayana/nexiom Length of output: 229 Unify SIGTERM/SIGINT ownership to avoid shutdown races.
Additionally, at line 72, 🤖 Prompt for AI Agents |
||
| this.logger.log('Graceful shutdown hooks registered (SIGTERM, SIGINT)'); | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| private async shutdown(app: INestApplication, signal: string): Promise<void> { | ||
| if (this.shuttingDown) { | ||
| this.logger.log( | ||
| `Already shutting down — ignoring duplicate signal ${signal}`, | ||
| ); | ||
| return; | ||
| } | ||
| this.shuttingDown = true; | ||
|
|
||
| this.logger.log( | ||
| `Received ${signal} -- draining in-flight work (timeout ${DRAIN_TIMEOUT_MS / 1_000}s)`, | ||
| ); | ||
|
|
||
| // Hard deadline -- force-exits if drain takes too long. | ||
| const deadline = setTimeout(() => { | ||
| this.logger.error( | ||
| `Drain deadline exceeded (${DRAIN_TIMEOUT_MS / 1_000}s) -- forcing exit`, | ||
| ); | ||
| process.exit(1); | ||
| }, DRAIN_TIMEOUT_MS); | ||
| // Allow the event loop to exit naturally if everything else closes first. | ||
| deadline.unref(); | ||
|
|
||
| try { | ||
| // app.close() triggers OnModuleDestroy on all providers. | ||
| // QueueService.stopConsuming() drains SQS consumers here. | ||
| await app.close(); | ||
| clearTimeout(deadline); | ||
| this.logger.log('Graceful shutdown complete'); | ||
| process.exit(0); | ||
| } catch (err) { | ||
| clearTimeout(deadline); | ||
| this.logger.error( | ||
| 'Error during graceful shutdown -- forcing exit', | ||
| err instanceof Error ? (err.stack ?? err.message) : String(err), | ||
| ); | ||
| process.exit(1); | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| } | ||
| } | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.