Skip to main content
RunBook Academy

AnsibleXXXIII · Delegation and Controller-Side ExecutionDelegation and controller-side execution

Making "exactly once" actually mean once

Advanced⏱ ~26 minansible-core

What you'll learn

  • Write a once-per-play condition that is unaffected by serial batching
  • Distinguish ansible_play_hosts_all, ansible_play_hosts and ansible_play_batch by scope and by failure behaviour
  • Use throttle to serialise a task without designating a single host
  • Choose between the constructs from the stated intent of the task

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.

The previous lesson established that run_once means once per batch. This one is about what to write instead, and the useful move is to stop asking “how do I make run_once behave” and start asking what the task actually needs.

Three intents hide behind the same keyword:

  • “This work happens one time per run.” A schema migration. A release tag. A change-ticket transition.
  • “Only one host may do this at a time.” Anything competing for a shared lock, a licence seat, or an API rate limit.
  • “This is not about any host.” Fetching a manifest, validating input, notifying a channel.

Each has a construct that expresses it, and each construct survives serial because it does not depend on the batch.

Intent one: once per run

The play host lists are the tool, and choosing between them is the whole decision.

Read-only / Safethe three host lists under serial
$ ansible-playbook -i hosts.ini playhosts-serial.yml
"msg": "web1: play_hosts=['web1', 'web2', 'web3', 'web4', 'web5', 'web6'] batch=['web1', 'web2'] all=['web1', 'web2', 'web3', 'web4', 'web5', 'web6']"
"msg": "web3: play_hosts=['web1', 'web2', 'web3', 'web4', 'web5', 'web6'] batch=['web3', 'web4'] all=['web1', 'web2', 'web3', 'web4', 'web5', 'web6']"
"msg": "web5: play_hosts=['web1', 'web2', 'web3', 'web4', 'web5', 'web6'] batch=['web5', 'web6'] all=['web1', 'web2', 'web3', 'web4', 'web5', 'web6']"
VariableScopeShrinks when a host fails?
ansible_play_hosts_allevery host the play started withno
ansible_play_hoststhe whole play, active hosts onlyyes
ansible_play_batchthe current serial batch, active hostsyes

Only the third is batch-scoped, and that is precisely why run_once — which works in batch terms — repeats. A condition written against either of the first two is evaluated identically in every batch, so exactly one host in the whole play satisfies it.

Read-only / Safeonce per play, whatever serial says
- name: Apply pending schema migrations
ansible.builtin.command: /opt/app/bin/migrate --apply
args:
  chdir: /opt/app
when: inventory_hostname == ansible_play_hosts[0]
delegate_to: "{{ migration_runner_host }}"

Verified with serial: 2 over six hosts: the task ran once, on web1, in the first batch, and was skipped for every host in batches two and three.

Note that this is a plain when: with no run_once anywhere. That is the point — the conditional is evaluated on every host individually, so none of the run_once hazards from the previous lesson apply. It composes with other conditions normally, and a reader can work out what it does without knowing the keyword’s edge cases.

Which list: ansible_play_hosts or ansible_play_hosts_all?

They differ in one situation, and it is the situation that matters: when the host they name has already dropped out.

Read-only / Safeweb1 fails, then both forms are tried
$ ansible-playbook -i hosts.ini allzero-fail.yml
TASK [web1 drops out] **********************************************************
fatal: [web1]: FAILED! => {"changed": false, "msg": "web1 is out"}
skipping: [web2]
skipping: [web3]
skipping: [web4]
skipping: [web5]
skipping: [web6]

TASK [Once per play via ansible_play_hosts_all[0]] *****************************
skipping: [web2]
skipping: [web3]
skipping: [web4]
skipping: [web5]
skipping: [web6]

TASK [Compare - ansible_play_hosts[0] instead] *********************************
ok: [web2] => {
  "msg": "PLAY_HOSTS[0] on web2 (hosts[0]=web2)"
}
skipping: [web3]
skipping: [web4]
skipping: [web5]
skipping: [web6]

ansible_play_hosts_all[0] is still web1. web1 is gone, so no surviving host matches and the task ran nowhere at all — silently, with a clean recap.

ansible_play_hosts[0] had already become web2, so the task ran.

Both behaviours are defensible and they encode different requirements:

  • ansible_play_hosts[0] — “this must happen once, on some host”. The default choice. Resilient, and the failure of one host does not silently cancel the shared work.
  • ansible_play_hosts_all[0] — “this must happen once, on a host chosen deterministically”. Stable across runs while the inventory is stable, and it stops rather than substituting when that host is unavailable.

If the answer is “it must happen once, on a specific host”, neither is right — that is delegate_to with a named host, and the condition is only deciding how often.

Intent two: one at a time, but everyone

throttle is the keyword for “this task must not run concurrently”, and it is a different requirement from “this task must run once”.

The distinction is worth stating plainly, because teams reach for run_once when they mean throttle: 1: run_once means five hosts get the work done by one of them; throttle: 1 means all five do the work, one after another.

Read-only / Safethrottle measured
$ /usr/bin/time -f 'elapsed %e s' ansible-playbook -i thr.ini thr.yml -f 5
-- forks 5, no throttle --
elapsed 2.48 s

-- forks 5, throttle 1 --
elapsed 8.91 s

-- forks 1, no throttle --
elapsed 8.92 s

-- forks 1, throttle 4 (cannot raise above forks) --
elapsed 8.91 s

Four hosts at two seconds each: 2.5 seconds in parallel, 8.9 seconds serialised. The last measurement is the one worth keeping — throttle: 4 with forks: 1 still took 8.9 seconds, because throttle can only lower concurrency, never raise it. The keyword documentation says so directly: “This is independent of the forks and serial settings, but cannot be set higher than those limits.”

Where this replaces a misused run_once:

Configuration changeevery host registers itself, one at a time
- name: Register this host with the service catalogue
ansible.builtin.uri:
  url: "https://catalogue.example.com/api/v1/nodes"
  method: POST
  body_format: json
  body:
    name: "{{ inventory_hostname }}"
    role: "{{ node_role }}"
  headers:
    Authorization: "Bearer {{ catalogue_token }}"
delegate_to: localhost
throttle: 1
no_log: true

Every host needs its own registration, so run_once would be wrong in the strongest sense — five of six hosts would go unregistered. throttle: 1 gets all six done without ever having two writers at the catalogue at once.

throttle applies to Play, Role, Block, Task and Handler, so a whole block of tasks that share one lock can be serialised together rather than task-by-task. The performance side of it — protecting package mirrors, licence servers and rate-limited APIs — is the subject of a lesson in the next part.

Intent three: not about any host

When the work is not per-host at all, the cleanest construct is the one that does not need a keyword: a play that targets one thing.

Read-only / Safeglobal work in its own play
- name: Pre-flight
hosts: localhost
gather_facts: false
connection: local
tasks:
  - name: Fetch the release manifest
    ansible.builtin.uri:
      url: "https://artifacts.example.com/releases/{{ release_version }}.json"
      return_content: true
    register: manifest

  - name: Refuse to proceed without a signature
    ansible.builtin.assert:
      that:
        - manifest.json.signature is defined
      fail_msg: "Release {{ release_version }} is unsigned; aborting."

- name: Roll the web tier
hosts: web
serial: 4
tasks:
  - name: The actual rollout
    ansible.builtin.debug:
      msg: "deploying {{ hostvars['localhost']['manifest']['json']['version'] }}"

Three properties make this the strongest of the three constructs where it fits:

  • Once is structural. No keyword to misread, nothing for a future serial: to change.
  • It fails before anything is touched. An unsigned manifest stops the run while the fleet is untouched, which is a better place to stop than batch two of five.
  • The value is available to every host, through hostvars['localhost'], and it is the same value for all of them — unlike a run_once registration, which differs per batch.

The cost is that variables do not flow implicitly between plays. Reaching back through hostvars['localhost'] is the price, and it is a fair one for work that genuinely belongs to the run rather than to a host.

Knowledge check

Knowledge check · 4 questions

  1. Q1. A play over 6 hosts with serial: 2 needs a schema migration applied exactly once per run. Which construct achieves that?

  2. Q2. What is the practical difference between run_once: true and throttle: 1 on the same task?

  3. Q3. A must-happen-once task is gated by when: inventory_hostname == ansible_play_hosts_all[0], and the host it names failed earlier in the play. Which statements are true? Select all that apply.

  4. Q4. A separate hosts: localhost play makes "runs once" structural, so no future serial: change can turn it into several executions.

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