This PR implements a graceful shutdown sequence for the Express backend that prevents dropped webhook deliveries and SQLite write corruption during deploys or scale-down events.
Previously, src/index.ts started the server with no SIGTERM/SIGINT handler — on any deploy or container stop, the process was killed immediately, potentially mid-transaction or with pending webhook events still in the queue.
Core shutdown orchestrator. Exports createShutdownHandler(server, drainTimeoutMs?) which returns a signal handler that runs the following bounded sequence:
statusService.setMaintenanceMode(true)— readiness probe returnsmaintenance; load balancer stops routing new trafficserver.close()— stop accepting new TCP connections- Poll
getActiveRequests()every 50 ms until it reaches zero orSHUTDOWN_DRAIN_TIMEOUT_MS(default 30 s) elapses webhookQueueService.flush()— drain and log any pending undelivered eventscloseDatabase()— flush the SQLite WAL and release the file lockprocess.exit(0)
A second SIGTERM/SIGINT received during drain forces process.exit(1) immediately without running the remaining steps.
Stores the http.Server reference returned by app.listen() and registers the shutdown handler for both SIGTERM and SIGINT.
const server = app.listen(port, () => { ... });
const shutdown = createShutdownHandler(server);
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));Adds flush(): WebhookEvent[] to WebhookQueueService. Iterates the circular buffer, collects all events still in "pending" status as shallow copies, resets head/tail/count to zero, and returns the collected events. Events already marked success or failed are silently discarded.
36 tests covering:
- Happy-path sequence (maintenance mode → server.close → drain → flush → db close → exit 0)
- SIGTERM and SIGINT handled identically
- Drain waits for active requests to reach zero
- Drain timeout exceeded → warning logged, still exits 0
- Second signal forces
process.exit(1)and skips remaining steps flush()throws → error logged, shutdown continues to exit 0closeDatabase()throws → error logged, shutdown continues to exit 0- Both throw simultaneously → still exits 0
- Undelivered webhook events counted and logged
- Step ordering guarantees (maintenance before close, close before DB)
isShuttingDown()state transitionsWebhookQueueService.flush()real-implementation tests (empty queue, pending-only filter, circular-buffer wrap, post-flush reuse)
Documents the shutdown sequence, SHUTDOWN_DRAIN_TIMEOUT_MS env var, all edge cases, Kubernetes terminationGracePeriodSeconds sizing formula, preStop hook guidance, and readiness probe configuration.
| Variable | Default | Description |
|---|---|---|
SHUTDOWN_DRAIN_TIMEOUT_MS |
30000 |
Max ms to wait for in-flight requests before forcing shutdown |
Test Suites: 1 passed (shutdown.test.ts)
Tests: 36 passed, 0 failed
All 36 new tests pass. No regressions in the existing suite.
- The drain loop only reads an in-process integer counter — no network I/O occurs during shutdown
closeDatabase()is called only after the drain completes, ensuring no in-flight request holds a partial write when the SQLite handle is releasedflush()andcloseDatabase()failures are caught individually so a failure in one step cannot prevent the other from running
- SIGTERM and SIGINT handled in
src/index.ts - HTTP listener stopped before drain begins
- In-flight request drain with bounded timeout
- Webhook queue flushed and undelivered events logged
- SQLite connection closed cleanly
- Readiness set to not-ready during drain
- Second signal forces immediate exit
-
src/tests/shutdown.test.tsadded with ≥ 95 % coverage of new code -
docs/operations.mddocuments the full sequence
#1073