Handlers Depend on Honest changed Reporting
Scenario: A production service restarts on every scheduled run, at 2 AM, seven nights a week. Nothing has changed on any of those nights. The task that notifies the restart is a shell command.
New words, in plain English
- Change contract - the idea that
changedmust mean something really changed, because other things depend on it. - Spurious change - a task reporting changed when it altered nothing.
- Restart storm - unnecessary service restarts caused by spurious changes.
Handlers make idempotency operationally important rather than merely tidy. Once a restart hangs off changed, a task that lies about changed is no longer a cosmetic problem - it is a recurring, self-inflicted service interruption.
The usual culprits are exactly the ones from the modules section. A shell or command task reports changed every run unless told otherwise. A lineinfile with a regexp that never matches appends and reports changed every run. A file task with state: touch updates the timestamp and reports changed every run.
Each has a correct fix: changed_when (or creates) for commands, an anchored regexp for lineinfile, and modification_time: preserve plus access_time: preserve for touch - or, better, a module that expresses the actual intent.
The verification is simple and should be routine: converge the system, run again, and confirm the handler section is empty. If a handler fires on the second run, some task upstream is reporting a change that did not happen.
Analogy: A smoke detector wired to the sprinklers is a fine design - until it fires on burnt toast. The sprinklers are working perfectly; the sensor is the liar. Fix the sensor, not the sprinklers.
A worked example
# Fires the handler EVERY run - a nightly restart storm
- name: Update the catalogue
ansible.builtin.shell: /opt/bin/refresh-catalogue.sh
notify: reload catalogue
# Fires ONLY on real change
- name: Update the catalogue
ansible.builtin.command: /opt/bin/refresh-catalogue.sh
register: refresh
changed_when: "'UPDATED' in refresh.stdout"
notify: reload catalogue
# The proof, every time:
# ansible-playbook site.yml # converges, handler fires
# ansible-playbook site.yml # changed=0, NO handler section
There is a subtler variant worth watching for. A task can be perfectly idempotent in content and still report changed because of a metadata difference - ownership, mode or timestamp. copy reports changed if the mode differs even when the bytes match. That is correct behaviour, but if it notifies a restart, a mode that is being fought over by two different tools will bounce the service forever.
When you find that, the answer is usually to establish single ownership of the file rather than to loosen the handler.
Tip: The empty-handler second run is the cheapest production-safety test you have. Make it part of code review, not just of your own workflow.
Goal: Put this to work in the ansible-handler-notify lab. Open/labs/ansible, pickansible-handler-notify, and fix the real broken project - Ansible really does SSH into four managed hosts and converge them.