Idempotency Is an Availability Property
Scenario: A scheduled convergence run bounces a payment service every night at 02:00. Nothing has changed on any of those nights. Three months of nightly ten-second outages went unexplained.
New words, in plain English
- Idempotent - running it again produces no further change, because everything is already correct.
- Convergence - moving a system from its current state to the described state.
- Non-idempotent - a task that acts every time regardless of the current state.
- Availability - whether the service is actually up and serving.
By Phase 4 idempotency should feel like hygiene. It is more than that: once handlers, scheduled runs and CI are involved, it is an availability property.
A non-idempotent task reports changed on every run. Anything wired to that signal fires on every run. If what is wired to it is a service restart, you have built an outage generator that runs on a schedule and looks completely green.
The usual sources are worth being able to spot on sight:
command/shellwithoutcreates,removesorchanged_whenlineinfilewhoseregexpdoes not match the line it writesblockinfiletasks sharing amarkerin one filefilewithstate: touchand nomodification_time: preservecopywhere the mode or ownership is contested by another toolget_urlwithoutchecksum, re-downloading every rununarchivewithoutcreates, re-extracting every run
The verification never changes: converge, run again, require changed=0. Put it in CI, not just in your own habits, so idempotency is enforced rather than remembered.
Analogy: A thermostat that fires the boiler every time you glance at it is not merely inefficient - it wears out the boiler, and the room was already the right temperature. The fault is in the sensor, and the sensor is your changed reporting.
A worked example
# Sources of a nightly restart storm, and their fixes
# 1. Command with no result semantics
- ansible.builtin.command: /opt/bin/refresh.sh
register: r
changed_when: "'UPDATED' in r.stdout" # <- the fix
# 2. touch always reports changed
- ansible.builtin.file:
path: /var/run/app.marker
state: touch
modification_time: preserve # <- the fix
access_time: preserve
# 3. Re-downloading every run
- ansible.builtin.get_url:
url: https://example.test/app.tar.gz
dest: /opt/app.tar.gz
checksum: "sha256:abc123..." # <- the fix
# 4. Re-extracting every run
- ansible.builtin.unarchive:
src: /opt/app.tar.gz
dest: /opt/app
remote_src: true
creates: /opt/app/VERSION # <- the fix
# The enforcement, in CI:
# ansible-playbook site.yml
# ansible-playbook site.yml | grep -q 'changed=0.*failed=0'
A subtlety worth naming: some genuine changes are not idempotency bugs. If two tools both manage a file's mode, copy will correctly report changed on every run because the mode really is different each time it looks. The fix is to establish single ownership of that file, not to silence the report with changed_when: false - which would hide a real, ongoing conflict.
Distinguishing "my task is lying" from "the system really is flapping" is the diagnostic skill here, and --diff answers it directly: it shows you what is actually different each run.
Warning:changed_when: falseon a task that genuinely mutates the host does not fix an idempotency bug - it hides one, and it hides it from--diff, from handlers and from anyone reading the output.
Goal: Put this to work in the ansible-command-changed-when lab. Open/labs/ansible, pickansible-command-changed-when, and fix the real broken project - Ansible really does SSH into four managed hosts and converge them.