Bulk Policy Attachment

Bulk policy attachment is the problem of binding one Index State Management (ISM) policy to thousands of existing indices at once — the day-two task that appears the moment you introduce a lifecycle policy on an OpenSearch cluster that already carries months of daily log indices. A single POST _plugins/_ism/add/logs-* call looks like the answer, but at fleet scale it hides three failure surfaces: the ISM coordinator has to initialise every newly managed index on its next sweep, so attaching ten thousand indices in one request spikes coordinator load; a naive re-run collides with indices that are already managed and reports partial failures you have to interpret; and “the call returned 200” is not the same as “every index is managed on the right policy.” This page, part of ISM Policy Implementation & Python Automation, shows how to attach a policy across large index sets deterministically — batching the work, throttling to protect the sweep, making re-runs idempotent, and proving attachment through _plugins/_ism/explain rather than trusting a status code.

Attach approaches and their scale profile

There are two primitives for attaching a policy, and the right one depends on how many indices you touch, whether the set is stable, and how much control you need over failure reporting. The table aligns each approach with the index-count band where it stays safe, the pressure it puts on the ISM coordinator, and how it behaves on a second run.

Approach Best index count Coordinator pressure Re-run behaviour Failure visibility
POST _plugins/_ism/add/<pattern> (single wildcard) up to ~500 High — all matches initialise on one sweep Already-managed matches fail in-band One aggregate failed_indices list
Chunked add over explicit index lists thousands Controlled — spread across sweeps Skip-managed pre-filter makes it idempotent Per-batch, per-index
POST _plugins/_ism/change_policy/<pattern> any already-managed set Low — no re-initialise Safe; re-points policy in place Aggregate list
Index-template policy_id (new indices only) n/a for existing None N/A N/A

The wildcard add is the correct tool for a few hundred indices created in the last day or two. Past that, the chunked approach — discover the matching indices, split them into fixed-size batches, and attach each batch with a pause between calls — is what keeps the coordinator’s sweep from stalling and gives you a per-index audit trail. Use change_policy rather than add when the indices are already managed and you only want to move them onto a newer policy version; add refuses an index that already has a policy, whereas change_policy re-points it cleanly. The template policy_id route only ever helps future indices, so it complements a bulk attach but never replaces it.

The batched attach pipeline

Bulk attachment at scale is a pipeline, not a single API call. You discover the target indices, filter out the ones already managed so the run is idempotent, split the remainder into fixed-size batches, attach each batch with a throttle pause and bounded retries, record what each batch returned, and finally sweep _plugins/_ism/explain to confirm every index landed on the expected policy — feeding any stragglers back into a reconcile pass. The diagram below shows that flow and where the two verification gates sit.

Batched ISM policy attachment pipeline with an idempotency filter and an explain-based verification gate Flow diagram. Discovery lists indices matching a pattern. An idempotency filter removes indices that are already managed. The remainder is chunked into fixed-size batches. Each batch is attached with a POST add call guarded by retry and backoff, followed by a throttle pause and a result record. After every batch completes, an explain sweep verifies attachment: indices that are managed on the expected policy are complete, and unmanaged or wrong-policy stragglers loop back into a reconcile pass that re-attaches or changes policy. Discover list pattern Filter managed idempotency Chunk batches size = s for each batch POST _ism/add retry + backoff Throttle pause protect sweep Record result Explain sweep verify state + policy Managed on expected policy Reconcile stragglers re-add / change_policy

The two gates on the right of that diagram are what separate a bulk attach from a bulk hope. The idempotency filter early in the pipeline lets you re-run the whole job after a partial failure without tripping over already-managed indices, and the explain sweep at the end is the only authority on whether the job actually succeeded. The deep procedural build of the attach side lives in Attaching ISM policies to thousands of indices in bulk; the verification and reconcile side is covered in Verifying bulk ISM policy attachment at scale.

1. Create the policy before attaching anything

An add call fails for every index if the policy id does not exist, so the policy is always the first object to deploy. Keep the policy body itself minimal here — this page is about attachment, not lifecycle design — but note the policy_id you PUT, because both the add call and the explain verification key on it.

HTTP
PUT _plugins/_ism/policies/bulk_hot_delete
{
  "policy": {
    "description": "Baseline lifecycle attached in bulk to existing logs indices",
    "default_state": "hot",
    "states": [
      {
        "name": "hot",
        "actions": [ { "rollover": { "min_index_age": "1d", "min_primary_shard_size": "50gb" } } ],
        "transitions": [ { "state_name": "delete", "conditions": { "min_index_age": "30d" } } ]
      },
      { "name": "delete", "actions": [ { "delete": {} } ] }
    ]
  }
}

2. Pattern add versus per-index add

POST _plugins/_ism/add/<index> attaches the named policy to whatever the target resolves to. The target can be a single concrete index, a comma-separated list, or a wildcard pattern. The body carries the policy_id.

HTTP
# Wildcard add: every current match initialises on the next coordinator sweep
POST _plugins/_ism/add/logs-2026.06-*
{ "policy_id": "bulk_hot_delete" }

The response reports how many indices were updated and which, if any, failed:

JSON
{
  "updated_indices": 187,
  "failures": false,
  "failed_indices": []
}

A wildcard is convenient but blunt: every match is scheduled to initialise on the same sweep, and the whole call is one transaction from your side — you get a single aggregate result with no control over ordering or pacing. For a few hundred indices that is fine. For thousands, prefer building explicit batches of concrete index names so you can pace and audit them, which is what step 3 sets up.

3. Batching over thousands of indices

Split the discovered index list into fixed-size batches and attach one batch per call. Given N target indices and a batch size s, the number of attach calls is

B=NsB = \left\lceil \frac{N}{s} \right\rceil

so 12,400 indices at s = 250 is 50 calls rather than one 12,400-index request. A batch size in the 100–500 range is the sweet spot: small enough that each call’s initialisation cost stays bounded, large enough that request overhead does not dominate. The add API accepts a comma-separated list, so a batch maps directly to one call.

HTTP
# One batch of concrete index names (comma-separated in the path)
POST _plugins/_ism/add/logs-2026.06.01-000001,logs-2026.06.01-000002,logs-2026.06.02-000001
{ "policy_id": "bulk_hot_delete" }

Batching also localises failure. If one batch throws a 429 under load, you retry that batch alone rather than replaying the entire fleet, and your result record tells you exactly which 250 indices to re-check.

4. Throttling and sweep pressure

Attaching a policy writes a managed-index config document; the ISM coordinator picks those up on its sweep and initialises each one, moving it into its default_state. The sweep interval is governed by plugins.index_state_management.job_interval (default 5 minutes). Push thousands of new config documents in seconds and the next sweep tries to initialise all of them at once, competing with real ISM work — rollovers and transitions — already scheduled. The fix is a deliberate pause between batches so initialisation spreads across several sweeps instead of landing on one.

HTTP
# Widen the sweep window while a large bulk attach is in flight, then restore it
PUT _cluster/settings
{
  "transient": {
    "plugins.index_state_management.job_interval": 10,
    "plugins.index_state_management.jitter": 0.6
  }
}

Raising job_interval during the attach lengthens the window each sweep has to absorb newly managed indices; the jitter fraction spreads job execution so managed indices do not all wake on the same tick. Keep these transient so an OpenSearch cluster restart restores your steady-state values. Pair the server-side widening with a client-side pause between batches — the two together are what keep a bulk attach from degrading live lifecycle work.

5. Idempotent re-runs

add refuses an index that already has a policy: that index appears in failed_indices with a message noting it is already managed. This is exactly what you want for safety, but it means a blind re-run of a partially-completed job reports a wall of “failures” that are really no-ops. Make the run idempotent by discovering current state first and attaching only the unmanaged indices. GET _plugins/_ism/explain/<pattern> returns the policy_id (or none) for every match.

HTTP
# Which of these indices are already managed, and on what policy?
GET _plugins/_ism/explain/logs-2026.06-*
JSON
{
  "logs-2026.06.01-000001": { "index.plugins.index_state_management.policy_id": "bulk_hot_delete", "enabled": true },
  "logs-2026.06.02-000001": { "index.plugins.index_state_management.policy_id": null, "enabled": null },
  "total_managed_indices": 1
}

An index whose policy_id is null is unmanaged and belongs in the next attach batch; one already on bulk_hot_delete is done; one on a different policy is a reconcile case for change_policy, not add. Filtering on this before every batch turns a re-run into a safe convergence step — it only ever acts on what still needs acting on.

Production automation

The following opensearch-py manager wraps the whole pipeline: discover, filter for idempotency, chunk, attach with bounded retries and structured logging, and record every batch’s outcome. Scope its service account narrowly first — a bulk-attach identity needs cluster:admin/opendistro/ism/managedindex/add and read on the target patterns, and nothing else. Endpoint-level scoping is covered in Security & Access Boundaries; the packaging and scheduling of a manager like this is the subject of Python Orchestration Frameworks.

Python
import logging
import time
from itertools import islice
from opensearchpy import OpenSearch, exceptions

logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
logger = logging.getLogger("ism.bulk_attach")


def chunked(seq, size):
    """Yield successive fixed-size batches from a list."""
    it = iter(seq)
    while batch := list(islice(it, size)):
        yield batch


class BulkPolicyAttacher:
    def __init__(self, client: OpenSearch, policy_id: str):
        self.client = client
        self.policy_id = policy_id

    def discover_unmanaged(self, pattern: str) -> list[str]:
        """List indices matching pattern that are not yet on this policy (idempotency)."""
        explain = self.client.transport.perform_request(
            "GET", f"/_plugins/_ism/explain/{pattern}"
        )
        unmanaged = []
        for name, meta in explain.items():
            if not isinstance(meta, dict) or name == "total_managed_indices":
                continue
            current = meta.get("index.plugins.index_state_management.policy_id")
            if current != self.policy_id:
                unmanaged.append(name)
        logger.info("discover: %d index(es) need attach on %r", len(unmanaged), pattern)
        return unmanaged

    def attach_batch(self, indices: list[str], max_retries: int = 4) -> dict:
        """Attach one batch with exponential backoff; return the API result."""
        target = ",".join(indices)
        for attempt in range(max_retries):
            try:
                result = self.client.transport.perform_request(
                    "POST", f"/_plugins/_ism/add/{target}",
                    body={"policy_id": self.policy_id},
                )
                logger.info("batch ok: updated=%s failed=%s",
                            result.get("updated_indices"), result.get("failed_indices"))
                return result
            except exceptions.TransportError as exc:
                status = getattr(exc, "status_code", None)
                if status == 429 and attempt < max_retries - 1:
                    delay = 2.0 * (2 ** attempt)
                    logger.warning("429 on batch; backing off %.1fs (attempt %d)", delay, attempt + 1)
                    time.sleep(delay)
                    continue
                logger.error("batch failed permanently: %s", exc)
                raise
        return {}

    def run(self, pattern: str, batch_size: int = 250, pause_s: float = 3.0) -> list[dict]:
        """Full pipeline: discover -> chunk -> attach with throttle -> record."""
        targets = self.discover_unmanaged(pattern)
        results = []
        for i, batch in enumerate(chunked(targets, batch_size), start=1):
            logger.info("batch %d: attaching %d index(es)", i, len(batch))
            results.append(self.attach_batch(batch))
            time.sleep(pause_s)  # client-side throttle protects the coordinator sweep
        logger.info("attach complete: %d batch(es), %d index(es)", len(results), len(targets))
        return results


if __name__ == "__main__":
    client = OpenSearch(
        hosts=[{"host": "localhost", "port": 9200}],
        http_auth=("admin", "admin"),
        use_ssl=True,
        verify_certs=True,
    )
    BulkPolicyAttacher(client, "bulk_hot_delete").run("logs-2026.06-*", batch_size=250, pause_s=3.0)

The manager never attaches an index it can already see is on the target policy, retries only on 429 (backpressure, safe to replay) and re-raises everything else, and records each batch result so a downstream verification pass has something to reconcile against. It is safe to run twice: the second run discovers a much shorter unmanaged list and converges on zero.

Operational guardrails

Bulk attachment is safe only when its pacing leaves the coordinator enough headroom to initialise managed indices without starving live lifecycle work. The settings below are the knobs that matter, with values tuned for a fleet in the low tens of thousands of indices.

Setting / knob Recommended value Purpose
Client batch size s 100500 Bound per-call initialisation cost and localise failures
Client pause between batches 25s Spread config-doc creation across sweeps
plugins.index_state_management.job_interval 10m during attach Widen the window each sweep has to absorb new managed indices
plugins.index_state_management.jitter 0.6 Desynchronise managed-index wake-ups
Retry policy (client) retry 429 only, exponential backoff Absorb backpressure without replaying real errors
Concurrency 1 bulk-attach worker per cluster Avoid stacking initialisation storms

Keep concurrency at a single bulk-attach worker per cluster. Running several in parallel multiplies the config-doc write rate and defeats the throttle — the coordinator sees one combined storm regardless of how many workers produced it. Restore job_interval and jitter to their steady-state values once the explain sweep confirms full attachment.

Troubleshooting

Failure mode Diagnosis command Fix command
add reports indices in failed_indices as already managed GET _plugins/_ism/explain/<pattern> Filter to policy_id: null before attaching; use POST _plugins/_ism/change_policy/<idx> to move managed indices
Whole batch returns 429 too_many_requests GET _nodes/stats/thread_pool (check write/management queues) Increase client pause, lower batch size, retry the failed batch only
Attached indices never leave null / uninitialised state GET _plugins/_ism/explain/<idx> (no state block) Confirm sweep is running; PUT _cluster/settings restore a sane plugins.index_state_management.job_interval
add fails for every index with policy-not-found GET _plugins/_ism/policies/<policy_id> PUT _plugins/_ism/policies/<policy_id> the policy first, then re-run the attach
Coordinator lag: rollovers on other indices stall during attach GET _plugins/_ism/explain/<hot-alias> (action age climbing) Pause the attach, widen job_interval, let the coordinator drain, then resume with a larger pause

A batch that fails wholesale on 429 is backpressure, not a broken payload — the same batch usually succeeds after a longer pause, which is why the automation retries only that status. An index stuck uninitialised after attachment almost always means the sweep is not keeping up; confirm job_interval is a value the coordinator can actually service before assuming the attach itself failed.

Frequently asked questions

Does re-running a bulk attach double-attach or corrupt already-managed indices?

No. add refuses any index that already has a policy — it lands in failed_indices with an “already managed” message and nothing changes. The risk is not corruption but noise: a blind re-run looks like a mass failure. Filter on _plugins/_ism/explain first so each run only attaches genuinely unmanaged indices and converges cleanly toward zero.

Should I use a single wildcard `add` or explicit batches?

Use a single wildcard for a few hundred indices created in the last day or two — it is one call and the coordinator absorbs the initialisation easily. Move to explicit fixed-size batches once you are into the thousands: batching bounds per-sweep initialisation cost, lets you throttle between calls, and gives you a per-batch audit trail so a 429 costs you one batch to retry, not the whole fleet.

Why did my attach return 200 but some indices are still unmanaged?

A 200 with a non-empty failed_indices list means the API accepted the request but rejected some targets — commonly because they were already managed, or closed, or matched by a pattern that also caught system indices. The status code covers the transport, not per-index success. Only an _plugins/_ism/explain sweep confirms actual state, which is why verification is a separate, mandatory stage.

How do I move thousands of managed indices onto a newer policy version?

Do not use add — it refuses managed indices. Use POST _plugins/_ism/change_policy/<pattern> with the new policy_id; it re-points the policy in place and lets you specify the state to resume from. Batch and throttle it exactly like an add run, and verify with explain afterward.

Up: ISM Policy Implementation & Python Automation