Verifying bulk ISM policy attachment at scale
After a bulk attach touches thousands of indices, an HTTP 200 proves the request was accepted — not that every index is actually managed on the policy you intended. This walkthrough builds the verification pass that closes that gap: sweep _plugins/_ism/explain across the whole pattern, classify each index as managed-on-target, on-the-wrong-policy, unmanaged, or failed-to-initialise, then reconcile the stragglers. It is the confirm-and-reconcile half of the pipeline in Bulk Policy Attachment, and the natural next step after Attaching ISM policies to thousands of indices in bulk.
Prerequisites
Verification reads a lot of state and then writes reconcile actions, so confirm both sides of that before running — a read-only account cannot reconcile, and an unfiltered pattern makes the counts meaningless.
Step-by-step procedure
The pass runs in four stages: sweep explain across the pattern, classify every index into one of four buckets, report a coverage ratio against the expected denominator, and reconcile each straggler bucket with the right corrective call.
1. Sweep the explain endpoint across the pattern
GET _plugins/_ism/explain/<pattern> returns one entry per matching index plus a total_managed_indices tally. At scale, ask for the policy id and failure detail so the classify step has everything it needs in one response.
from opensearchpy import OpenSearch
def sweep_explain(client: OpenSearch, pattern: str) -> dict:
# show_policy surfaces the attached policy id; the response also carries
# per-index state and any action failure.
return client.transport.perform_request(
"GET", f"/_plugins/_ism/explain/{pattern}?show_policy=true"
)
{
"logs-2026.06.01-000001": {
"index.plugins.index_state_management.policy_id": "bulk_hot_delete",
"state": { "name": "hot" }, "action": { "failed": false }, "enabled": true
},
"logs-2026.06.14-000003": {
"index.plugins.index_state_management.policy_id": "bulk_hot_delete",
"state": {}, "action": { "failed": true, "name": "rollover" }, "enabled": true
},
"total_managed_indices": 12586
}
Gotcha:
total_managed_indicescounts indices ISM manages, not indices on your policy. An index managed by some other policy still increments it, so never treat that number alone as success — the classify step below keys on thepolicy_idstring.
2. Classify every index into four buckets
Walk the sweep response and sort each index into one of four states. This is the core of verification: a single index can be managed on the target and healthy, managed on the wrong policy, unmanaged entirely, or managed on the target but stuck with a failed action.
def classify(explain: dict, target_policy: str) -> dict:
buckets = {"ok": [], "wrong_policy": [], "unmanaged": [], "failed": []}
for name, meta in explain.items():
if name == "total_managed_indices" or not isinstance(meta, dict):
continue
policy = meta.get("index.plugins.index_state_management.policy_id")
action = meta.get("action") or {}
if policy is None:
buckets["unmanaged"].append(name)
elif policy != target_policy:
buckets["wrong_policy"].append(name)
elif action.get("failed"):
buckets["failed"].append(name)
else:
buckets["ok"].append(name)
return buckets
ok=12401 wrong_policy=3 unmanaged=171 failed=11
Gotcha: an index with
state: {}butpolicy_idset is attached-but-not-yet-initialised — the sweep has not reached it. Treat a small, shrinkingunmanaged/empty-state count as “wait one sweep and re-check,” not a failure. Only a count that stays flat across two sweeps is a real straggler.
3. Report the coverage ratio
Reduce the buckets to a single coverage figure against the expected denominator N from the attach ledger. Coverage is the fraction of expected indices that are managed on the target policy and healthy:
Anything below 1.0 names exactly how many indices still need reconciliation, and the buckets say which kind.
import logging
logger = logging.getLogger("ism.verify")
def report(buckets: dict, expected_total: int) -> float:
ok = len(buckets["ok"])
coverage = ok / expected_total if expected_total else 1.0
logger.info(
"coverage=%.4f ok=%d wrong_policy=%d unmanaged=%d failed=%d",
coverage, ok, len(buckets["wrong_policy"]),
len(buckets["unmanaged"]), len(buckets["failed"]),
)
return coverage
[INFO] coverage=0.9853 ok=12401 wrong_policy=3 unmanaged=171 failed=11
Gotcha: use the expected total from the attach ledger as the denominator, not
len(ok) + len(stragglers)from this sweep. If the attach pattern silently missed a whole day of indices, those never appear in the explain response at all, and a self-referential denominator would hide the miss behind a perfect-looking1.0.
4. Reconcile each straggler bucket
Each bucket has a different corrective call. Unmanaged indices get a fresh add. Wrong-policy indices get change_policy — never add, which refuses a managed index. Failed-action indices get retry, which re-runs the stuck action in place.
def reconcile(client, buckets, target_policy):
if buckets["unmanaged"]:
client.transport.perform_request(
"POST", "/_plugins/_ism/add/" + ",".join(buckets["unmanaged"]),
body={"policy_id": target_policy})
if buckets["wrong_policy"]:
client.transport.perform_request(
"POST", "/_plugins/_ism/change_policy/" + ",".join(buckets["wrong_policy"]),
body={"policy_id": target_policy})
for name in buckets["failed"]:
client.transport.perform_request("POST", f"/_plugins/_ism/retry/{name}")
[INFO] reconcile: re-added 171, changed_policy 3, retried 11
Gotcha: reconcile is itself idempotent — re-running the whole verify-then-reconcile pass converges. But
retryonly helps if the underlying cause of the failed action is gone; retrying arolloverthat failed for a missing write alias just fails again. Diagnose persistent failures through Error Handling & Retries before looping retry blindly.
The verify pass is a classify-and-route loop that either declares full coverage or feeds each straggler bucket to its matching corrective call:
Verification
Two independent cross-checks confirm the verify pass itself is honest — that the denominator is right and no stragglers are hiding behind an initialising sweep.
# 1. Does ISM's managed count reconcile with your expected total plus prior managed?
GET _plugins/_ism/explain/logs-2026.06-*?pretty
# "total_managed_indices": 12587 <- must equal ledger N + previously-managed
# 2. Are any target-policy indices still sitting on a failed action?
GET _plugins/_ism/explain/logs-2026.06-*?pretty | grep -A2 '"failed": true'
# expect no matches once retries have drained
A clean pass shows total_managed_indices equal to the expected denominator plus indices managed before the attach, and zero "failed": true entries under the target policy. If the managed count is short, the attach pattern missed indices that never entered the explain response — go back to the attach step rather than reconciling, because there is nothing in these buckets to reconcile.
Common failures
| Symptom | Root cause | Fix command |
|---|---|---|
| Coverage looks perfect but indices are missing | Denominator taken from the sweep, not the attach ledger | Recompute C against the ledger’s expected N; re-list the pattern with _cat/indices |
Large unmanaged bucket that never shrinks |
Coordinator sweep not initialising attached indices | Confirm plugins.index_state_management.job_interval; re-add the bucket and wait a sweep |
failed bucket refills after every retry |
Underlying action still cannot succeed (missing write alias, blocked tier) | Diagnose via Error Handling & Retries; fix the root cause before POST _plugins/_ism/retry/<idx> |
wrong_policy indices rejected by reconcile |
Used add (refuses managed) instead of change_policy |
POST _plugins/_ism/change_policy/<idx> with the target policy_id |
explain request times out on huge patterns |
Single sweep over tens of thousands of indices | Split the pattern by date sub-range and sweep each; aggregate the buckets client-side |
Frequently asked questions
Why isn't `total_managed_indices` enough to confirm success?
Because it counts every index ISM manages under the pattern, regardless of which policy. An index left on an old policy, or managed by an unrelated one, still increments it. You can hit your expected total_managed_indices while dozens of indices sit on the wrong policy. Verification has to key on the per-index policy_id string, which is exactly what the classify step does.
How do I tell "still initialising" apart from "genuinely stuck"?
Compare two sweeps a sweep-interval apart. An index that is attached but shows an empty state in the first sweep and a concrete state in the second was simply waiting for the coordinator — normal. One that stays state-less or "failed": true across both sweeps is a real straggler and belongs in the reconcile pass. Never reconcile off a single sweep taken seconds after the attach.
Is it safe to run verification repeatedly?
Yes, and you should — the verify-then-reconcile pass is idempotent and convergent. Each run re-classifies from live state, so already-healthy indices fall into ok and are untouched, add skips managed indices, and change_policy/retry are no-ops once their target is correct. Schedule it to run a few times after a large attach so transient stragglers clear themselves before you page anyone.
Related
- Bulk Policy Attachment — the section overview covering both the attach and this verification stage.
- Attaching ISM policies to thousands of indices in bulk — the attach run that produces the ledger this pass verifies against.
- Error Handling & Retries — diagnosing why an index’s ISM action keeps failing before retrying it.