Skip to main content
RunBook Academy

AnsibleXXXI · Serial Execution and Failure ToleranceSerial Execution

Handlers flush per batch

Advanced⏱ ~20 minansible-playbook

What you'll learn

  • State how many times handlers flush in a batched play and why
  • Explain why per-batch flushing is the mechanism a rolling restart depends on
  • Identify handlers that are unsafe to fire once per batch
  • Reason about which hosts are left inconsistent when one batch fails

Prerequisites

Verified against ansible-core 2.21.x · ansible (community package) 14.x · Python (controller) 3.12+ · ansible-lint 26.x · Molecule 26.x · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · RHEL / Rocky / AlmaLinux 9.x · 2026-08-11

Not yet marked complete on this device.

In an unbatched play, handlers run once, at the end, after every host has finished every task. That is the behaviour the handlers part established, and on a fleet it is a problem: a template task that changes configuration on 400 hosts queues 400 notifications, and the flush at the end of the play restarts 400 services at the same moment.

The handler is doing exactly what it was told. The play just never gave it a reason to stagger.

serial is that reason, and the mechanism is worth stating precisely because everything in the rolling-deployment part is built on it.

The observed behaviour

Read-only / Safesix hosts, serial: 2, executed on ansible-core 2.21.3
$ ansible-playbook -i inv6.ini handlers.yml
PLAY [Handlers per batch] ******************************************************

TASK [Deploy config] ***********************************************************
changed: [h01]
changed: [h02]

RUNNING HANDLER [Restart service] **********************************************
ok: [h01] => { "msg": "RESTARTED h01" }
ok: [h02] => { "msg": "RESTARTED h02" }

PLAY [Handlers per batch] ******************************************************

TASK [Deploy config] ***********************************************************
changed: [h03]
changed: [h04]

RUNNING HANDLER [Restart service] **********************************************
ok: [h03] => { "msg": "RESTARTED h03" }
ok: [h04] => { "msg": "RESTARTED h04" }

PLAY [Handlers per batch] ******************************************************

TASK [Deploy config] ***********************************************************
changed: [h05]
changed: [h06]

RUNNING HANDLER [Restart service] **********************************************
ok: [h05] => { "msg": "RESTARTED h05" }
ok: [h06] => { "msg": "RESTARTED h06" }

Three batches, three flushes, two hosts restarted per flush. Never more than two instances of the service down at once.

This follows directly from the previous lesson: a batched play is the same play executed once per batch, and the implicit handler flush at the end of a play therefore happens at the end of each batch. There is no separate rule for handlers under serial. There is one rule — the play restarts — and this is a consequence of it.

Why this is the whole mechanism

Take the per-batch flush away and a rolling restart is not expressible.

Consider what a rolling deploy needs: for each batch, change the configuration, restart the service, confirm it came back, then move on. The “restart the service” step must happen inside the batch, between the change and the health check. If handlers only flushed at the end of the play, the sequence would be:

  1. Batch 1 writes config. Notifies.
  2. Batch 1 health-checks — against a service still running the old config.
  3. Batch 2 writes config. Notifies.
  4. Batch 2 health-checks — same problem.
  5. End of play: every service restarts simultaneously.

Which is the fleet-wide simultaneous restart you used serial to avoid, with the health checks rendered meaningless because they all ran before any restart happened.

Per-batch flushing is what makes the health check land after the restart of the hosts it is checking, and before the next batch is touched.

The handler that fires once per batch

The same property that makes rolling restarts work makes a certain class of handler wrong under serial, and it is wrong quietly.

A handler is per-host. Under serial, it runs once per batch for the hosts in that batch. If what the handler touches is not per-host — if it is a shared dependency, a central cache, a load balancer, an external API — then it fires once per batch against the same shared thing.

Service impact possiblea handler that is wrong under serial
- name: Deploy backend configuration
hosts: appservers
become: true
serial:
  - 1
  - 5
  - 25%
tasks:
  - name: Write the backend definition
    ansible.builtin.template:
      src: backend.conf.j2
      dest: /etc/app/backend.conf
      mode: '0644'
    notify: Reload the shared proxy

handlers:
  # WRONG under serial: fires once per batch, against the same proxy.
  - name: Reload the shared proxy
    ansible.builtin.systemd_service:
      name: haproxy
      state: reloaded
    delegate_to: proxy01.example.com

Without serial this handler is fine — it fires once, at the end of the play, however many hosts notified it. Add serial and it fires once per batch. A 200-host fleet with serial: [1, 5, 25%] runs six batches, so the shared proxy is reloaded six times during a single deploy.

Six reloads of HAProxy is survivable. Six restarts of a shared message broker, six cache flushes, or six calls to an API with a rate limit are not, and the failure surfaces as something unrelated: the fourth batch fails its health check because the proxy was mid-reload when it was probed.

The fix is to move the shared work out of the batched play. Anything that touches a single shared resource once belongs in a separate play with its own hosts: — usually hosts: localhost or the resource itself — that runs after the rolling play completes.

Service impact possibleshared work in its own play
- name: Roll the backend configuration across the fleet
hosts: appservers
become: true
serial:
  - 1
  - 5
  - 25%
tasks:
  - name: Write the backend definition
    ansible.builtin.template:
      src: backend.conf.j2
      dest: /etc/app/backend.conf
      mode: '0644'
    notify: Restart the local app

handlers:
  - name: Restart the local app
    ansible.builtin.systemd_service:
      name: app
      state: restarted

- name: Reload the shared proxy once, after the rollout
hosts: proxy01.example.com
become: true
tasks:
  - name: Reload haproxy
    ansible.builtin.systemd_service:
      name: haproxy
      state: reloaded

Where the failure boundary falls

Per-batch flushing has a second consequence, and it is the useful one during an incident.

Each batch has its own flush points. A batch that fails takes only its own queued handlers down with it. Batches that already completed have already flushed.

That partitions the fleet into three states after a failed rollout:

GroupState
Hosts in completed batchesconfiguration written and applied — consistent
Hosts in the failing batchconfiguration possibly written, handler discarded — mixed
Hosts in later batchesnever touched — consistent, on the old version

The middle row is the only one you have to investigate, and it is bounded by the batch size. On a 200-host fleet with serial: 25%, a failed rollout leaves at most 50 hosts in an uncertain state, not 200.

This is a real argument for smaller batches that has nothing to do with capacity: the batch size is also the size of the search you will have to run afterwards.

Knowledge check

Knowledge check · 4 questions

  1. Q1. A play over 6 hosts with serial: 2 has one task that notifies one handler, and every host reports changed. How many RUNNING HANDLER banners appear?

  2. Q2. Which handlers become incorrect when serial is added to a play that previously had none? Select all that apply.

  3. Q3. After a rolling deploy fails in batch three of eight, hosts in batches one and two have already had their handlers flushed and are in a consistent state.

  4. Q4. Inside a batched rolling play, why is meta: flush_handlers usually needed before the health check task?

Passing score: 75%. Answers are checked in this browser.