This document describes the graceful shutdown mechanism implemented in the Callora Backend service.
The graceful shutdown handler ensures that the application terminates cleanly when receiving termination signals (SIGTERM/SIGINT), preventing data loss and ensuring all in-flight operations complete successfully before exit. On SIGTERM, the handler starts subsystem draining immediately while the HTTP server is also being closed, so the process can move toward a clean exit without waiting on the server close callback before the drain phase begins.
- Signal Handling: Responds to SIGTERM and SIGINT signals
- Request Draining: Waits up to 30 seconds for in-flight HTTP requests to complete
- Subsystem Coordination: Stops and drains background jobs, webhook dispatchers, and other subsystems
- Database Cleanup: Closes all database connection pools gracefully
- Structured Logging: Logs each phase of the shutdown process with correlation IDs
- Timeout Protection: Forcefully closes lingering connections after the grace period
- Idempotency: Duplicate signals are ignored if shutdown is already in progress
The main orchestrator that coordinates the shutdown sequence.
Location: src/lifecycle/shutdown.ts
Interface:
function createGracefulShutdownHandler(options: {
server: Server;
activeConnections: Set<Socket>;
closeDatabase: () => Promise<void>;
logger?: Logger;
timeoutMs?: number;
subsystems?: DrainableSubsystem[];
}): (signal: NodeJS.Signals) => Promise<number>;Interface for background subsystems that need to be gracefully stopped.
interface DrainableSubsystem {
name: string;
beginShutdown: () => void | Promise<void>;
awaitIdle: () => Promise<void>;
}Built-in Subsystems:
gateway-proxy: Tracks in-flight HTTP requests through the API gatewayrevenue-ledger-indexer: Background job for indexing revenue eventsidempotency-sweeper: Background job for cleaning up expired idempotency recordswebhook-dispatcher: Asynchronous webhook delivery system
Middleware-based tracker for monitoring active HTTP requests.
function createInFlightDrainTracker(name: string): {
middleware: RequestHandler;
subsystem: DrainableSubsystem;
/** Returns true once beginShutdown() has been called. */
isDraining: () => boolean;
};The isDraining() flag can be passed to the proxy router factory via ProxyDeps.drainState
so that new requests arriving after shutdown begins are immediately rejected with
503 Service Unavailable (with Connection: close and Retry-After: 0), while
requests that were already in flight when the shutdown signal arrived are allowed
to complete normally. See the Proxy drain guard section below for details.
The shutdown process follows these phases:
- Log the received signal (SIGTERM or SIGINT)
- Start the grace period timer (default: 30 seconds)
- Call
beginShutdown()on all registered subsystems - Subsystems stop accepting new work but continue processing in-flight operations
- Log each subsystem as it stops
- Close the HTTP server to stop accepting new connections
- Existing connections remain open for in-flight requests
- Wait for all subsystems to complete in-flight work via
awaitIdle() - Race against the timeout period
- Log each subsystem as it becomes idle
- If the grace period expires, forcefully destroy all remaining socket connections
- Log warning with connection count
- Close all database connection pools:
- Drizzle ORM connections
- PostgreSQL connection pool
- Prisma client
- Health check pools
- Wait for all connections to drain
- Exit with code 0 for clean shutdown
- Exit with code 1 if any errors occurred
No specific environment variables are required. The shutdown handler is configured programmatically.
const DEFAULT_TIMEOUT_MS = 30_000; // 30 secondsimport { createGracefulShutdownHandler } from './lifecycle/shutdown.js';
const server = app.listen(PORT);
const activeConnections = new Set<Socket>();
server.on('connection', (socket) => {
activeConnections.add(socket);
socket.once('close', () => activeConnections.delete(socket));
});
const shutdown = createGracefulShutdownHandler({
server,
activeConnections,
closeDatabase: async () => {
await pool.end();
await prisma.$disconnect();
},
timeoutMs: 30_000,
});
process.once('SIGTERM', () => shutdown('SIGTERM').then(process.exit));
process.once('SIGINT', () => shutdown('SIGINT').then(process.exit));To register a custom drainable subsystem:
const mySubsystem: DrainableSubsystem = {
name: 'my-background-job',
beginShutdown() {
// Stop accepting new work
this.accepting = false;
},
async awaitIdle() {
// Wait for in-flight work to complete
while (this.activeJobs > 0) {
await this.waitForJob();
}
},
};
const shutdown = createGracefulShutdownHandler({
// ... other options
subsystems: [mySubsystem],
});To track in-flight HTTP requests:
import { createInFlightDrainTracker } from './lifecycle/shutdown.js';
const tracker = createInFlightDrainTracker('api-routes');
// Apply middleware
app.use('/api', tracker.middleware);
// Register subsystem
const shutdown = createGracefulShutdownHandler({
// ... other options
subsystems: [tracker.subsystem],
});The /v1/call proxy router supports an optional drainState dependency that
enables active request rejection during the shutdown drain window:
import { createInFlightDrainTracker } from './lifecycle/shutdown.js';
import { createProxyRouter } from './routes/proxyRoutes.js';
// Create the tracker first so we can pass isDraining to the router
const proxyDrainTracker = createInFlightDrainTracker('gateway-proxy');
const proxyRouter = createProxyRouter({
// ... other deps
drainState: { isDraining: proxyDrainTracker.isDraining },
});
// Mount the drain tracker middleware BEFORE the proxy router
// so that each request entering /v1/call is counted by the tracker
app.use('/v1/call', proxyDrainTracker.middleware);
app.use('/v1/call', proxyRouter);Behaviour during drain:
| Request timing | What happens |
|---|---|
Arrived before beginShutdown() |
Allowed to complete normally; counted by the tracker |
Arrived after beginShutdown() |
Immediately rejected with 503 Service Unavailable |
The 503 response includes:
Connection: close— instructs the load balancer not to reuse the socket.Retry-After: 0— advises the client to retry immediately on a healthy instance.- JSON body:
{ "code": "SERVICE_UNAVAILABLE", "message": "..." }
The drainState hook is optional; omitting it reverts to the original behaviour
(requests proceed even during shutdown).
The isDraining() accessor is exposed on the return value of
createInFlightDrainTracker so it can be injected into any component that
needs to know whether shutdown is in progress:
const tracker = createInFlightDrainTracker('my-subsystem');
tracker.isDraining(); // false — before beginShutdown()
tracker.subsystem.beginShutdown();
tracker.isDraining(); // true — from now onThe shutdown handler emits structured log messages for each phase:
[shutdown:signal_received] Received SIGTERM, initiating graceful shutdown
[shutdown:subsystems_stopping] Stopping 4 subsystem(s): gateway-proxy, revenue-ledger-indexer, idempotency-sweeper, webhook-dispatcher
[shutdown:subsystems_stopping] Stopped subsystem: gateway-proxy
[shutdown:server_closing] Closing HTTP server
[shutdown:subsystems_draining] Draining 4 subsystem(s) (timeout: 30000ms)
[shutdown:subsystems_draining] Drained subsystem: gateway-proxy
[shutdown:database_closing] Closing database pools
[shutdown:database_closing] Database pools closed successfully
[shutdown:complete] Shutdown complete (exit_code: 0, duration: 1247ms)
Subsystem Stop Failure:
[shutdown:error] Failed to stop subsystem webhook-dispatcher: Connection timeout
Drain Timeout:
[shutdown:timeout_reached] Subsystem drain timeout after 30000ms
[shutdown:timeout_reached] Graceful drain exceeded 30000ms, forcefully closing 2 connection(s)
Database Close Error:
[shutdown:error] Error closing database: Connection pool already closed
Location: src/lifecycle/shutdown.test.ts
Run tests:
npm test -- shutdown.test.tsThe test suite covers:
- ✅ Clean shutdown with SIGTERM
- ✅ Clean shutdown with SIGINT
- ✅ Subsystem stopping and draining
- ✅ Timeout with forceful connection closure
- ✅ Server close errors
- ✅ Database close errors
- ✅ Duplicate signal handling
- ✅ Subsystem drain timeout
- ✅ Request tracking middleware
- ✅ Multiple concurrent requests
- ✅ Structured logging output
- ✅
isDraining()flag — false before shutdown, true after - ✅ Proxy drain guard — 503 on new requests during shutdown
- ✅ Proxy drain guard —
Connection: close+Retry-After: 0headers - ✅ Proxy drain guard — upstream NOT called for rejected requests
- ✅ Proxy drain guard — usage NOT recorded for rejected requests
- ✅ Shutdown handler waits for in-flight proxy requests before closing DB
- ✅
isDraining()flag — false before shutdown, true after - ✅ Proxy drain guard — 503 on new requests during shutdown
- ✅ Proxy drain guard —
Connection: close+Retry-After: 0headers - ✅ Proxy drain guard — upstream NOT called for rejected requests
- ✅ Proxy drain guard — usage NOT recorded for rejected requests
- ✅ Shutdown handler waits for in-flight proxy requests before closing DB
To test in a running environment:
# Start the server
npm start
# In another terminal, send SIGTERM
kill -TERM <pid>
# Or use Ctrl+C to send SIGINTVerify logs show:
- Signal received
- Subsystems stopping
- Server closing
- Database cleanup
- Exit code 0
For Kubernetes deployments, ensure:
-
Termination Grace Period is at least 35 seconds (5s buffer beyond the 30s drain timeout):
spec: terminationGracePeriodSeconds: 35
-
Readiness Probe fails quickly on shutdown to stop routing new traffic:
readinessProbe: httpGet: path: /api/health port: 3000 periodSeconds: 5
When running with Docker, ensure proper signal forwarding:
# Use exec form to ensure signals reach the Node process
CMD ["node", "dist/index.js"]The /api/health endpoint continues responding during shutdown until the HTTP server closes. External health checkers should mark the pod as unhealthy once the endpoint becomes unreachable.
Cause: In-flight requests or subsystems are not completing.
Solution:
- Check logs for which subsystems are slow to drain
- Verify database query performance
- Ensure background jobs are properly cancellable
Cause: Requests exceeded the 30-second grace period.
Solution:
- Investigate slow endpoints or queries
- Consider increasing
timeoutMsif legitimate long-running operations exist - Add request timeouts at the application level
Cause: Error occurred during shutdown phases.
Solution:
- Review error logs for specific failures
- Check database connection health
- Verify subsystem shutdown logic
Cause: Attempting to close database pools multiple times.
Solution:
- Ensure
closePgPool()guards against duplicate calls - Check for race conditions in shutdown logic
- Graceful Degradation: The shutdown handler ensures no data is lost during termination
- Timeout Protection: Prevents indefinite hangs from misbehaving subsystems
- Connection Closure: Forces closure of lingering connections to prevent resource leaks
- Audit Logging: All shutdown phases are logged for security auditing
Potential improvements:
- Configurable per-subsystem timeouts
- Prometheus metrics for shutdown duration
- Webhooks to notify external systems on shutdown
- Support for custom exit codes per error type
- Graceful reload without full shutdown (SIGHUP)