Graceful Shutdown Runbook
Owner: backend infra Last updated: 2026-05-27 (#808) Related: ADR-018 (multi-pod safety contract), MVP P0 NFR FINAL EN Β§Graceful Shutdown Contract
When this mattersβ
- ECS rolling deploys (every PR merge to main β staging β prod)
- Auto-scaling scale-in events (off-peak hours)
- Manual
aws ecs stop-taskinvocations Ctrl+Cduring local dev (same code path so dev behaviour matches prod)
Without an explicit drain routine, NodeJS receives SIGTERM and exits without firing NestJS shutdown hooks. In-flight BullMQ jobs are abandoned, their HTTP-side caller never gets a response, and the task-termination window (default 30s for ECS) is wasted on a process that already left.
Shutdown sequence (by design)β
ECS sends SIGTERM
β
βΌ
process.on('SIGTERM') handler in api/src/main.ts
β
βΌ
app.close() β NestJS Lifecycle
β
βββ 1. stop accepting new HTTP connections (http.Server.close)
β
βββ 2. drain in-flight HTTP handlers (default 30s Nest grace)
β
βββ 3. onModuleDestroy on every module (parallel where independent):
β β’ channels-inbound BullMQ worker β await current 'email-route' /
β 'whatsapp-route' job, then close ioredis connection
β β’ telegram-inbound BullMQ worker β same for 'route' jobs
β β’ scheduler BullMQ worker β current cron tick completes
β β’ background-tasks BullMQ worker β current job completes
β β’ correction-refinement BullMQ worker β current correction completes
β β’ Socket.io Redis adapter β closes pub/sub connections
β β’ Throttler Redis storage β closes connection
β β’ Sentry SDK β flushes pending events (5s timeout)
β
βββ 4. TypeOrmCoreModule β closes PG connection pool last
β
βΌ
process.exit(0) β within the ECS stopTimeout window
ECS sends SIGKILL after stopTimeout (default 30s β must be β₯120s in our task definition, see below).
Required ECS configurationβ
{
"stopTimeout": 120,
"containerDefinitions": [...]
}
Why 120s:
- Max LLM call timeout = 60s
- BullMQ job processing a held conversation can include 1Γ LLM call
- Worker close awaits current job β up to 60s
-
- 30s HTTP grace + 30s safety margin = 120s
A value below 60s risks SIGKILL hitting mid-LLM-call and leaving Postgres messages row half-written (we have transactional protection at the DB layer, so no data corruption β but the HTTP caller times out and retries, doubling load).
Where to set: Terraform module ecs/task-definitions/humanwork-api.tf (owned by @ethumanity ECS PR).
What we explicitly do NOT doβ
- β Forceful job cancellation. BullMQ has
Worker.close(force: true)β never use it. In-flight jobs run to completion or fail and retry; never kill mid-execution. - β Custom timeout-then-kill. If
app.close()hangs longer than ECSstopTimeout, ECS SIGKILLs us automatically. Adding our own kill on top creates double-kill races. - β Sentry flush after process.exit. The Sentry handler is wired via
app.getHttpAdapter().getHttpServer().on('close')so it fires as part of step 1 (HTTP server close), not after exit.
Verificationβ
Local smoke testβ
# Start the API
cd api && npm run start:dev
# In another terminal:
kill -TERM $(pgrep -f "node.*main.js")
# Expected log order:
# [Shutdown] SIGTERM received, draining...
# [BullMQModule] Stopping queue ...
# [TypeOrmModule] Connection closed
# [Shutdown] drain complete, exiting
Total elapsed: typically <2s on dev (no real jobs in flight). Prod with in-flight LLM jobs: 30β90s depending on what's running.
Integration testβ
api/test/graceful-shutdown.spec.ts (#808) spawns a NestJS process, enqueues a slow BullMQ job, sends SIGTERM, asserts:
- exit code = 0
- elapsed time < 30s
- BullMQ job marked
completed(not abandoned) - HTTP server returned 503 to a request sent during drain
Production deploy checkβ
After any ECS rolling deploy, in CloudWatch query Sentry-task-stop events:
ECS service event: "deployment completed"
- Tasks stopped: N
- Tasks stopped via SIGKILL: 0 β MUST be 0
- Tasks stopped within stopTimeout: N β MUST equal "Tasks stopped"
A single SIGKILL means a task didn't drain within 120s β investigate which queue was holding it.
Code referencesβ
| File | Purpose |
|---|---|
api/src/main.ts (around line 211) | process.on('SIGTERM' | 'SIGINT') handler |
api/src/main.ts:37 | app.enableShutdownHooks() β wires NestJS lifecycle |
api/src/channels/channels-inbound.processor.ts | WorkerHost β auto-closes via app.close() |
api/src/channels/telegram/telegram-inbound.processor.ts | same |
api/src/scheduler/scheduler.processor.ts | same |
api/src/common/background-tasks.processor.ts | same |
docs/decisions/018-multi-pod-safety-contract.md AA-8 | hard requirement |