Graceful Shutdown
The problem
Section titled “The problem”When your process receives SIGTERM (deploy, restart, scale-down), you need to:
- Stop claiming new jobs
- Let the current handler finish
- Report the result back to Chronos
- Then exit
If you kill the process mid-handler, the execution’s lease expires on the server and Chronos schedules a retry, wasting time and potentially causing side effects if the handler wasn’t idempotent.
Why the SDK doesn’t handle signals
Section titled “Why the SDK doesn’t handle signals”The SDK doesn’t install SIGTERM/SIGINT handlers because your application owns its process lifecycle. You might:
- Run multiple services in one process
- Need cleanup beyond the Chronos worker (close DB connections, flush logs)
- Use a framework that manages shutdown itself
Signal handling is your responsibility.
Basic shutdown
Section titled “Basic shutdown”import { Chronos } from '@chronos.sh/sdk';
const chronos = new Chronos({ apiKey: process.env.CHRONOS_API_KEY!,});
chronos.worker .handle('sync-tenant', syncTenantHandler) .handle('send-report', sendReportHandler);
const shutdown = async () => { await chronos.worker.stop(); process.exit(0);};
process.on('SIGTERM', shutdown);process.on('SIGINT', shutdown);
await chronos.worker.start();What stop() does
Section titled “What stop() does”- Aborts the long-poll immediately: the current HTTP request to
/v1/worker/jobs/claimis cancelled, so no new job is claimed - Waits for in-flight work: if a handler is mid-execution,
stop()waits until it finishes and reports its result - Resolves: the promise returned by
start()resolves, your shutdown handler continues
stop() never throws. If no work is in-flight, it resolves immediately.
Second-signal escape hatch
Section titled “Second-signal escape hatch”If a handler is stuck (infinite loop, deadlock), the first SIGTERM will wait forever. Handle a second signal as a force-exit:
let stopping = false;
const shutdown = async () => { if (stopping) { process.exit(1); } stopping = true;
await chronos.worker.stop(); process.exit(0);};
process.on('SIGTERM', shutdown);process.on('SIGINT', shutdown);First signal: graceful. Second signal: immediate exit. The abandoned execution will time out on the server and be retried.
Worst-case shutdown time
Section titled “Worst-case shutdown time”The longest a graceful shutdown can take:
handler runtime + result report retriesResult reporting retries up to 3 times with retryDelayMs (default 1000ms) between attempts. Worst case:
handler runtime + (3 × 1000ms) ≈ handler runtime + 3 secondsSet your process manager’s grace period to exceed your longest expected handler runtime plus a few seconds.
With other cleanup
Section titled “With other cleanup”If your process has more to clean up beyond the worker:
const shutdown = async () => { // Stop accepting new work await chronos.worker.stop();
// Clean up other resources await db.disconnect(); await redis.quit(); await logger.flush();
process.exit(0);};Docker / container deployments
Section titled “Docker / container deployments”Containers receive SIGTERM on shutdown. Ensure your STOPSIGNAL is SIGTERM (the default) and your grace period covers your longest handler:
# Default: STOPSIGNAL SIGTERM (no change needed)STOPSIGNAL SIGTERMFor Docker Compose or Swarm, set the stop grace period:
services: worker: stop_grace_period: 60s # Match to your longest handler + bufferFor other orchestrators (ECS, systemd, Caprover), configure the equivalent grace period to exceed your longest expected handler runtime.
Testing shutdown
Section titled “Testing shutdown”Verify graceful shutdown works by sending SIGTERM while a handler is running:
# Terminal 1: start your workerCHRONOS_API_KEY=chrns_... npx tsx worker.ts
# Terminal 2: send SIGTERMkill -TERM $(pgrep -f worker.ts)Watch for the handler to complete and the process to exit cleanly. If it exits immediately (code 137/143 without completing), your signal wiring isn’t working.