Processing outside the API — ironic — branch stable/2026.2
Branch
stable/2026.2@39ce09ec3b0d— repository https://opendev.org/openstack/ironic — analysed on 2026-10-29
Almost all of Ironic's work happens in ironic-conductor, outside the HTTP request that started it. Four kinds of triggers drive it: RPC messages from the API (57 conductor methods, table in 03 § 1.3), heartbeats from the ramdisk agent (relayed by the API as an RPC call), periodic tasks (14 in the conductor manager, 9 in the shipped hardware interfaces), and process start (registration, stuck-state recovery, takeover). There are no OVSDB events, no notification listeners and no cache polling.
1. Summary
| Process | Trigger | Type | Purpose | Reads | Writes / emits | Defined at |
|---|---|---|---|---|---|---|
| conductor | _conductor_service_record_keepalive | loop, every [conductor]/heartbeat_interval (10 s) | prove the conductor is alive | — | conductors.updated_at, online | ironic/conductor/base_manager.py:580 |
| conductor | init_host → _correct_stuck_states | process start | fail nodes left in transient states by a dead conductor | nodes in STUCK_STATES_TREATED_AS_FAIL, unreserved, mapped here | fail event, last_error | ironic/conductor/base_manager.py:186 |
| conductor | _sync_power_states | SQL + BMC polling, [conductor]/sync_power_state_interval (120 s) | detect power changes made outside Ironic | nodes not in maintenance mapped here; BMC power state | power_state, maintenance on repeated failure, Nova power-update event | ironic/conductor/manager.py:1708 |
| conductor | _power_failure_recovery | node periodic, [conductor]/power_failure_recovery_interval (300 s) | clear maintenance once the BMC answers again | nodes with fault power failure | maintenance=false, fault=null | ironic/conductor/manager.py:1861 |
| conductor | _check_deploy_timeouts | SQL polling, check_provision_state_interval (60 s) | fail deployments whose agent stopped calling back | wait call-back nodes with provision_updated_at older than deploy_callback_timeout (1800 s) | fail → deploy failed | ironic/conductor/manager.py:1927 |
| conductor | _check_cleanwait_timeouts | idem, clean_callback_timeout | idem for cleaning | clean wait nodes | fail → clean failed (+ tear down) | ironic/conductor/manager.py:2164 |
| conductor | _check_rescuewait_timeouts | check_rescue_state_interval (60 s), rescue_callback_timeout | idem for rescue | rescue wait | rescue failed | ironic/conductor/manager.py:2186 |
| conductor | _check_servicewait_timeouts | check_provision_state_interval, service_callback_timeout | idem for servicing | service wait | service failed | ironic/conductor/manager.py:2209 |
| conductor | _check_inspect_wait_timeouts | check_provision_state_interval, inspect_wait_timeout | idem for inspection | inspect wait | inspect failed | ironic/conductor/manager.py:3459 |
| conductor | _check_orphan_nodes | SQL polling, check_provision_state_interval | release locks of dead conductors, fail what they were doing | nodes reserved by offline conductors | release, fail (deploying→deploy failed, cleaning→clean failed + maintenance), abort power operations | ironic/conductor/manager.py:1950 |
| conductor | _sync_local_state | node periodic, [conductor]/sync_local_state_interval (180 s) | takeover: re-prepare boot environment of active nodes now mapped to this conductor | active, unreserved, not maintenance, conductor_affinity != me | _do_takeover: deploy prepare/take_over, console restart, conductor_affinity | ironic/conductor/manager.py:2238 |
| conductor | _check_orphan_allocations | [conductor]/check_allocations_interval (60 s) | resume allocations of dead conductors | allocations with offline conductor_affinity | takeover + do_allocate | ironic/conductor/manager.py:3983 |
| conductor | _clean_up_caches | [conductor]/cache_clean_up_interval (3600 s) | free disk in image caches | cache directories | file deletion | ironic/conductor/manager.py:115 |
| conductor | manage_node_history | [conductor]/node_history_cleanup_interval (86400 s) | prune node history | node_history | DELETE | ironic/conductor/manager.py:4057 |
| conductor | cleanup_stale_conductors | [conductor]/conductor_cleanup_interval (86400 s) | delete conductors offline for conductor_cleanup_timeout (14 days) | conductors | DELETE | ironic/conductor/manager.py:4069 |
| conductor | _send_sensor_data | [sensor_data]/interval, only if send_sensor_data | publish IPMI/Redfish sensor readings | BMC sensors | notifications | ironic/conductor/manager.py:3090 |
conductor (pxe/ipxe boot) | _check_boot_timeouts | node periodic [pxe]/boot_retry_check_interval, enabled by boot_retry_timeout | reboot nodes whose PXE boot seems stuck | wait call-back, clean wait… unreserved nodes without heartbeat | power cycle | ironic/drivers/modules/pxe_base.py:463 |
conductor (redfish firmware/management/bios/raid) | _query_*_status, _query_*_failed | node periodics [redfish]/firmware_update_status_interval, raid_config_status_interval… | follow BMC task monitors of asynchronous Redfish jobs (firmware update, BIOS apply, RAID create) | nodes in *wait with pending jobs in driver_internal_info | continue_node_clean/deploy/service or error handler | ironic/drivers/modules/redfish/firmware.py:1023, ironic/drivers/modules/redfish/management.py:1450, ironic/drivers/modules/redfish/bios.py:512, ironic/drivers/modules/redfish/raid.py:999 |
| conductor | heartbeat (RPC call from the API) | event from IPA, every heartbeat_timeout/2 seconds roughly | advance deploy/clean/rescue/service/inspection | node, agent commands status | steps, state transitions | ironic/conductor/manager.py:3554, ironic/drivers/modules/agent_base.py:639 |
| conductor | continue_node_clean/deploy/service (RPC cast) | event (driver finished an async step) | run the next step | step list in driver_internal_info | state transitions | ironic/conductor/manager.py:1413, ironic/conductor/manager.py:964, ironic/conductor/manager.py:1018 |
| conductor | continue_inspection (RPC call from the API) | event (IPA posted inventory) | process inspection data | inventory | hooks, rules, ports, properties, inspect → manageable | ironic/conductor/manager.py:4250 |
| networking | ironic-networking RPC server | event | configure switch ports and LAGs | switch config | switches | ironic/networking/manager.py:329 |
2. Common mechanics
2.1 Which conductor runs what
Periodic tasks run in every conductor, but each only touches the nodes the hash ring maps to it: iter_nodes filters on _mapped_to_this_conductor (ironic/conductor/base_manager.py:530, ironic/conductor/base_manager.py:596). node_periodic (ironic/conductor/periodics.py:108) wraps this: query nodes with filters, run predicate, take a lock per node (shared or exclusive), ignore NodeLocked/NodeNotFound, stop after limit nodes, and run up to [conductor]/periodic_max_workers (8) in parallel. There is no leader election: the ring partitions the work.
2.2 Worker pool
RPC handlers return quickly and push long work to a thread pool of [conductor]/workers_pool_size (300) threads plus a reserved pool of reserved_workers_pool_percentage (5 %) usable by user-initiated work only (ironic/conductor/base_manager.py:555). When both are full, the RPC answers NoFreeConductorWorker (503) and the node state is rolled back by provisioning_error_handler (ironic/conductor/utils.py:488). Heartbeats are not allowed into the reserved pool (_allow_reserved_pool=False, ironic/conductor/manager.py:3554).
2.3 Locks
Every operation on a node goes through task_manager.acquire (ironic/conductor/task_manager.py:158): an exclusive lock is the nodes.reservation column (see 03 § 3), retried node_locked_retry_attempts (3) times every node_locked_retry_interval (1 s); a shared lock takes nothing in the database. Long operations keep the lock across threads: the TaskManager is released only when the spawned worker ends (ironic/conductor/task_manager.py:546). A conductor that dies keeps its reservations until another conductor runs _check_orphan_nodes (it notices the dead conductor after heartbeat_timeout, 60 s) or until it restarts with the same hostname (ironic/db/sqlalchemy/api.py:1538).
3. Sheets
3.1 Heartbeat processing (deploy interface heartbeat)
- Trigger:
POST /v1/heartbeat/{node}→ RPC callheartbeat→ shared lock, token check, workertask.driver.deploy.heartbeat(ironic/drivers/modules/agent_base.py:639). - Emitter: IPA, every
heartbeat_timeoutfrom the lookup config with jitter ([api]/ramdisk_heartbeat_timeout, 300 s, is the timeout the agent is told). - Purpose: record that the agent is alive and where (
agent_url), then move the current operation forward. - Flow:
- Ignore heartbeats in states that do not wait for an agent (
HEARTBEAT_ALLOWED, or fast-track states),ironic/drivers/modules/agent_base.py:491. - Upgrade to an exclusive lock without retry; if locked, skip (next heartbeat retries).
- Save
agent_url,agent_version,agent_last_heartbeat,agent_verify_ca. - Maintenance: abort the current operation unless provisioning in maintenance is allowed (
ironic/drivers/modules/agent_base.py:497). - Dispatch on the provision state:
wait call-back→ load in-band deploy steps once, thenprocess_next_step('deploy')unless a step is polling (ironic/drivers/modules/agent_base.py:529);clean wait→ first heartbeat after boot starts cleaning (resume, fetch in-band steps,set_node_cleaning_steps,continue_node_clean), later ones check the running step (ironic/drivers/modules/agent_base.py:558);rescue wait→finalize_rescue(ironic/drivers/modules/agent_base.py:595);service wait→ like cleaning (ironic/drivers/modules/agent_base.py:607).
- Ignore heartbeats in states that do not wait for an agent (
- Concurrency: shared lock first, exclusive lock without retry: two heartbeats never run steps twice.
- Errors: any exception moves the node to the corresponding
*failedstate through the error handlers; a missing or wrong token is refused before the worker (400). - Cost: one DB write per heartbeat per node, plus one
GET /v1/commandsto the agent when a step is running.
3.2 Step framework (clean, deploy, service, verify)
Formal specification in 07 § 3.3. Summary of the runtime loop (ironic/conductor/cleaning.py:229, ironic/conductor/deployments.py:323, ironic/conductor/servicing.py:133):
- The ordered step list is stored in
driver_internal_info.<kind>_steps, the current index in<kind>_step_index, the running step innode.<kind>_step. - For each remaining step: save it as current; reserved names (
power_on,power_off,reboot,hold,wait) are handled by the conductor (ironic/conductor/steps.py:1063);execute_on_child_nodessteps run on child nodes; others callinterface.execute_<kind>_step. - A step returns
None(done, next step), or*WAIT(asynchronous:process_event('wait'), the step continues on a heartbeat, a driver periodic orcontinue_node_*), or raises (failure:*failed). - After the last step: tear down the ramdisk environment,
done(ormanagefor manual cleaning, or for retired nodes). abort(API) on a*wait/*holdnode: a non-abortable running step getsabort_after: trueand the abort happens when it completes (ironic/conductor/manager.py:1553,ironic/conductor/cleaning.py:514).
3.3 _sync_power_states
- Trigger: every 120 s; nodes not in maintenance mapped to this conductor, failing nodes first.
- Data read:
SELECT uuid, driver, conductor_group, … FROM nodes WHERE maintenance = 0(then hash-ring filter in Python), then for each node a BMC call (get_power_state), in up to[conductor]/sync_power_state_workers(8) threads. - Flow (
ironic/conductor/manager.py:4574): power state unknown and power interface invalid → skip; BMC error → count, afterpower_state_sync_max_retries(3) put the node in maintenance with faultpower failure(ironic/conductor/manager.py:4524); same state → nothing; node in a provisioning state where power changes are expected (SYNC_EXCLUDED_STATES) or in maintenance → nothing; DB state unknown → record it and send Novapower-update; otherwise, withforce_power_state_during_sync(true) and a power interface that supports it, power the machine back to the DB state, else record the observed state and notify Nova. - Concurrency: shared lock per node, upgraded only when something must change.
- Cost: one BMC request per node per cycle; IPMI calls are additionally throttled per BMC (5 s).
3.4 Callback timeouts (_check_*_timeouts)
- Query: nodes mapped here, in
wait call-back/clean wait/rescue wait/service wait/inspect wait, unreserved, withprovision_updated_at < now - timeout(ironic/conductor/base_manager.py:611). - Effect:
failevent withlast_error"Timeout reached while waiting for callback for node …" and the error handler of the flow (tear down cleaning, power off…). Every heartbeat touchesprovision_updated_at(node.touch_provisioning(),ironic/drivers/modules/agent_base.py:529), so only a silent agent times out.
3.5 Conductor failure and takeover
- A conductor stops heartbeating; after
heartbeat_timeout(60 s) the others consider it offline and the hash rings, rebuilt every 15 s, map its nodes elsewhere. _check_orphan_nodeson the new owner releases the dead conductor's reservations and fails what was running (ironic/conductor/manager.py:1950)._sync_local_statere-preparesactivenodes (PXE/iPXE configs, boot ISOs) on the new conductor (ironic/conductor/manager.py:2107): needed because these files live on the conductor's local disk._check_orphan_allocationsresumes allocations.- When the dead conductor restarts,
_correct_stuck_statesfails the nodes it had left indeploying,cleaning, … (ironic/conductor/base_manager.py:327).
3.6 Inspection (built in; ironic-inspector is not used)
- Start:
PUT …/states/provision {"target": "inspect"}→inspect_hardware(ironic/conductor/manager.py:3414,ironic/conductor/inspection.py:29). Out-of-band (redfishinspect) completes synchronously from BMC data; in-band (agentinspect) boots the ramdisk and waits ininspect wait(ironic/drivers/modules/inspector/interface.py:183). - Continue: IPA posts to
/v1/continue_inspection→ RPCcontinue_inspection(ironic/conductor/manager.py:4250) →inspection.continue_inspection(ironic/conductor/inspection.py:119): run the interface's processing (hooks from[inspector]/hooks:ramdisk-error,validate-interfaces,ports,architectureby default; others such asmemory,cpus,root-device,local-link-connection,parse-lldp,physical-networkare opt-in, entry pointsironic.inspection.hooksinpyproject.toml), then inspection rules of phasemainsorted by priority (ironic/common/inspection_rules/engine.py:192; 15 operators inironic/common/inspection_rules/operators.py:27, 18 actions inironic/common/inspection_rules/actions.py:31), store inventory and plugin data (node_inventory), cache firmware,done→manageable. - Auto-discovery: unknown machines posting inventory are enrolled when
[auto_discovery]/enabled(ironic/api/controllers/v1/ramdisk.py:360).
3.7 Allocation (create_allocation RPC)
ironic/conductor/manager.py:3915 spawns do_allocate (ironic/conductor/allocations.py:34): candidate query by resource class, available, not maintenance, not reserved, no instance; trait filter; candidate list filter; owner filter; random shuffle; try to lock and update each candidate up to a retry limit; on success allocation.state = active, node.instance_uuid = allocation.uuid, node.allocation_id. Not deterministic by design (random order).
Source: reverse-spec-ironic/05-async-processing_stable-2026.2.md. The _rewrite/ and _inventory/ files are not published on the site.