From caa57cdfa8e6718062d2c511031f8d6c3ffe9900 Mon Sep 17 00:00:00 2001 From: Musa Khalid <112591148+Mkalbani@users.noreply.github.com> Date: Thu, 26 Mar 2026 21:20:12 +0000 Subject: [PATCH 1/2] feat(server): graceful shutdown hooks --- README.md | 8 +++ src/db.ts | 10 ++++ src/db/index.ts | 7 ++- src/index.test.ts | 48 ++++++++++++++++- src/index.ts | 132 ++++++++++++++++++++++++++++++---------------- src/lib/prisma.ts | 7 +++ 6 files changed, 166 insertions(+), 46 deletions(-) diff --git a/README.md b/README.md index 7ea2025..c3ea8cf 100644 --- a/README.md +++ b/README.md @@ -82,4 +82,12 @@ callora-backend/ - `PORT` — HTTP port (default: 3000). Optional for local dev. +## Production Shutdown Expectations + +- The server listens for `SIGTERM` and `SIGINT` and performs a graceful shutdown. +- On shutdown, it stops accepting new HTTP requests, waits for active connections to finish, and closes database resources. +- A 30 second timeout is enforced for in-flight connections; lingering sockets are destroyed to prevent hung termination. +- Shutdown hooks are registered with `process.once(...)` to avoid duplicate execution during restarts. +- The dev workflow (`npm run dev` with `tsx watch`) is preserved. Restarts trigger the same graceful path instead of abrupt termination. + This repo is part of [Callora](https://github.com/your-org/callora). Frontend: `callora-frontend`. Contracts: `callora-contracts`. diff --git a/src/db.ts b/src/db.ts index 5347dad..ce7003a 100644 --- a/src/db.ts +++ b/src/db.ts @@ -18,6 +18,8 @@ export const pool = new Pool({ connectionTimeoutMillis: config.dbPool.connectionTimeoutMillis, }); +let poolClosed = false; + /** * Convenience helper that proxies to pool.query for simple one-off queries. */ @@ -42,4 +44,12 @@ export async function checkDbHealth(): Promise<{ ok: boolean; error?: string }> error: error instanceof Error ? error.message : 'Unknown database error', }; } +} + +export async function closePgPool(): Promise { + if (poolClosed) { + return; + } + await pool.end(); + poolClosed = true; } \ No newline at end of file diff --git a/src/db/index.ts b/src/db/index.ts index 28e1db3..189e93c 100644 --- a/src/db/index.ts +++ b/src/db/index.ts @@ -5,6 +5,7 @@ import { readFileSync } from 'fs'; import { join } from 'path'; const logger = console; +let sqliteClosed = false; // Create SQLite database instance const sqlite = new Database('./database.db'); @@ -61,7 +62,11 @@ export async function initializeDb() { // Graceful shutdown // Export close function for graceful shutdown -export function closeDb() { +export async function closeDb(): Promise { + if (sqliteClosed) { + return; + } sqlite.close(); + sqliteClosed = true; } export { schema }; diff --git a/src/index.test.ts b/src/index.test.ts index f1fb99c..1bb2b6c 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -1,6 +1,7 @@ /// import request from 'supertest'; -import app from './index.js'; +import type { Server } from 'http'; +import app, { createGracefulShutdownHandler } from './index.js'; jest.mock('./db/index.js', () => ({ db: {}, @@ -14,3 +15,48 @@ describe('Health API', () => { expect(response.body.status).toBe('ok'); }); }); + +describe('graceful shutdown', () => { + it('closes server and database resources', async () => { + const closeServer = jest.fn((callback: (err?: Error) => void) => callback()); + const closeDatabase = jest.fn(async () => Promise.resolve()); + const logger = { log: jest.fn(), warn: jest.fn(), error: jest.fn() }; + + const shutdown = createGracefulShutdownHandler({ + server: { close: closeServer } as unknown as Server, + activeConnections: new Set(), + closeDatabase, + logger, + timeoutMs: 50, + }); + + await expect(shutdown('SIGTERM')).resolves.toBe(0); + expect(closeServer).toHaveBeenCalledTimes(1); + expect(closeDatabase).toHaveBeenCalledTimes(1); + }); + + it('reuses in-flight shutdown promise on repeated signals', async () => { + let closeCallback: ((err?: Error) => void) | undefined; + const closeServer = jest.fn((callback: (err?: Error) => void) => { + closeCallback = callback; + }); + const closeDatabase = jest.fn(async () => Promise.resolve()); + + const shutdown = createGracefulShutdownHandler({ + server: { close: closeServer } as unknown as Server, + activeConnections: new Set(), + closeDatabase, + timeoutMs: 50, + }); + + const first = shutdown('SIGTERM'); + const second = shutdown('SIGINT'); + + expect(closeServer).toHaveBeenCalledTimes(1); + closeCallback?.(); + + await expect(first).resolves.toBe(0); + await expect(second).resolves.toBe(0); + expect(closeDatabase).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/index.ts b/src/index.ts index 678031c..923d1d3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,9 +1,11 @@ import express from 'express'; import { initializeDb, closeDb } from './db/index.js'; -import { type AuthenticatedLocals } from './middleware/requireAuth.js'; +import { closePgPool } from './db.js'; +import { closeDbPool } from './config/health.js'; +import { disconnectPrisma } from './lib/prisma.js'; import { errorHandler } from './middleware/errorHandler.js'; -import type { Response } from 'express'; import type { Socket } from 'net'; +import type { Server } from 'http'; import { createDeveloperRouter } from './routes/developerRoutes.js'; import { createGatewayRouter } from './routes/gatewayRoutes.js'; @@ -15,7 +17,71 @@ import { createSettlementStore } from './services/settlementStore.js'; import { createApiRegistry } from './data/apiRegistry.js'; import { ApiKey } from './types/gateway.js'; +type ShutdownLogger = Pick; +interface GracefulShutdownOptions { + server: Server; + activeConnections: Set; + closeDatabase: () => Promise; + logger?: ShutdownLogger; + timeoutMs?: number; +} + +export function createGracefulShutdownHandler({ + server, + activeConnections, + closeDatabase, + logger = console, + timeoutMs = 30_000, +}: GracefulShutdownOptions) { + let shutdownPromise: Promise | null = null; + + return async (signal: NodeJS.Signals | string): Promise => { + if (shutdownPromise) { + return shutdownPromise; + } + + shutdownPromise = (async () => { + logger.log(`\n[shutdown] Received ${signal}. Starting graceful shutdown...`); + + const timeoutHandle = setTimeout(() => { + if (activeConnections.size > 0) { + logger.warn(`[shutdown] Timeout reached. Destroying ${activeConnections.size} connection(s).`); + for (const socket of activeConnections) { + socket.destroy(); + } + } + }, timeoutMs); + timeoutHandle.unref(); + + try { + await new Promise((resolve, reject) => { + server.close((err?: Error) => { + if (err) { + reject(err); + return; + } + resolve(); + }); + }); + logger.log('[shutdown] HTTP server closed. No new requests accepted.'); + + await closeDatabase(); + logger.log('[shutdown] Database and pools closed.'); + + logger.log('[shutdown] Shutdown complete.'); + return 0; + } catch (error) { + logger.error('[shutdown] Shutdown failed:', error); + return 1; + } finally { + clearTimeout(timeoutHandle); + } + })(); + + return shutdownPromise; + }; +} // Helper for Jest/CommonJS compat const isDirectExecution = process.argv[1] && (process.argv[1].endsWith('index.ts') || process.argv[1].endsWith('index.js')); @@ -85,6 +151,15 @@ if (isDirectExecution) { const PORT = process.env.PORT ?? 3000; + const closeAllDataResources = async () => { + await closeDb(); + await Promise.allSettled([ + closePgPool(), + disconnectPrisma(), + closeDbPool(), + ]); + }; + // Initialize database and start server async function startServer() { try { @@ -102,52 +177,21 @@ if (isDirectExecution) { socket.once('close', () => activeConnections.delete(socket)); }); - async function gracefulShutdown(signal: string) { - console.log(`\n[shutdown] Received ${signal}. Starting graceful shutdown...`); + const gracefulShutdown = createGracefulShutdownHandler({ + server, + activeConnections, + closeDatabase: closeAllDataResources, + }); - // 1. Stop accepting new requests - server.close(() => { - console.log('[shutdown] HTTP server closed. No new requests accepted.'); + const onSignal = (signal: NodeJS.Signals) => { + void gracefulShutdown(signal).then((exitCode) => { + process.exit(exitCode); }); - - // 2. Wait for in-flight requests to finish (max 30s) - const TIMEOUT_MS = 30_000; - const deadline = setTimeout(() => { - console.warn('[shutdown] Timeout reached. Forcing exit.'); - process.exit(1); - }, TIMEOUT_MS); - deadline.unref(); - - // 3. Wait until all active connections are gone - await new Promise((resolve) => { - if (activeConnections.size === 0) return resolve(); - console.log(`[shutdown] Waiting for ${activeConnections.size} in-flight connection(s)...`); - const interval = setInterval(() => { - if (activeConnections.size === 0) { - clearInterval(interval); - resolve(); - } - }, 200); - }); - - // 4. Close the database - console.log('[shutdown] Closing database...'); - try { - closeDb(); - console.log('[shutdown] Database closed.'); - } catch (err) { - console.error('[shutdown] Error closing database:', err); - } - - // 5. Exit cleanly - console.log('[shutdown] Shutdown complete. Exiting.'); - clearTimeout(deadline); - process.exit(0); - } + }; // Register shutdown signals - process.on('SIGTERM', () => gracefulShutdown('SIGTERM')); - process.on('SIGINT', () => gracefulShutdown('SIGINT')); + process.once('SIGTERM', () => onSignal('SIGTERM')); + process.once('SIGINT', () => onSignal('SIGINT')); } catch (error) { console.error('Failed to start server:', error); diff --git a/src/lib/prisma.ts b/src/lib/prisma.ts index 7239eab..4147552 100644 --- a/src/lib/prisma.ts +++ b/src/lib/prisma.ts @@ -15,6 +15,13 @@ function getPrismaClient(): PrismaClient { return prisma; } +export async function disconnectPrisma(): Promise { + if (!prisma) { + return; + } + await prisma.$disconnect(); +} + export default new Proxy({} as PrismaClient, { get(_target, prop, receiver) { const client = getPrismaClient(); From e7636e8d948b3922c2e8b9f0124a6ce4c913965c Mon Sep 17 00:00:00 2001 From: Musa Khalid <112591148+Mkalbani@users.noreply.github.com> Date: Thu, 26 Mar 2026 21:24:15 +0000 Subject: [PATCH 2/2] feat: add peer dependency flag to multiple packages in package-lock.json --- package-lock.json | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 322fc8b..81e9154 100644 --- a/package-lock.json +++ b/package-lock.json @@ -86,6 +86,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -608,7 +609,8 @@ "version": "0.3.15", "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.3.15.tgz", "integrity": "sha512-Cj++n1Mekf9ETfdc16TlDi+cDDQF0W7EcbyRHYOAeZdsAe8M/FJg18itDTSwyHfar2WIezawM9o0EKaRGVKygQ==", - "license": "Apache-2.0" + "license": "Apache-2.0", + "peer": true }, "node_modules/@electric-sql/pglite-socket": { "version": "0.0.20", @@ -2611,6 +2613,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=8.0.0" } @@ -2992,6 +2995,7 @@ "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@types/node": "*" } @@ -3184,6 +3188,7 @@ "integrity": "sha512-RmhMd/wD+CF8Dfo+cVIy3RR5cl8CyfXQ0tGgW6XBL8L4LM/UTEbNXYRbLwU6w+CgrKBNbrQWt4FUtTfaU5jSYQ==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@types/node": "*", "pg-protocol": "*", @@ -3337,6 +3342,7 @@ "integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/types": "8.56.1", @@ -3830,6 +3836,7 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4189,6 +4196,7 @@ "integrity": "sha512-yR5HATnqeYNVnkaUTf4bOP2dJSnyhP4puJN/QPRyx4YkBEEUxib422n2XzPqDEHjQQqazoYoADdAm5vE15+dAQ==", "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" @@ -4338,6 +4346,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -4866,8 +4875,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/d": { "version": "1.0.2", @@ -5586,6 +5594,7 @@ "integrity": "sha512-uYixubwmqJZH+KLVYIVKY1JQt7tysXhtj21WSvjcSmU5SVNzMus1bgLe+pAt816yQ8opKfheVVoPLqvVMGejYw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", @@ -6683,6 +6692,7 @@ "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.3.tgz", "integrity": "sha512-SFsVSjp8sj5UumXOOFlkZOG6XS9SJDKw0TbwFeV+AJ8xlST8kxK5Z/5EYa111UY8732lK2S/xB653ceuaoGwpg==", "license": "MIT", + "peer": true, "engines": { "node": ">=16.9.0" } @@ -7094,6 +7104,7 @@ "integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/core": "30.2.0", "@jest/types": "30.2.0", @@ -8842,6 +8853,7 @@ "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.15.3.tgz", "integrity": "sha512-FBrGau0IXmuqg4haEZRBfHNWB5mUARw6hNwPDXXGg0XzVJ50mr/9hb267lvpVMnhZ1FON3qNd4Xfcez1rbFwSg==", "license": "MIT", + "peer": true, "dependencies": { "aws-ssl-profiles": "^1.1.1", "denque": "^2.1.0", @@ -9309,6 +9321,7 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.19.0.tgz", "integrity": "sha512-QIcLGi508BAHkQ3pJNptsFz5WQMlpGbuBGBaIaXsWK8mel2kQ/rThYI+DbgjUvZrIr7MiuEuc9LcChJoEZK1xQ==", "license": "MIT", + "peer": true, "dependencies": { "pg-connection-string": "^2.11.0", "pg-pool": "^3.12.0", @@ -9653,6 +9666,7 @@ "resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.7.tgz", "integrity": "sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw==", "license": "Unlicense", + "peer": true, "engines": { "node": ">=12" }, @@ -9758,6 +9772,7 @@ "integrity": "sha512-gDKOXwnPiMdB+uYMhMeN8jj4K7Cu3Q2wB/wUsITOoOk446HtVb8T9BZxFJ1Zop6alc89k6PMNdR2FZCpbXp/jw==", "hasInstallScript": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@prisma/config": "7.4.1", "@prisma/dev": "0.20.0", @@ -10172,8 +10187,7 @@ "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/semver": { "version": "7.7.4", @@ -11605,6 +11619,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -12064,6 +12079,7 @@ "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "dev": true, "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" }