Recovering a Stuck ILM Step
An index has stopped moving through its lifecycle — it either sits in the ERROR step or lingers in one step that never advances — and this runbook walks the diagnosis-to-recovery path that gets it moving again without guessing.
A stalled index is not an ILM outage; the coordinator is still polling, but this one index has hit a precondition it cannot satisfy and has parked itself. The recovery discipline is the same every time: read the authoritative state from the explain API, let step_info name the fault, repair the underlying constraint, and only then resume the state machine. This page is the hands-on continuation of monitoring ILM execution and error states, which is where you learn to detect the stall; here we clear it. Both live under the broader discipline of ILM policy design and lifecycle synchronization, where a declared policy is continuously reconciled against what the deployment is actually running.
Prerequisites
- Elasticsearch 8.x with the ILM coordinator confirmed
RUNNING(GET _ilm/statusreturns"operation_mode": "RUNNING") — if the coordinator is stopped, no index advances and no amount ofretryhelps. elasticsearch-pyv8.0+ (pip install "elasticsearch>=8,<9"); the code below uses the v8 keyword-argument surface (ilm.explain_lifecycle,ilm.retry,ilm.move_to_step), not the legacybody=pattern.manage_ilmon the account that callsPOST <index>/_ilm/retryandPOST _ilm/move/<index>, kept separate from read-only monitoring per securing ILM policies with RBAC.- Read access to
GET _cluster/allocation/explainandGET _cat/aliases, the two tools that confirm the two most common root causes.
The recovery decision path
The single rule that separates a real fix from a retry loop: retry re-runs the failed step exactly as written, so if the world has not changed since it failed, it fails again. Read step_info, change the world, then retry. The flow below is the whole runbook in one picture — explain, read the fault, repair the cause, and choose retry (the common case) over move (the wedged-step exception).
Implementation
1. Read the fault from explain
The explain API is the only authoritative source — never infer the problem from the index name or age. When a step fails, the current step becomes the literal string ERROR, failed_step names which step broke, and step_info carries the machine-readable type and a human-readable reason that usually names the fix outright.
GET logs-app-000042/_ilm/explain?humanA rollover-alias mismatch is the single most common stall — ILM cannot roll an index whose rollover_alias no longer resolves to it:
{
"indices": {
"logs-app-000042": {
"managed": true,
"policy": "logs-app-policy",
"phase": "hot",
"action": "rollover",
"step": "ERROR",
"failed_step": "check-rollover-ready",
"step_info": {
"type": "illegal_argument_exception",
"reason": "index.lifecycle.rollover_alias [logs-write] does not point to index [logs-app-000042]"
}
}
}
}The four causes you will see the most: a rollover alias that no longer points at the write index; an allocation the deployment cannot satisfy because no node carries the required data-tier attribute or the destination tier crossed a disk watermark; a shrink whose preconditions are unmet (the source shards are not co-located on one node, or the target name is taken); and a missing snapshot repository for a searchable-snapshot or wait-for-snapshot step. Each has a distinct repair, and step_info.reason tells you which one you have.
2. Repair the root cause
Match the fault to its fix before touching retry. For the alias mismatch above, re-point the write alias:
POST _aliases
{
"actions": [
{ "add": { "index": "logs-app-000042", "alias": "logs-write", "is_write_index": true } }
]
}For an allocation stall, confirm the real blocker with GET _cluster/allocation/explain — it names the missing node attribute or the crossed watermark — and clear it by tagging a node, freeing disk on the destination tier, or relaxing the watermark. For a shrink, ensure the source index is allocated to a single node and the target name is free. For a snapshot step, register or repair the repository and verify it with POST _snapshot/<repo>/_verify.
3. Resume the lifecycle from Python
Once the cause is fixed, re-run the failed step with POST <index>/_ilm/retry. The v8 client exposes this as client.ilm.retry(index=...) — there is no retry_lifecycle method, and API/HTTP failures raise ApiError, which is a sibling of TransportError and will not be caught by it. The helper below sweeps a pattern, retries only indices actually in ERROR, and reports what it did.
import logging
from elasticsearch import Elasticsearch, ApiError
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
logger = logging.getLogger("ilm_recover")
class StuckStepRecovery:
def __init__(self, es: Elasticsearch):
self.es = es
def find_errored(self, pattern: str = "logs-*") -> dict[str, dict]:
# only_errors trims the response to indices currently in an ERROR step.
resp = self.es.ilm.explain_lifecycle(index=pattern, only_errors=True, human=True)
errored = {}
for idx, meta in resp.get("indices", {}).items():
info = meta.get("step_info", {})
logger.warning(
"%s stuck at failed_step=%s: %s — %s",
idx, meta.get("failed_step"),
info.get("type", "unknown"), info.get("reason", ""),
)
errored[idx] = meta
return errored
def retry(self, index: str) -> bool:
# retry ONLY works on an index currently in ERROR; it re-runs the failed
# step and does not bypass min_age. Fix the cause BEFORE calling this,
# or the step drops straight back to ERROR on the next poll.
try:
self.es.ilm.retry(index=index)
logger.info("Retry submitted for %s", index)
return True
except ApiError as exc:
# 400 here usually means the index is no longer in ERROR.
logger.error("Retry failed for %s (%s): %s", index, exc.status_code, exc.info)
return False
def move(self, index: str, current_step: dict, next_step: dict) -> bool:
# Escape hatch for a wedged step: jump to a known-good position.
# current_step must match the index's CURRENT (phase, action, name) exactly.
try:
self.es.ilm.move_to_step(
index=index, current_step=current_step, next_step=next_step,
)
logger.info("Moved %s from %s to %s", index, current_step, next_step)
return True
except ApiError as exc:
logger.error("Move failed for %s (%s): %s", index, exc.status_code, exc.info)
return False
if __name__ == "__main__":
es = Elasticsearch(
"https://es-cluster-01:9200",
api_key="YOUR_BASE64_ENCODED_API_KEY",
verify_certs=True,
)
recovery = StuckStepRecovery(es)
stuck = recovery.find_errored("logs-*")
for name in stuck:
# Assumes the operator has already repaired the underlying cause.
recovery.retry(name)If the step itself is wrong — a corrected policy left the index pointed at a step that no longer applies, or the step is genuinely wedged — use move_to_step (REST POST _ilm/move/<index>) to jump the index to a known-good position. The current_step must match the index’s current (phase, action, name) exactly, or the API rejects the move:
POST _ilm/move/logs-app-000042
{
"current_step": { "phase": "hot", "action": "rollover", "name": "check-rollover-ready" },
"next_step": { "phase": "warm", "action": "allocate", "name": "check-allocation" }
}Verification
Recovery is proven by explain, not by the absence of an error in the retry response. Poll the same index and confirm step is no longer ERROR and failed_step is gone:
GET logs-app-000042/_ilm/explain?human{
"indices": {
"logs-app-000042": {
"managed": true,
"phase": "hot",
"action": "rollover",
"step": "check-rollover-ready",
"step_info": { "message": "Waiting for index to meet rollover conditions" }
}
}
}The step has advanced off ERROR back into a normal waiting state — check-rollover-ready here means ILM is polling the rollover conditions again. For cluster-wide confirmation that nothing is still parked, sweep with the error filter and expect an empty result:
GET _all/_ilm/explain?only_errors=trueAn empty indices object means no managed index remains in an error step.
Gotchas and edge cases
- Retrying without fixing the cause just loops back to
ERROR.retryre-runs the step exactly as it failed; if the alias still does not resolve or the watermark is still crossed, the very next poll returns the index toERROR. Repair the world first — the explain output before and after a fix should differ, or you have not actually changed anything. - A
poll_intervaldelay is not a stuck step. The coordinator re-evaluates onindices.lifecycle.poll_interval(default10m), so an index can wait minutes between a satisfied precondition and the visible advance. Onlystep == "ERROR", or a step whosestep_time_millishas not moved across several intervals, is genuinely stuck — a fresh wait is normal. moverequires the exact current step.POST _ilm/move/<index>fails unlesscurrent_stepmatches the index’s live(phase, action, name)tuple. Read it straight from explain immediately before the call; a stale tuple from an earlier poll is the usual reason a move is rejected. Reservemovefor genuinely wedged steps —retryis the correct tool whenever the step itself is sound.- Allocation and watermarks are the real cause more often than the policy. A stall that presents as
check-allocationor aWAITINGtransition is almost always a node missing its data-tier attribute or a destination tier pastcluster.routing.allocation.disk.watermark.high— not a broken policy. Confirm with_cluster/allocation/explainand free capacity before you edit anything in the lifecycle.
FAQ
What is the difference between _ilm/retry and _ilm/move?
POST <index>/_ilm/retry re-runs the step that is currently in ERROR, unchanged — it is the right tool when the step itself is correct and you have just fixed the precondition it was waiting on. POST _ilm/move/<index> deliberately jumps the index from one (phase, action, name) position to a different one, which you only need when the step is genuinely wedged or a corrected policy left the index pointed at a step that no longer applies. Reach for retry first; move is the escape hatch.
Why does my index return to ERROR immediately after I retry it?
Because the underlying cause was never fixed. retry replays the failed step against the current cluster state, so if the rollover alias still does not point to the index, or the destination tier is still over its disk watermark, the step fails again on the next poll. Read step_info.reason, make a concrete change that addresses it, and confirm the fix independently (for example _cat/aliases or _cluster/allocation/explain) before retrying.
Can I call retry on an index that is not in ERROR?
No. retry only applies to an index whose current step is ERROR; calling it on a healthy index returns a 400. If an index is merely slow to advance, it is waiting on the poll interval or a min_age timer, not stuck — and retry does not fast-forward either of those. Use explain to confirm the index is actually in ERROR before you attempt recovery.
Related
- Manually triggering an ILM phase transition — using
_ilm/movedeliberately to push an index forward, the same API this runbook uses to escape a wedged step. - Handling ILM step execution failures programmatically — classifying retryable versus terminal failures and the backoff behind an automated retry.
- Monitoring ILM execution and error states — detecting the stall in the first place with drift and error alerting.
← Back to Monitoring ILM Execution & Error States · ILM Policy Design & Lifecycle Synchronization