Process Management: Daemons, systemd, and Graceful Shutdown

Learning Objectives

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 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.

SupervisorEnvironmentRestart mechanism
systemdBare Linux hostRestart=on-failure
DockerSingle-container--restart on-failure / unless-stopped / always
Kubernetes (kubelet)ClusterPod 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 enablestart 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 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:

[Unit]
Description=AI Gateway Worker
After=network-online.target
Wants=network-online.target
StartLimitIntervalSec=60
StartLimitBurst=5

[Service]
Type=simple
User=gateway
WorkingDirectory=/opt/gateway
Environment=GATEWAY_ENV=production
EnvironmentFile=/etc/gateway/worker.env
ExecStart=/opt/gateway/.venv/bin/python -m gateway.worker
Restart=on-failure
RestartSec=5
RestartSteps=4
RestartMaxDelaySec=60
TimeoutStopSec=30
KillSignal=SIGTERM

[Install]
WantedBy=multi-user.target

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.

ValueRestarts after…Typical use
no (default)neverone-shot tasks
on-successclean exit onlyprocesses meant to loop after finishing
on-failurenon-zero exit, fatal signal, timeout, or watchdogrecommended for long-running services
on-abnormalfatal signal, timeout, or watchdog onlya deliberate error exit should stay down
on-watchdogwatchdog timeout onlypairs with WatchdogSec=
alwaysany exit, clean or notdaemons 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 enablestart 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

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, raise TimeoutStopSec 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

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.

ImplementationHow it dedupsNote
Unique constraint / insert-firstStore key in a UNIQUE column, insert in the same transaction as the effectStrongest — dedup record and effect commit atomically
Dedup store with TTLSET key val NX in Redis, TTL > max redelivery windowCheck-then-act must be atomic to avoid races
Provider-native keysSend 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.

Your Progress

Answer Explanations