Skip to content

Commit 88bb4e5

Browse files
authored
Merge pull request #682 from nomsoscript/fix/graceful-axum-shutdown-452
fix: graceful shutdown on SIGINT
2 parents 5a2cc8d + c803097 commit 88bb4e5

6 files changed

Lines changed: 286 additions & 34 deletions

File tree

backend/src/Main.rs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,9 @@ async fn main() -> anyhow::Result<()> {
125125
"Dispute file analysis queue initialised"
126126
);
127127

128+
let shutdown_pool = pool.clone();
129+
let shutdown_queue = queue_tx.clone();
130+
128131
// ── 4. Application state ────────────────────────────────────────────────
129132
let state = Arc::new(AppState {
130133
db: pool,
@@ -150,6 +153,11 @@ async fn main() -> anyhow::Result<()> {
150153
.with_graceful_shutdown(shutdown_signal())
151154
.await?;
152155

156+
info!("HTTP listener stopped; closing dispute queue and draining database pool");
157+
shutdown_queue.close();
158+
shutdown_pool.close().await;
159+
info!("Axum server shutdown completed");
160+
153161
Ok(())
154162
}
155163

@@ -209,4 +217,4 @@ async fn shutdown_signal() {
209217
_ = ctrl_c => { info!("Received Ctrl-C, shutting down") },
210218
_ = terminate => { info!("Received SIGTERM, shutting down") },
211219
}
212-
}
220+
}

backend/src/config/db.ts

Lines changed: 5 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -346,20 +346,12 @@ export const prisma = globalForPrisma.prisma || createPrismaClient();
346346
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
347347

348348
// ---------------------------------------------------------------------------
349-
// Graceful shutdown — release pool connections on process exit signals
349+
// Graceful pool draining — called by the server-level shutdown handler
350350
// ---------------------------------------------------------------------------
351-
async function gracefulShutdown(signal: string): Promise<void> {
351+
export async function drainDatabasePool(signal = "shutdown"): Promise<void> {
352352
console.log(`[POOL] Received ${signal}. Draining connection pool...`);
353353
stopPoolHealthCheck();
354-
try {
355-
await prisma.$disconnect();
356-
await pool.end();
357-
console.log("[POOL] Connection pool drained successfully.");
358-
} catch (err: any) {
359-
console.error("[POOL] Error during pool shutdown:", err.message);
360-
}
361-
process.exit(0);
354+
await prisma.$disconnect();
355+
await pool.end();
356+
console.log("[POOL] Connection pool drained successfully.");
362357
}
363-
364-
process.on("SIGTERM", () => gracefulShutdown("SIGTERM"));
365-
process.on("SIGINT", () => gracefulShutdown("SIGINT"));

backend/src/index.ts

Lines changed: 60 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,23 @@
11
import express, { Express, Request, Response, NextFunction } from "express";
2+
import type { Server } from "node:http";
23
import cors from "cors";
34
import cookieParser from "cookie-parser";
45
import crypto from "crypto";
56
import dotenv from "dotenv";
6-
import { prisma, connectWithRetry, startPoolHealthCheck } from "./config/db";
7+
import {
8+
connectWithRetry,
9+
drainDatabasePool,
10+
pool,
11+
startPoolHealthCheck,
12+
stopPoolHealthCheck,
13+
} from "./config/db";
714
import { trace } from "./config/tracing";
815
import { intakeRateLimit } from "./middleware/intakeRateLimit";
916
import { sqlInjectionGuard } from "./middleware/sanitize";
1017
import { tracingMiddleware } from "./utils/tracing";
1118
import { metricsMiddleware } from "./middleware/metrics";
1219
import { createMetricsRouter, updatePoolMetrics } from "./utils/metrics";
20+
import { closeHttpServer, createGracefulShutdownHandler } from "./utils/graceful-shutdown";
1321
import authRoutes from "./routes/auth";
1422
import jobsRoutes from "./routes/jobs";
1523
import disputesRoutes from "./routes/disputes";
@@ -20,7 +28,6 @@ import uploadsRoutes from "./routes/uploads";
2028
import bulkRoutes from "./routes/bulk";
2129
import poolRoutes from "./routes/pool";
2230
import stateRoutes from "./routes/state";
23-
import { pool } from "./config/db";
2431
import { startStorageCleanup, stopStorageCleanup } from "./utils/storage-cleanup";
2532
import { startNonceCleanup, stopNonceCleanup } from "./utils/nonce-cleanup";
2633

@@ -31,6 +38,16 @@ const port = process.env.PORT || 3001;
3138
const logger = trace.getLogger("server");
3239
const isProduction = process.env.NODE_ENV === "production";
3340
const CSRF_COOKIE_NAME = "lance-csrf-token";
41+
let isShuttingDown = false;
42+
let server: Server | null = null;
43+
let poolMetricsInterval: NodeJS.Timeout | null = null;
44+
45+
function positiveIntEnv(name: string, fallback: number): number {
46+
const parsed = Number.parseInt(process.env[name] || String(fallback), 10);
47+
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
48+
}
49+
50+
const SHUTDOWN_TIMEOUT_MS = positiveIntEnv("SHUTDOWN_TIMEOUT_MS", 10_000);
3451

3552
// Enable CORS for frontend requests with credentials support
3653
const FRONTEND_URL = process.env.FRONTEND_URL || "http://localhost:3000";
@@ -76,6 +93,19 @@ app.get("/api/v1/auth/csrf", (req: Request, res: Response) => {
7693
res.json({ csrfToken });
7794
});
7895

96+
app.use((req: Request, res: Response, next: NextFunction) => {
97+
if (!isShuttingDown) {
98+
return next();
99+
}
100+
101+
logger.warn("Request rejected during graceful shutdown", {
102+
method: req.method,
103+
path: req.path,
104+
});
105+
res.setHeader("Connection", "close");
106+
return res.status(503).json({ error: "Server is shutting down" });
107+
});
108+
79109
app.use(csrfMiddleware);
80110
app.use(tracingMiddleware); // Global request tracing and diagnostics
81111
app.use(intakeRateLimit);
@@ -148,23 +178,34 @@ app.get("/health", async (req: Request, res: Response) => {
148178
}
149179
});
150180

151-
// Graceful shutdown handler
152-
process.on("SIGTERM", async () => {
153-
logger.info("SIGTERM received, shutting down gracefully");
154-
stopStorageCleanup();
155-
stopNonceCleanup();
156-
try {
157-
await prisma.$disconnect();
158-
logger.info("Database connection closed");
159-
process.exit(0);
160-
} catch (error) {
161-
logger.error("Error during shutdown", {
162-
error: error instanceof Error ? error.message : String(error),
163-
});
164-
process.exit(1);
165-
}
181+
const shutdown = createGracefulShutdownHandler({
182+
logger,
183+
timeoutMs: SHUTDOWN_TIMEOUT_MS,
184+
markShuttingDown: () => {
185+
isShuttingDown = true;
186+
},
187+
closeServer: () => closeHttpServer(server),
188+
stopBackgroundTasks: [
189+
() => {
190+
stopStorageCleanup();
191+
stopNonceCleanup();
192+
stopPoolHealthCheck();
193+
if (poolMetricsInterval) {
194+
clearInterval(poolMetricsInterval);
195+
poolMetricsInterval = null;
196+
}
197+
},
198+
],
199+
drainDatabase: drainDatabasePool,
200+
exit: (code) => process.exit(code),
166201
});
167202

203+
for (const signal of ["SIGINT", "SIGTERM"] as NodeJS.Signals[]) {
204+
process.once(signal, () => {
205+
void shutdown(signal);
206+
});
207+
}
208+
168209
// ---------------------------------------------------------------------------
169210
// Start the server — validate the DB connection with retry backoff first,
170211
// then kick off background pool health-checking.
@@ -175,10 +216,10 @@ async function bootstrap(): Promise<void> {
175216
startPoolHealthCheck();
176217
startStorageCleanup();
177218
startNonceCleanup();
178-
app.listen(port, () => {
219+
server = app.listen(port, () => {
179220
console.log(`⚡️[server]: Server is running at http://localhost:${port}`);
180221
// Update pool metrics periodically so the Prometheus scrape has fresh data
181-
setInterval(() => {
222+
poolMetricsInterval = setInterval(() => {
182223
updatePoolMetrics(pool.totalCount, pool.idleCount, pool.waitingCount);
183224
}, 15_000).unref();
184225
});

backend/src/routes/pool-enhanced.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ router.get("/health", async (req: Request, res: Response) => {
6060
const uptime = stats.uptimeSeconds || 0;
6161

6262
// Determine overall health status
63-
const isPrimary Healthy = stats.primaryStatus === "healthy";
63+
const isPrimaryHealthy = stats.primaryStatus === "healthy";
6464
const hasHealthyReplicas = (stats.replicaStatuses || []).some(
6565
(s: string) => s === "healthy"
6666
);
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import type { Server } from "node:http";
2+
3+
export type ShutdownLogger = {
4+
info(message: string, context?: Record<string, unknown>): void;
5+
warn(message: string, context?: Record<string, unknown>): void;
6+
error(message: string, context?: Record<string, unknown>): void;
7+
};
8+
9+
export interface GracefulShutdownOptions {
10+
logger: ShutdownLogger;
11+
timeoutMs: number;
12+
markShuttingDown?: () => void;
13+
closeServer?: () => Promise<void>;
14+
stopBackgroundTasks?: Array<() => void | Promise<void>>;
15+
drainDatabase?: (signal: NodeJS.Signals) => Promise<void>;
16+
exit?: (code: number) => void;
17+
}
18+
19+
function describeError(error: unknown): string {
20+
return error instanceof Error ? error.message : String(error);
21+
}
22+
23+
async function withTimeout<T>(
24+
work: Promise<T>,
25+
timeoutMs: number,
26+
signal: NodeJS.Signals
27+
): Promise<T> {
28+
let timeout: NodeJS.Timeout | undefined;
29+
const timeoutPromise = new Promise<never>((_, reject) => {
30+
timeout = setTimeout(() => {
31+
reject(new Error(`Graceful shutdown timed out after ${timeoutMs}ms for ${signal}`));
32+
}, timeoutMs);
33+
timeout.unref();
34+
});
35+
36+
try {
37+
return await Promise.race([work, timeoutPromise]);
38+
} finally {
39+
if (timeout) {
40+
clearTimeout(timeout);
41+
}
42+
}
43+
}
44+
45+
export function closeHttpServer(server: Server | null | undefined): Promise<void> {
46+
if (!server) {
47+
return Promise.resolve();
48+
}
49+
50+
const serverWithIdleClose = server as Server & {
51+
closeIdleConnections?: () => void;
52+
};
53+
54+
return new Promise((resolve, reject) => {
55+
server.close((error?: Error) => {
56+
if (error) {
57+
reject(error);
58+
return;
59+
}
60+
resolve();
61+
});
62+
63+
serverWithIdleClose.closeIdleConnections?.();
64+
});
65+
}
66+
67+
export function createGracefulShutdownHandler(options: GracefulShutdownOptions) {
68+
let shuttingDown = false;
69+
70+
return async function gracefulShutdown(signal: NodeJS.Signals): Promise<void> {
71+
if (shuttingDown) {
72+
options.logger.warn("Shutdown already in progress; ignoring duplicate signal", { signal });
73+
return;
74+
}
75+
76+
shuttingDown = true;
77+
options.markShuttingDown?.();
78+
options.logger.info("Shutdown signal received; draining API resources", {
79+
signal,
80+
timeoutMs: options.timeoutMs,
81+
});
82+
83+
try {
84+
await withTimeout(
85+
(async () => {
86+
await options.closeServer?.();
87+
88+
for (const stopTask of options.stopBackgroundTasks ?? []) {
89+
await stopTask();
90+
}
91+
92+
await options.drainDatabase?.(signal);
93+
})(),
94+
options.timeoutMs,
95+
signal
96+
);
97+
98+
options.logger.info("Graceful shutdown completed", { signal });
99+
options.exit?.(0);
100+
} catch (error) {
101+
options.logger.error("Graceful shutdown failed", {
102+
signal,
103+
error: describeError(error),
104+
});
105+
options.exit?.(1);
106+
}
107+
};
108+
}

0 commit comments

Comments
 (0)