Handlers & Real Idempotency Testing

Ansible

Chapter 6 · Handlers & Real Idempotency Testing

ansible1-5 used notify: restart nginx without explaining it. This chapter closes that gap — and goes further, turning idempotency from something ansible1-4 merely asserted into something you can actually prove, by running a playbook twice and reading the result honestly.

Handlers — Explaining notify:

handlers: - name: restart nginx service: name: nginx state: restarted

A handler is a special task that only runs if something notify-ed it, and runs at most once per play, no matter how many tasks notified it. ansible1-5's own template task — notify: restart nginx — only actually triggers this handler when the template task itself reports changed. If the config file was already correct, the task reports ok, and the handler never fires at all. This is ansible1-4's idempotency principle paying off directly: a service restart happens exactly when something genuinely changed, never as a matter of routine.

Why Handlers Run at the End

Handlers run after every regular task in the play has completed, not the moment they're notified. If five separate tasks all notify the same handler during one run, it still only fires once, at the very end — avoiding five redundant restarts when a single one is enough. For the rare case where an action genuinely needs to happen immediately, mid-play, rather than deferred — meta: flush_handlers exists as an explicit escape hatch, worth knowing exists even if it's rarely needed.

changed vs. ok — Reading Ansible's Own Report Honestly

ansible1-2 already showed the report format — each task is ok, changed, or failed per host. Put together with ansible1-4's idempotency principle, this gives a concrete, checkable claim: a healthy second run of an idempotent playbook should report changed=0 in the PLAY RECAP. Not "probably nothing changed" — a specific, verifiable number.

Real Idempotency Testing — Running a Playbook Twice

# First run, against a fresh host PLAY RECAP ***** web1.example.com : ok=6 changed=4 unreachable=0 failed=0 # Second run, immediately after, no changes made in between PLAY RECAP ***** web1.example.com : ok=6 changed=0 unreachable=0 failed=0

This is the actual practice, not just a claim to trust: run the playbook once, expect real changes on a fresh host. Run it again, immediately, with nothing else touched. If the second run reports anything other than changed=0, that's a genuine bug somewhere in the playbook — most often ansible1-4's own command/shell trap, a task quietly doing something every single run regardless of whether it needed to.

A Concrete Idempotency Bug, Caught by This Test

Recall ansible1-4's own unfixed example — shell: "systemctl restart myapp", no changed_when. Before the fix, running this idempotency test would show changed=1 on every single run, second run included, no matter how many times it's repeated — the exact symptom that reveals the bug. After the fix (a when:/changed_when: tied to whether the config genuinely changed), a clean second run correctly reports changed=0. The fix from that chapter and the verification practice from this one are the same discipline, applied at two different points.

Second-run resultWhat it means
changed=0The playbook is genuinely idempotent — nothing left to converge
changed>0, identical to the first runA real idempotency bug — something is running unconditionally every time
--check plus --diff shows exactly what would change
ansible1-4's own --check shows that a task would report changed; adding --diff shows the actual line-by-line difference for file-based tasks like template. Genuinely useful when a second run reports an unexpected change and you need to see precisely what's different, not just that something is.
A notify inside a skipped task never fires — a real, easy-to-miss gotcha
If a task carrying notify: restart nginx is itself skipped by a when: condition, the handler is never triggered at all — the notify line is still physically present in the file and looks like it should apply, but a skipped task notifies nothing. Worth checking directly when a service unexpectedly fails to restart after a config change that should have triggered it.

Hands-On Exercises

Exercise 1

Three separate tasks in one play each notify the same handler, "restart myapp." Explain exactly how many times the handler actually runs, and when, during that one playbook run.

📄 View solution
Exercise 2

A playbook's second run reports changed=2 in the PLAY RECAP, identical to the first run's result for those same two tasks. Explain what this tells you, and the specific next step you'd take to investigate.

📄 View solution
Exercise 3

A task that copies a config file and notifies a "restart myapp" handler has when: feature_flag_enabled attached to it. On a host where feature_flag_enabled is false, explain what happens to both the task and the handler.

📄 View solution

Chapter 6 Quick Reference

  • A handler only runs if notified, at most once, at the end of the play — regardless of how many tasks notify it
  • A task only notifies its handler when that task itself reports changed — ansible1-4's idempotency principle, made practical
  • A healthy, idempotent playbook reports changed=0 on an immediate second run — a concrete, checkable claim, not a hope
  • Running a playbook twice and checking for changed=0 is the real idempotency test — not just trusting a module's own reputation
  • --check --diff shows exactly what would change, line by line, for file-based tasks
  • A notify inside a task skipped by when: never fires — a real, easy-to-miss cause of "why didn't this restart"