Diagnosing stuck ISM policy transitions
When an OpenSearch Index State Management (ISM) index sits in a state whose transition conditions look satisfied — the age is past min_index_age, the size is over min_size — yet the index never advances, the fault is almost always in the execution layer between the policy and the scheduler, not in the conditions themselves. This walkthrough reads _plugins/_ism/explain for the three fields that reveal the real cause (retry_count, failed, and info), checks that the background sweep is actually evaluating the index, and clears the stall with _plugins/_ism/retry.
A transition that “should have fired an hour ago” is a diagnosis problem before it is a fix problem: _cat/indices shows a healthy green index and gives no hint that ISM has quietly parked it. This is the detection half of the parent Error Handling & Retries discipline — you first prove where the lifecycle stopped, then decide whether the sibling automated recovery in Implementing retry logic for stuck ISM transitions applies, or whether the condition itself is misconfigured back in Phase Transition Logic.
Prerequisites
Confirm each item before you conclude an index is “stuck” — several of these rule out the common false alarm where the transition was simply going to fire on the next sweep.
Step-by-step procedure
The order matters: prove the index is managed, then read the failure fields, then rule out an exhausted retry budget, and only then question the condition itself.
1. Confirm the index is actually managed by ISM
An index that was created before its policy was attached, or that lost its policy in a template change, is not “stuck” — ISM is simply not looking at it. The explain endpoint is the authoritative check; _cat/indices cannot show management status.
GET _plugins/_ism/explain/logs-prod-000042?pretty
{
"logs-prod-000042": {
"index.plugins.index_state_management.policy_id": "logs-hot-warm-cold",
"index.opendistro.index_state_management.policy_id": "logs-hot-warm-cold",
"enabled": true,
"index_creation_date": 1752624000000
},
"total_managed_indices": 1
}
Gotcha: if
policy_idisnullorenabledisfalse, the index is unmanaged and no condition will ever fire — attach or re-enable the policy rather than retrying. Apolicy_idthat is present but points at a policy the index no longer matches is the classic template-drift case covered separately for the phase-transition cluster.
2. Read the failure triad: failed, retry_count, and info
When the index is managed, expand the explain output for the current action. Three fields decide everything: action.failed, retry_count, and info.cause.
GET _plugins/_ism/explain/logs-prod-000042?pretty
{
"logs-prod-000042": {
"state": { "name": "hot", "start_time": 1752624300000 },
"action": { "name": "transition", "start_time": 1752624300000, "failed": true },
"step": { "name": "attempt_transition", "status": "failed" },
"retry_count": 4,
"info": {
"message": "Failed to evaluate transition conditions",
"cause": "index [logs-prod-000042] missing rollover_alias setting"
}
}
}
Gotcha:
action.failed: truewith a populatedinfo.causemeans ISM tried and hit a real error — the transition never even got to condition evaluation. Readinfo.causeliterally; here the transition action cannot resolve the write index becauseplugins.index_state_management.rollover_aliasis unset, so no age/size math is ever reached.
3. Determine whether the retry budget is exhausted
Every action carries an implicit or policy-defined retry block. Once retry_count reaches the policy’s retry.count ceiling, ISM stops re-attempting on its own — the index then stays failed forever with no further sweep activity, which is exactly what makes it look permanently stuck.
GET _plugins/_ism/policies/logs-hot-warm-cold?pretty
"retry": { "count": 3, "backoff": "exponential", "delay": "1m" }
Gotcha: when
retry_count(4 above) already exceedsretry.count(3), the background sweep will not touch the index again no matter how long you wait. Only an explicit_plugins/_ism/retrycall resets the counter and re-arms execution — waiting is futile at this point.
4. Re-evaluate the transition condition against live values
If action.failed is false and the index still has not moved, the condition may simply not be met despite appearances. Compare the policy’s transitions conditions against the live index age and size — operators routinely misread min_index_age (measured from index creation, not from the last document) or a store.size that counts replicas.
GET _cat/indices/logs-prod-000042?v&h=index,docs.count,pri.store.size,creation.date.string
index docs.count pri.store.size creation.date.string
logs-prod-000042 81034112 47.9gb 2026-07-16T00:00:00.000Z
Gotcha:
min_index_agecounts fromcreation.date, so an index re-created two hours ago will not satisfy amin_index_age: "7d"transition even though it is “full”. Andmin_sizecompares against primary store size — astore.sizethat looks over threshold in_cat/indicesincludes replicas and can mislead you by a factor of two.
5. Clear the block and re-arm with _plugins/_ism/retry
Once the root cause from Step 2 is fixed (here, set the missing rollover alias), reset the failure flag and counter. An empty body re-runs the failed step in the current state.
POST _plugins/_ism/retry/logs-prod-000042
{ "updated_indices": 1, "failures": false, "failed_indices": [] }
Gotcha:
_retryonly re-queues the step — actual execution still waits for the next sweep tick, so do not expect the state to advance instantly. If you must skip a permanently blocked action, pass{"state": "warm"}to jump straight to a known-good state, but only when skipping is safe for the lifecycle defined in Phase Transition Logic.
Verification
Confirm the index resumed its lifecycle and the failure fields reset.
# 1. Failure cleared and counter reset to zero?
curl -s "https://os.internal:9200/_plugins/_ism/explain/logs-prod-000042?pretty"
# "action": { "failed": false }, "retry_count": 0, "step": { "status": "starting" }
# 2. Did the state actually advance on the following sweep?
curl -s "https://os.internal:9200/_plugins/_ism/explain/logs-prod-000042?filter_path=**.state"
# "state": { "name": "warm" } <- transition fired
A healthy result shows action.failed: false, retry_count: 0, and — after one job_interval — a state.name that has moved on from hot. If retry_count climbs again immediately, the Step 2 root cause is not truly cleared and you are back to a persistent block, not a transient stall.
Common failures
| Symptom | Root cause | Fix command |
|---|---|---|
explain shows policy_id: null on a full index |
Policy never attached (index predates policy or template drift) | POST _plugins/_ism/add/logs-prod-000042 {"policy_id":"logs-hot-warm-cold"} |
action.failed: true, info.cause mentions rollover_alias |
Transition action cannot resolve the write index without the alias | PUT logs-prod-000042/_settings {"plugins.index_state_management.rollover_alias":"logs-write"} then POST _plugins/_ism/retry/logs-prod-000042 |
retry_count frozen above retry.count, no sweep activity |
Retry budget exhausted; ISM stopped attempting on its own | POST _plugins/_ism/retry/logs-prod-000042 to reset the counter |
Condition looks met but index never moves, failed: false |
min_index_age counts from creation, or min_size compares primaries only |
Recompute against creation.date and pri.store.size; adjust the transition value |
| Follower index “stuck” on a write action | CCR follower cannot execute write actions locally | Drive the transition on the leader; do not retry the follower |
Frequently asked questions
Why does the transition never fire even though `min_index_age` has clearly passed?
Two usual causes: the sweep has not run yet (wait up to one job_interval), or action.failed is true and ISM stopped before evaluating the condition at all. Always read _plugins/_ism/explain first — if info.cause is populated, the condition math was never reached, so no amount of waiting helps.
What is the difference between `retry_count` and the policy `retry.count`?
retry_count is the live per-index counter of how many times ISM has re-attempted the failed step; retry.count is the policy-defined ceiling. When the live counter reaches or exceeds the ceiling, ISM stops retrying automatically and the index parks indefinitely until you call _plugins/_ism/retry, which resets the live counter to zero.
Does calling `_plugins/_ism/retry` advance the state immediately?
No. It clears the failure flag and re-queues the step, but execution still happens on the next background sweep tick. Expect up to one job_interval of delay before _plugins/_ism/explain shows the state moving on. If you need it faster for one index, an out-of-band trigger is more appropriate than shortening the OpenSearch cluster-wide sweep.
Related
- Implementing retry logic for stuck ISM transitions — automating the
_retryrecovery this page diagnoses by hand. - Handling async ISM policy execution failures — catching the silent scheduler-tick failures behind a stall.
- Phase Transition Logic — correcting a
transitionscondition that never evaluates true.