Skip to content

Commit d6ec641

Browse files
Merge pull request #174 from Mkalbani/feature/process-shutdown-hygiene
Feature/process shutdown hygiene
2 parents a1d68db + 34649f0 commit d6ec641

File tree

7 files changed

+123
-50
lines changed

7 files changed

+123
-50
lines changed

README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,4 +118,12 @@ The app validates all environment variables at startup using [Zod](https://zod.d
118118
| `LOG_LEVEL` | No | `info` | `trace` / `debug` / `info` / `warn` / `error` / `fatal` |
119119
| `GATEWAY_PROFILING_ENABLED` | No | `false` | Enable request profiling |
120120

121+
## Production Shutdown Expectations
122+
123+
- The server listens for `SIGTERM` and `SIGINT` and performs a graceful shutdown.
124+
- On shutdown, it stops accepting new HTTP requests, waits for active connections to finish, and closes database resources.
125+
- A 30 second timeout is enforced for in-flight connections; lingering sockets are destroyed to prevent hung termination.
126+
- Shutdown hooks are registered with `process.once(...)` to avoid duplicate execution during restarts.
127+
- The dev workflow (`npm run dev` with `tsx watch`) is preserved. Restarts trigger the same graceful path instead of abrupt termination.
128+
121129
This repo is part of [Callora](https://github.com/your-org/callora). Frontend: `callora-frontend`. Contracts: `callora-contracts`.

package-lock.json

Lines changed: 21 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/db.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ export const pool = new Pool({
1818
connectionTimeoutMillis: config.dbPool.connectionTimeoutMillis,
1919
});
2020

21+
let poolClosed = false;
22+
2123
/**
2224
* Convenience helper that proxies to pool.query for simple one-off queries.
2325
*/
@@ -42,4 +44,12 @@ export async function checkDbHealth(): Promise<{ ok: boolean; error?: string }>
4244
error: error instanceof Error ? error.message : 'Unknown database error',
4345
};
4446
}
47+
}
48+
49+
export async function closePgPool(): Promise<void> {
50+
if (poolClosed) {
51+
return;
52+
}
53+
await pool.end();
54+
poolClosed = true;
4555
}

src/db/index.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { readFileSync } from 'fs';
55
import { join } from 'path';
66

77
const logger = console;
8+
let sqliteClosed = false;
89

910
// Create SQLite database instance
1011
const sqlite = new Database('./database.db');
@@ -61,7 +62,11 @@ export async function initializeDb() {
6162

6263
// Graceful shutdown
6364
// Export close function for graceful shutdown
64-
export function closeDb() {
65+
export async function closeDb(): Promise<void> {
66+
if (sqliteClosed) {
67+
return;
68+
}
6569
sqlite.close();
70+
sqliteClosed = true;
6671
}
6772
export { schema };

src/index.test.ts

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
/// <reference types="jest" />
22
import request from 'supertest';
3-
import app from './index.js';
3+
import type { Server } from 'http';
4+
import app, { createGracefulShutdownHandler } from './index.js';
45

56
jest.mock('./db/index.js', () => ({
67
db: {},
@@ -14,3 +15,48 @@ describe('Health API', () => {
1415
expect(response.body.status).toBe('ok');
1516
});
1617
});
18+
19+
describe('graceful shutdown', () => {
20+
it('closes server and database resources', async () => {
21+
const closeServer = jest.fn((callback: (err?: Error) => void) => callback());
22+
const closeDatabase = jest.fn(async () => Promise.resolve());
23+
const logger = { log: jest.fn(), warn: jest.fn(), error: jest.fn() };
24+
25+
const shutdown = createGracefulShutdownHandler({
26+
server: { close: closeServer } as unknown as Server,
27+
activeConnections: new Set(),
28+
closeDatabase,
29+
logger,
30+
timeoutMs: 50,
31+
});
32+
33+
await expect(shutdown('SIGTERM')).resolves.toBe(0);
34+
expect(closeServer).toHaveBeenCalledTimes(1);
35+
expect(closeDatabase).toHaveBeenCalledTimes(1);
36+
});
37+
38+
it('reuses in-flight shutdown promise on repeated signals', async () => {
39+
let closeCallback: ((err?: Error) => void) | undefined;
40+
const closeServer = jest.fn((callback: (err?: Error) => void) => {
41+
closeCallback = callback;
42+
});
43+
const closeDatabase = jest.fn(async () => Promise.resolve());
44+
45+
const shutdown = createGracefulShutdownHandler({
46+
server: { close: closeServer } as unknown as Server,
47+
activeConnections: new Set(),
48+
closeDatabase,
49+
timeoutMs: 50,
50+
});
51+
52+
const first = shutdown('SIGTERM');
53+
const second = shutdown('SIGINT');
54+
55+
expect(closeServer).toHaveBeenCalledTimes(1);
56+
closeCallback?.();
57+
58+
await expect(first).resolves.toBe(0);
59+
await expect(second).resolves.toBe(0);
60+
expect(closeDatabase).toHaveBeenCalledTimes(1);
61+
});
62+
});

src/index.ts

Lines changed: 24 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
import './config/env.js'
22
import express from 'express';
33
import { initializeDb, closeDb } from './db/index.js';
4-
import { type AuthenticatedLocals } from './middleware/requireAuth.js';
4+
import { closePgPool } from './db.js';
5+
import { closeDbPool } from './config/health.js';
6+
import { disconnectPrisma } from './lib/prisma.js';
57
import { errorHandler } from './middleware/errorHandler.js';
68
import { createGatewayIpAllowlist } from './middleware/ipAllowlist.js';
79
import type { Response } from 'express';
810
import type { Socket } from 'net';
11+
import type { Server } from 'http';
912

1013
import { createDeveloperRouter } from './routes/developerRoutes.js';
1114
import { createGatewayRouter } from './routes/gatewayRoutes.js';
@@ -86,6 +89,15 @@ if (isDirectExecution) {
8689

8790
const PORT = config.port;
8891

92+
const closeAllDataResources = async () => {
93+
await closeDb();
94+
await Promise.allSettled([
95+
closePgPool(),
96+
disconnectPrisma(),
97+
closeDbPool(),
98+
]);
99+
};
100+
89101
// Initialize database and start server
90102
async function startServer() {
91103
try {
@@ -103,52 +115,21 @@ if (isDirectExecution) {
103115
socket.once('close', () => activeConnections.delete(socket));
104116
});
105117

106-
async function gracefulShutdown(signal: string) {
107-
console.log(`\n[shutdown] Received ${signal}. Starting graceful shutdown...`);
108-
109-
// 1. Stop accepting new requests
110-
server.close(() => {
111-
console.log('[shutdown] HTTP server closed. No new requests accepted.');
112-
});
118+
const gracefulShutdown = createGracefulShutdownHandler({
119+
server,
120+
activeConnections,
121+
closeDatabase: closeAllDataResources,
122+
});
113123

114-
// 2. Wait for in-flight requests to finish (max 30s)
115-
const TIMEOUT_MS = 30_000;
116-
const deadline = setTimeout(() => {
117-
console.warn('[shutdown] Timeout reached. Forcing exit.');
118-
process.exit(1);
119-
}, TIMEOUT_MS);
120-
deadline.unref();
121-
122-
// 3. Wait until all active connections are gone
123-
await new Promise<void>((resolve) => {
124-
if (activeConnections.size === 0) return resolve();
125-
console.log(`[shutdown] Waiting for ${activeConnections.size} in-flight connection(s)...`);
126-
const interval = setInterval(() => {
127-
if (activeConnections.size === 0) {
128-
clearInterval(interval);
129-
resolve();
130-
}
131-
}, 200);
124+
const onSignal = (signal: NodeJS.Signals) => {
125+
void gracefulShutdown(signal).then((exitCode) => {
126+
process.exit(exitCode);
132127
});
133-
134-
// 4. Close the database
135-
console.log('[shutdown] Closing database...');
136-
try {
137-
closeDb();
138-
console.log('[shutdown] Database closed.');
139-
} catch (err) {
140-
console.error('[shutdown] Error closing database:', err);
141-
}
142-
143-
// 5. Exit cleanly
144-
console.log('[shutdown] Shutdown complete. Exiting.');
145-
clearTimeout(deadline);
146-
process.exit(0);
147-
}
128+
};
148129

149130
// Register shutdown signals
150-
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
151-
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
131+
process.once('SIGTERM', () => onSignal('SIGTERM'));
132+
process.once('SIGINT', () => onSignal('SIGINT'));
152133

153134
} catch (error) {
154135
console.error('Failed to start server:', error);

src/lib/prisma.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,13 @@ function getPrismaClient(): PrismaClient {
1515
return prisma;
1616
}
1717

18+
export async function disconnectPrisma(): Promise<void> {
19+
if (!prisma) {
20+
return;
21+
}
22+
await prisma.$disconnect();
23+
}
24+
1825
export default new Proxy({} as PrismaClient, {
1926
get(_target, prop, receiver) {
2027
const client = getPrismaClient();

0 commit comments

Comments
 (0)