Process Management: Daemons, systemd, and Graceful Shutdown
Learning Objectives
Explain what a daemon is and why modern services run in the foreground under a supervisor rather than daemonizing themselves.
Write a systemd unit file that starts, restarts on crash with backoff, runs as a dedicated user, and streams logs to the journal.
Implement graceful shutdown in an asyncio application that catches SIGTERM, stops accepting new work, drains in-flight tasks within a timeout, and exits cleanly.
Design a worker that survives crashes and forced kills without losing or double-processing queued messages, using at-least-once delivery plus idempotency.
Pre-Quiz: Daemons and Services
Why does modern Linux favor running a Python service in the foreground under a supervisor instead of having the program daemonize itself with the classic double-fork ritual?
Foreground processes run faster because they are not detached from the terminal.
A foreground process plus a supervisor is simpler and far more observable — if the process dies, the supervisor notices and can restart it.
Double-forking is forbidden on modern kernels and will cause the process to be killed immediately.
Foreground processes automatically write their own PID files, which supervisors require.
A team writes their gateway to run in the foreground, log to stdout/stderr, exit non-zero on unrecoverable errors, and respond to SIGTERM. What is the practical benefit of adhering to this "common contract"?
It guarantees the service can only ever run under systemd, which is the most reliable supervisor.
It lets the same application run unchanged under systemd on a bare host or under Docker/Kubernetes, because every supervisor speaks that same contract.
It removes the need for any supervisor at all, since the app now self-heals.
It forces logs into rotated files on disk, which is required for compliance.
Following the Twelve-Factor "logs as event streams" principle, how should a Python gateway handle its logging?
Open a dedicated log file, rotate it nightly, and manage retention within the application.
Write each event as a line to stdout/stderr and let the execution environment capture, route, and store the stream.
Send every log line directly to a remote aggregation server over the network from within the app.
Suppress logging entirely in production to reduce disk I/O.
Daemons and Services
Key Points
A daemon is a long-running background process with no controlling terminal that provides a service rather than interacting with a user.
On modern Linux you do not self-daemonize; you run in the foreground and let a supervisor own the lifecycle (start, restart, log, resource-limit, order).
Supervision is portable: systemd on a host, Docker restart policies in containers, the kubelet in Kubernetes — same responsibilities, different vocabulary.
Write the app supervisor-agnostic: foreground, log to stdout/stderr, exit non-zero on failure, respond to SIGTERM.
Treat logs as an event stream to stdout/stderr (Twelve-Factor); systemd captures it into the journal automatically.
A multi-channel AI gateway is not a script you run once and walk away from — it is a service that must be running at 3 a.m., come back automatically after a reboot, and not garble a user's request during a redeploy. The central mindset shift is that your application should not manage its own lifecycle. It runs in the foreground, logs to standard output, responds to a couple of signals, and lets a dedicated supervisor own everything else.
Historically a program made itself a daemon through an elaborate ritual: fork, have the parent exit, call setsid() to detach from the terminal, fork again, close the standard file descriptors, and write a PID file. This double-forking dance is fiddly, error-prone, and hard to observe — if the process died, nothing noticed. The modern approach is the opposite: the app runs in the foreground and a supervisor starts it, restarts it on crash, captures its logs, enforces limits, and orders it relative to other units. You write no "go into the background" logic at all.
The dominant supervisor is systemd, which runs as PID 1 and manages the machine's services. But the concept is portable. In a container, the runtime plays supervisor: Docker restart policies (--restart on-failure, unless-stopped, always) restart a crashed container much as Restart=on-failure restarts a crashed service. In Kubernetes, the kubelet restarts crashed containers per the pod's restartPolicy and uses liveness probes to detect hangs.
Supervisor
Environment
Restart mechanism
systemd
Bare Linux host
Restart=on-failure
Docker
Single-container
--restart on-failure / unless-stopped / always
Kubernetes (kubelet)
Cluster
Pod restartPolicy + liveness probes
One of the strongest habits is treating logs as event streams to stdout. A well-behaved service does not open its own log files or rotate them; it writes each event as a line to stdout/stderr and lets the environment capture it. Under systemd this is automatic — anything on stdout/stderr is captured by the journal, timestamped, and tagged with the unit name. A minimal setup is just:
import logging, sys
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s %(levelname)s %(name)s %(message)s',
stream=sys.stdout, # let the supervisor own the stream
)
The payoff is uniformity: the same log line is captured identically under systemd, Docker, or Kubernetes, and each environment forwards the stream wherever it likes without the application knowing.
Visual animation — coming soon
Post-Quiz: Daemons and Services
Why does modern Linux favor running a Python service in the foreground under a supervisor instead of having the program daemonize itself with the classic double-fork ritual?
Foreground processes run faster because they are not detached from the terminal.
A foreground process plus a supervisor is simpler and far more observable — if the process dies, the supervisor notices and can restart it.
Double-forking is forbidden on modern kernels and will cause the process to be killed immediately.
Foreground processes automatically write their own PID files, which supervisors require.
A team writes their gateway to run in the foreground, log to stdout/stderr, exit non-zero on unrecoverable errors, and respond to SIGTERM. What is the practical benefit of adhering to this "common contract"?
It guarantees the service can only ever run under systemd, which is the most reliable supervisor.
It lets the same application run unchanged under systemd on a bare host or under Docker/Kubernetes, because every supervisor speaks that same contract.
It removes the need for any supervisor at all, since the app now self-heals.
It forces logs into rotated files on disk, which is required for compliance.
Following the Twelve-Factor "logs as event streams" principle, how should a Python gateway handle its logging?
Open a dedicated log file, rotate it nightly, and manage retention within the application.
Write each event as a line to stdout/stderr and let the execution environment capture, route, and store the stream.
Send every log line directly to a remote aggregation server over the network from within the app.
Suppress logging entirely in production to reduce disk I/O.
Pre-Quiz: systemd Units in Practice
A Python service works when launched from an activated virtualenv in a login shell but fails under systemd with a "module not found" error. What is the most likely cause?
systemd requires services to be written in C, not Python.
ExecStart relies on the shell's PATH or an activated venv, but systemd inherits neither — it needs an absolute path to the venv interpreter.
The service must fork itself into the background for systemd to find its modules.
systemd only supports python2, so the venv's Python 3 fails.
A service has a bad config file and crashes instantly on every start. With Restart=on-failure and RestartSec=5, what additional mechanism stops it from crash-looping forever?
Nothing — RestartSec=5 alone eventually gives up after enough attempts.
StartLimitIntervalSec / StartLimitBurst: if it restarts more than the burst count within the interval, systemd puts the unit in failed and stops trying.
Restart=on-failure never restarts a service that exits non-zero, so there is no loop.
The kernel's OOM killer terminates the crash loop automatically.
You edited the unit file, then ran systemctl start gateway-worker. After a reboot the service is not running. What did you most likely forget?
systemctl enable — start runs it now but does not wire it into a boot target, so it does not come up on reboot.
journalctl -u gateway-worker, which is required to persist a service across boots.
Restart=always, without which no service survives a reboot.
Nothing — start already makes the service persistent, so the reboot itself is the bug.
systemd Units in Practice
Key Points
A unit is a plain-text .ini file (typically /etc/systemd/system/<name>.service) with [Unit], [Service], and [Install] sections.
The cardinal Python rule: ExecStart must use an absolute path to the venv interpreter and run in the foreground — systemd inherits neither your PATH nor an activated venv.
Restart=on-failure with a non-trivial RestartSec (plus optional RestartSteps/RestartMaxDelaySec backoff) self-heals from crashes.
StartLimitIntervalSec/StartLimitBurst stop an unrecoverable failure from crash-looping — the unit lands in failed; systemctl reset-failed clears it.
Logs flow to the journal; read with journalctl -u <name>. Use enable --now — start alone does not survive a reboot.
A systemd unit is a plain-text, .ini-style file with three sections: [Unit] (metadata and ordering), [Service] (how to run the process), and [Install] (how it hooks into boot). Here is a production-shaped unit for a gateway worker:
Several fields deserve attention. ExecStart= must use an absolute path to the interpreter — ideally the virtualenv's Python — because systemd does not inherit your login shell's PATH and does not activate virtualenvs; the process must run in the foreground (no fork, no &) because systemd tracks the PID it spawned. User= runs the service as an unprivileged dedicated account, limiting blast radius. Environment= sets inline variables; EnvironmentFile= loads KEY=value pairs from a file — the natural home for secrets. After=/Wants=network-online.target order the unit after the network is up.
The Type= field tells systemd how to decide the service has started. Type=simple (default) considers it started the instant the process is forked; Type=exec waits for execve(); the most robust is Type=notify, where the app calls sd_notify with READY=1 once it has bound sockets and opened connections. Avoid Type=forking, which exists for legacy double-forking daemons.
The Restart= directive is the heart of self-healing. The recommended value for a long-running service is Restart=on-failure: it restarts on a non-zero exit, a fatal signal, a timeout, or a watchdog expiry — but not after a clean exit you requested, so it never fights systemctl stop.
Value
Restarts after…
Typical use
no (default)
never
one-shot tasks
on-success
clean exit only
processes meant to loop after finishing
on-failure
non-zero exit, fatal signal, timeout, or watchdog
recommended for long-running services
on-abnormal
fatal signal, timeout, or watchdog only
a deliberate error exit should stay down
on-watchdog
watchdog timeout only
pairs with WatchdogSec=
always
any exit, clean or not
daemons that must never stay stopped
RestartSec= sets the delay before restarting (default is a mere 100 ms); a value like 5 prevents a tight crash-loop from pegging the CPU. For exponential backoff, add RestartSteps= and RestartMaxDelaySec=. But backoff alone will not stop an unrecoverable failure from looping forever — that is the job of start-rate limiting: StartLimitIntervalSec=60 and StartLimitBurst=5 mean "if the service is (re)started more than 5 times within any 60-second window, give up." The unit enters failed; run systemctl reset-failed <name> once fixed.
Figure 12.2: systemd service state lifecycle under Restart=on-failure.
stateDiagram-v2
[*] --> activating: systemctl start
activating --> active: process running
active --> deactivating: systemctl stop / SIGTERM
deactivating --> inactive: clean exit
inactive --> [*]
active --> failed: crash / non-zero exit / watchdog
failed --> activating: Restart=on-failure (after RestartSec)
failed --> [*]: StartLimitBurst exceeded (give up)
note right of failed
systemctl reset-failed
clears a rate-limited unit
end note
Because the service logs to stdout/stderr, systemd routes everything into the journal, read with journalctl:
journalctl -u gateway-worker -f # follow live, like tail -f
journalctl -u gateway-worker --since "10 min ago" # recent window
journalctl -u gateway-worker -p err # only error-priority lines
journalctl -u gateway-worker -b # only since last boot
Managing the unit follows a small set of commands. The critical pairing is enable versus start: start runs it now but is not persistent; enable wires it into a boot target so it comes up on reboot. enable --now does both — almost always what you want.
systemctl daemon-reload # re-read unit files after editing
systemctl enable --now gateway-worker # start now AND start on every boot
systemctl status gateway-worker # state + a few recent log lines
systemctl restart gateway-worker # stop then start
Visual animation — coming soon
Post-Quiz: systemd Units in Practice
A Python service works when launched from an activated virtualenv in a login shell but fails under systemd with a "module not found" error. What is the most likely cause?
systemd requires services to be written in C, not Python.
ExecStart relies on the shell's PATH or an activated venv, but systemd inherits neither — it needs an absolute path to the venv interpreter.
The service must fork itself into the background for systemd to find its modules.
systemd only supports python2, so the venv's Python 3 fails.
A service has a bad config file and crashes instantly on every start. With Restart=on-failure and RestartSec=5, what additional mechanism stops it from crash-looping forever?
Nothing — RestartSec=5 alone eventually gives up after enough attempts.
StartLimitIntervalSec / StartLimitBurst: if it restarts more than the burst count within the interval, systemd puts the unit in failed and stops trying.
Restart=on-failure never restarts a service that exits non-zero, so there is no loop.
The kernel's OOM killer terminates the crash loop automatically.
You edited the unit file, then ran systemctl start gateway-worker. After a reboot the service is not running. What did you most likely forget?
systemctl enable — start runs it now but does not wire it into a boot target, so it does not come up on reboot.
journalctl -u gateway-worker, which is required to persist a service across boots.
Restart=always, without which no service survives a reboot.
Nothing — start already makes the service persistent, so the reboot itself is the bug.
Pre-Quiz: Graceful Shutdown
In an asyncio application, why should the SIGTERM handler registered with loop.add_signal_handler do nothing but set an asyncio.Event?
Because signal handlers are limited to a single line of code by the Python interpreter.
Because cleanup (closing connections, acking messages) belongs in normal coroutine code that observes the event, where async I/O is safe — not in the fragile signal-handling context.
Because setting an event is the only operation SIGTERM handlers are permitted to perform.
Because the event automatically forwards SIGTERM to child processes.
During a drain, the worker awaits in-flight tasks with a bounded timeout, then force-cancels any stragglers. Why cancel rather than simply wait indefinitely for them to finish?
Cancelling is faster and always completes the work correctly.
A single stuck task could otherwise block the whole shutdown past TimeoutStopSec, after which systemd sends the uncatchable SIGKILL and cuts off all cleanup.
asyncio forbids awaiting more than one task at a time during shutdown.
Cancelled tasks are automatically re-run from the start on the next process launch.
The unit sets TimeoutStopSec=30 and the worker uses GRACE_PERIOD = 25. A legitimate batch drain now genuinely needs 45 seconds. What is the correct fix?
Lower GRACE_PERIOD to 10 so the drain finishes faster.
Ignore SIGTERM in the application so systemd never stops the service mid-drain.
Raise TimeoutStopSec in the unit to comfortably exceed the real drain time; otherwise SIGKILL interrupts a perfectly reasonable drain.
Remove TimeoutStopSec entirely so there is never any deadline.
Graceful Shutdown
Key Points
A supervisor stops a service by sending SIGTERM, waiting TimeoutStopSec, then the uncatchable SIGKILL — that window is your chance to clean up.
In asyncio, register loop.add_signal_handler(SIGTERM/SIGINT, …) whose only job is to set an asyncio.Event; do cleanup in normal coroutine code.
Draining is two-phase: stop accepting work and await in-flight tasks within a bounded grace period, then force-cancel() stragglers.
Cancelled tasks clean up via CancelledError in try/finally — roll back, nack for redelivery, flush — before closing connections last.
Keep the internal grace period comfortably below TimeoutStopSec (or raise the timeout if a real drain needs longer); make shutdown re-entrant so a second signal never derails cleanup.
When systemd (or Kubernetes, or Docker) stops a service it does not kill it instantly. It first sends SIGTERM — the polite request and the default KillSignal= — then waits up to TimeoutStopSec=, and only if the process is still alive at the end of that window does it send the uncatchable SIGKILL. That window is precisely the application's opportunity to perform a graceful shutdown: stop accepting new work, drain in-flight work, flush buffers, close connections, acknowledge messages, and exit cleanly.
Figure 12.1: The service stop lifecycle.
flowchart TD
A["systemctl stop / redeploy"] --> B["systemd sends SIGTERM"]
B --> C["Signal handler sets shutdown event"]
C --> D["App stops accepting new work"]
D --> E["Drain in-flight tasks"]
E --> F{"Drained within TimeoutStopSec?"}
F -->|Yes| G["Tasks finish and are acknowledged"]
G --> H["Process exits 0 (clean)"]
F -->|"No (timeout exceeded)"| I["systemd sends SIGKILL"]
I --> J["Process dies immediately, no cleanup"]
J --> K["Unacknowledged work left for redelivery"]
The default behavior of a bare python worker.py is that SIGTERM terminates the process abruptly with no cleanup — the worst outcome for a message processor. In asyncio the correct fix is cooperative signal handlers on the event loop. The Python docs recommend loop.add_signal_handler(signum, callback) over the low-level signal.signal(), because the callback runs inside the event loop and can safely touch loop state and set events. It is Unix-only and must be registered from the main thread:
import asyncio, signal
async def main():
loop = asyncio.get_running_loop()
stop = asyncio.Event()
for sig in (signal.SIGINT, signal.SIGTERM):
# Handler does the MINIMUM: just flip the event. No I/O here.
loop.add_signal_handler(sig, stop.set)
await run_until_shutdown(stop)
The single most important rule: the signal handler must do nothing but flip a flag. Cleanup happens in normal coroutine code that observes the event, never inside the signal-handling context, where async I/O is fragile.
Draining means: stop pulling new work, let running tasks finish (or be safely cancelled), and only then exit. A robust worker combines two strategies: signal-an-event and let workers finish (await completion of in-flight tasks); and cancel-and-cleanup for stragglers still running when the grace period expires (call .cancel(), then await asyncio.gather(*tasks, return_exceptions=True); each task catches CancelledError in try/finally to release resources — roll back a transaction, nack a message, flush a metric).
Figure 12.3: The two-phase drain — finish in-flight tasks, then force-cancel stragglers.
flowchart TD
A["Shutdown event set (SIGTERM)"] --> B["Stop accepting new work"]
B --> C["await asyncio.wait(inflight, timeout=GRACE_PERIOD)"]
C --> D{"Task finished before grace expired?"}
D -->|Yes| E["Task runs to completion, msg.ack()"]
D -->|"No (straggler)"| F["task.cancel()"]
F --> G["CancelledError caught in try/finally"]
G --> H["Roll back / msg.nack() for redelivery"]
E --> I["Close external resources (queue.close)"]
H --> I
I --> J["Exit cleanly"]
Here is a complete worker that catches SIGTERM, stops accepting new work, drains within a bounded timeout, and force-cancels stragglers. Notice the ordering: stop accepting work first, drain with a bounded asyncio.wait(..., timeout=GRACE_PERIOD), force-cancel, and only then close the queue.
GRACE_PERIOD = 25 # seconds; MUST be < unit's TimeoutStopSec (30)
async def handle_message(msg, inflight: set):
try:
await do_work(msg) # the real side effect
await msg.ack() # ack AFTER the work is durably done
except asyncio.CancelledError:
await msg.nack() # drain expired: let it be redelivered
raise
finally:
inflight.discard(asyncio.current_task())
async def main():
loop = asyncio.get_running_loop()
stop = asyncio.Event()
for sig in (signal.SIGINT, signal.SIGTERM):
loop.add_signal_handler(sig, stop.set)
inflight = set()
queue = await connect_queue()
while not stop.is_set():
try:
msg = await asyncio.wait_for(queue.get(), timeout=1.0)
except asyncio.TimeoutError:
continue # re-check stop periodically even when idle
task = asyncio.create_task(handle_message(msg, inflight))
inflight.add(task)
if inflight:
done, pending = await asyncio.wait(inflight, timeout=GRACE_PERIOD)
for task in pending:
task.cancel()
if pending:
await asyncio.gather(*pending, return_exceptions=True)
await queue.close() # close external resources last
The grace period is a hard budget set by TimeoutStopSec=. Keep the app's internal grace period shorter than it (25 vs 30 here, leaving margin for connection teardown); if a legitimate drain needs 45 seconds, raiseTimeoutStopSec to match. Make shutdown idempotent and re-entrant: a second SIGTERM should not restart or crash the drain (the asyncio runner even escalates a second Ctrl+C to KeyboardInterrupt). Note that asyncio.run() already best-effort-cancels remaining tasks on exit, but it is signal-blind, so you still need explicit SIGTERM handling to trigger and bound your drain.
Visual animation — coming soon
Post-Quiz: Graceful Shutdown
In an asyncio application, why should the SIGTERM handler registered with loop.add_signal_handler do nothing but set an asyncio.Event?
Because signal handlers are limited to a single line of code by the Python interpreter.
Because cleanup (closing connections, acking messages) belongs in normal coroutine code that observes the event, where async I/O is safe — not in the fragile signal-handling context.
Because setting an event is the only operation SIGTERM handlers are permitted to perform.
Because the event automatically forwards SIGTERM to child processes.
During a drain, the worker awaits in-flight tasks with a bounded timeout, then force-cancels any stragglers. Why cancel rather than simply wait indefinitely for them to finish?
Cancelling is faster and always completes the work correctly.
A single stuck task could otherwise block the whole shutdown past TimeoutStopSec, after which systemd sends the uncatchable SIGKILL and cuts off all cleanup.
asyncio forbids awaiting more than one task at a time during shutdown.
Cancelled tasks are automatically re-run from the start on the next process launch.
The unit sets TimeoutStopSec=30 and the worker uses GRACE_PERIOD = 25. A legitimate batch drain now genuinely needs 45 seconds. What is the correct fix?
Lower GRACE_PERIOD to 10 so the drain finishes faster.
Ignore SIGTERM in the application so systemd never stops the service mid-drain.
Raise TimeoutStopSec in the unit to comfortably exceed the real drain time; otherwise SIGKILL interrupts a perfectly reasonable drain.
Remove TimeoutStopSec entirely so there is never any deadline.
Pre-Quiz: Surviving Crashes
A worker pulls a message, charges a card, then crashes before the ack lands. Under at-least-once delivery, what happens, and how do you prevent a double charge?
The message is silently lost; nothing can be done because the ack never arrived.
The broker redelivers the unacknowledged message; an idempotency key checked atomically before the side effect makes the re-run a safe no-op.
The broker refunds the charge automatically and cancels the message.
Switching to exactly-once delivery guarantees the card is never charged twice.
A worker deadlocks: it is technically alive but processing nothing, so Restart=on-failure never fires. What mechanism catches this?
Nothing — a hung process is indistinguishable from a busy one and cannot be detected.
A watchdog (WatchdogSec=): the app periodically emits WATCHDOG=1, and if the heartbeat stops, systemd treats it as failure and restarts the unit.
RestartSec, which fires after the configured delay whether or not the process has exited.
journalctl -u, which restarts any unit that stops logging.
A team argues that if graceful shutdown always drains cleanly, they don't need idempotency. Why is this reasoning flawed?
It isn't flawed — a clean drain on every stop fully removes the need for idempotency.
Graceful shutdown only reduces redeliveries in the orderly case; under a SIGKILL, OOM kill, or power loss there is no drain at all, and only idempotency makes correctness unconditional.
Idempotency is only needed for read operations, which draining does not cover.
Draining and idempotency are the same mechanism, so having one means having the other.
Why must a queue worker acknowledge a message after the side effect and its dedup record are durably committed, never before?
Acking early is faster and has no downside in practice.
Acking before completion is effectively at-most-once: a crash between the ack and the work silently loses the message with no redelivery.
The broker rejects any ack that arrives after the side effect completes.
Acking after commit is what triggers the broker to send SIGTERM to the worker.
Surviving Crashes
Key Points
Assume the process can die at any point — including after doing the work but before recording it. Correctness must not depend on cleanup ever running.
At-least-once delivery guarantees no loss (unacked messages are redelivered) but permits duplicates; exactly-once delivery is impractical (Two Generals).
Achieve exactly-once effect with an idempotency key checked atomically — a unique-constrained insert, or SET key val NX with a TTL longer than the redelivery window.
Ack after, never before; make effects idempotent (upserts, conditional writes); route poison messages to a dead-letter queue.
A watchdog (WatchdogSec) / liveness probe catches a process that hangs; graceful shutdown reduces redeliveries, but idempotency is the guarantee.
Graceful shutdown handles the orderly stop, but a supervised worker will also be killed at inconvenient times — SIGKILL after a hung drain, a segfault, an OOM kill, a power loss. A crash-safe design assumes the process can die at any point, including after doing the work but before recording that it did. Two failure modes pull in opposite directions: message loss (work vanishes) and double-processing (the same work runs twice). Virtually every production broker resolves this with at-least-once delivery.
At-least-once means a message stays owned by the broker — invisible and unacknowledged — until the consumer explicitly acknowledges it; if the ack does not arrive within a visibility/lease timeout, the broker redelivers. This is the default across SQS standard queues, Celery, Sidekiq, BullMQ/Redis, RabbitMQ, and Kafka consumer groups. It guarantees no loss even across crashes — but explicitly permits duplicates. The classic window: a worker pulls a message, performs the side effect, then crashes before the ack lands; the broker cannot tell "succeeded but didn't ack" from "never processed," so it redelivers and the effect happens twice.
Exactly-once delivery is impractical end-to-end — the acknowledgement that would confirm a single delivery can itself be lost (the Two Generals problem). So the engineering stance is to achieve exactly-once effect by making the consumer idempotent: re-running a message is a safe no-op yielding the same outcome. The core mechanism is the idempotency key — a stable identifier (message ID, payload hash, client request ID). Before the side effect, the worker atomically checks whether that key was already processed; if so, it short-circuits and re-acks.
Implementation
How it dedups
Note
Unique constraint / insert-first
Store key in a UNIQUE column, insert in the same transaction as the effect
Strongest — dedup record and effect commit atomically
Dedup store with TTL
SET key val NX in Redis, TTL > max redelivery window
Check-then-act must be atomic to avoid races
Provider-native keys
Send an Idempotency-Key header (e.g. Stripe)
Provider dedupes and returns the original response
Beyond dedup, three rules make workers crash-safe: ack after, never before (ack early is at-most-once — a crash between ack and completion silently loses the message); make the effect itself idempotent (prefer upserts, SET status='done', conditional writes); and handle poison messages (a message that always crashes the worker redelivers forever — cap attempts and route exhausted messages to a dead-letter queue).
Crash-and-restart handles a process that dies, but not one that hangs. systemd's WatchdogSec= establishes a heartbeat contract: the app periodically calls sd_notify with WATCHDOG=1, and if the heartbeat stops (a deadlock), systemd restarts the unit per Restart=on-watchdog or on-failure. In clusters the same idea appears as liveness probes (restart on failure) and readiness probes (stop routing traffic until ready).
Assembling the full recovery story: when the worker crashes, Restart=on-failure restarts it; because in-flight messages were never acknowledged, the broker never removed them, so the restarted process picks them back up (nothing lost); the idempotency layer then absorbs the inevitable redelivery (nothing duplicated).
Figure 12.4: Crash recovery — at-least-once redelivery absorbed by an idempotency key.
flowchart TD
A["Worker crashes / SIGKILL mid-work"] --> B["In-flight message was never acknowledged"]
B --> C["Restart=on-failure restarts the worker"]
C --> D["Broker redelivers the unacknowledged message"]
D --> E{"Idempotency key already recorded?"}
E -->|"Yes (duplicate)"| F["Short-circuit: re-ack, no repeat effect"]
E -->|No| G["Perform side effect + record key atomically"]
G --> H["msg.ack() after commit"]
F --> I["Exactly-once effect: no loss, no duplicate"]
H --> I
The two safety nets play distinct roles. Graceful shutdown reduces the number of redeliveries in the common, orderly-stop case — fewer messages are in flight when the process exits. But the idempotency layer is what makes correctness unconditional, even when the process is SIGKILL'd with no chance to drain. Graceful shutdown is the optimization; idempotency is the guarantee. Supervised restart + at-least-once + ack-after-commit + an idempotency key is the four-part recipe that lets a gateway worker crash — or be killed outright — and recover without a single user ever seeing a lost or duplicated response.
Post-Quiz: Surviving Crashes
A worker pulls a message, charges a card, then crashes before the ack lands. Under at-least-once delivery, what happens, and how do you prevent a double charge?
The message is silently lost; nothing can be done because the ack never arrived.
The broker redelivers the unacknowledged message; an idempotency key checked atomically before the side effect makes the re-run a safe no-op.
The broker refunds the charge automatically and cancels the message.
Switching to exactly-once delivery guarantees the card is never charged twice.
A worker deadlocks: it is technically alive but processing nothing, so Restart=on-failure never fires. What mechanism catches this?
Nothing — a hung process is indistinguishable from a busy one and cannot be detected.
A watchdog (WatchdogSec=): the app periodically emits WATCHDOG=1, and if the heartbeat stops, systemd treats it as failure and restarts the unit.
RestartSec, which fires after the configured delay whether or not the process has exited.
journalctl -u, which restarts any unit that stops logging.
A team argues that if graceful shutdown always drains cleanly, they don't need idempotency. Why is this reasoning flawed?
It isn't flawed — a clean drain on every stop fully removes the need for idempotency.
Graceful shutdown only reduces redeliveries in the orderly case; under a SIGKILL, OOM kill, or power loss there is no drain at all, and only idempotency makes correctness unconditional.
Idempotency is only needed for read operations, which draining does not cover.
Draining and idempotency are the same mechanism, so having one means having the other.
Why must a queue worker acknowledge a message after the side effect and its dedup record are durably committed, never before?
Acking early is faster and has no downside in practice.
Acking before completion is effectively at-most-once: a crash between the ack and the work silently loses the message with no redelivery.
The broker rejects any ack that arrives after the side effect completes.
Acking after commit is what triggers the broker to send SIGTERM to the worker.