Index Lifecycle Basics

Index lifecycle basics are the rules that decide when an OpenSearch index rolls over, migrates to cheaper hardware, sheds replicas, and is finally deleted — expressed as a declarative Index State Management (ISM) policy OpenSearch runs on its own. In production telemetry and log workloads, hand-rolled rotation via external cron introduces configuration drift, uneven shard distribution, and unbounded storage growth: one missed job and a hot node crosses its flood-stage watermark and locks every index on it read-only. A policy replaces that fragility with a finite state machine the engine evaluates on a fixed interval, executing deterministic transitions against measurable thresholds. This model is the timing layer that the rest of OpenSearch ISM Architecture & Fundamentals builds on — it is what decides when an index moves, while node roles and routing decide where it lands.

ISM job-scheduler evaluation loop On every job_interval tick the elected coordinator reads each managed index's current state and live metadata, then evaluates the state's transition conditions. If a condition is not met the index stays in its current state. If it is met the coordinator runs the destination state's actions in order — rollover, force_merge, allocation, delete — and any action failure leaves the index in place to retry on the next poll; on success the index advances and its new routing attribute is stamped. Both outcomes return to a wait, and the loop repeats every job_interval. Coordinator poll cycle every job_interval · default 5m + jitter Read current state + transitions against live index metadata (_stats, age) transition condition met? no Index stays in current state yes Run entering actions, in order rollover · force_merge · allocation · delete any action fails → stays, retries next poll Advance to destination state stamp new routing attribute wait job_interval, then repeat

How a lifecycle phase maps to cluster hardware

A lifecycle policy only makes economic sense when each phase lands on hardware matched to that phase’s access pattern. The phase names in the policy (hot, warm, cold, delete) are arbitrary labels; the routing attribute each phase writes is what actually binds it to a hardware pool. Declaring and verifying those attributes is the job of Node Role Allocation, and sizing the pools so a migration wave never overruns the next tier is covered in Hot-Warm-Cold Tier Design. The table below is the reference every template and allocation action in this guide points back to.

Lifecycle phase Storage profile vCPU : RAM ratio Routing attribute Primary workload
Hot Local NVMe SSD 1 : 4 (compute-heavy) node.attr.data: hot Active ingest, rollover, real-time search
Warm SATA/SAS SSD 1 : 6 node.attr.data: warm Recent history, force-merged reads, few writes
Cold High-density HDD 1 : 8 (storage-heavy) node.attr.data: cold Compliance retention, rare queries
Delete — (no allocation) Terminal state: index removed, aliases freed

The delete phase has no hardware because it holds no data — it exists only to run the delete action once the retention threshold is met. Everything upstream of it is a trade of query latency for bytes-per-dollar, which is why the routing attribute, not the phase name, is the load-bearing part of the policy.

The policy state machine

An ISM policy is a JSON document that defines a finite state machine for one index (or index alias). Each policy is a set of states; each state carries actions it runs on entry and transitions it evaluates on every poll. The coordinator re-evaluates every managed index at the interval set by plugins.index_state_management.job_interval (default 5 minutes). When a transition’s conditions evaluate true, OpenSearch runs the destination state’s actions and moves the index there.

The telemetry-lifecycle policy state machine A managed index starts in hot as the default state and runs the rollover action at one day, forty-five gigabytes, or seventy-five million documents. At an age of three days it transitions to warm, which drops to one replica and force-merges to a single segment before requiring the warm tier. At fourteen days it transitions to cold, which drops to zero replicas and requires the cold tier. At thirty days it transitions to delete, the terminal state that removes the index and frees its aliases. HOT default_state action: rollover 1d · 45gb · 75M docs WARM replica → 1 force_merge → 1 seg require.data: warm COLD replica → 0 require.data: cold rare-query tier DELETE action: delete index removed aliases freed age ≥ 3d age ≥ 14d age ≥ 30d Each state runs its actions on entry; transition conditions are re-checked every poll (job_interval).

Three architectural properties fall out of this model and shape every policy you write:

  • Deterministic, at-least-once execution. Actions run in listed order. If an action fails — say force_merge cannot proceed because the node is above watermark.high — the index stays in its current state and the step retries on the next poll. Nothing is skipped; a phase is never half-applied.
  • Stateless evaluation. Conditions are checked against live index metadata (_stats, _cat/indices, index.creation_date). ISM keeps no external bookkeeping, so a coordinator failover or cluster restart resumes cleanly from the index’s own state.
  • Coordinator-scheduled fan-out. One elected coordinator node owns the execution queue and spreads expensive actions (shrink, force_merge, snapshot) across data nodes to avoid hot-spotting a single node during a migration wave.

Because evaluation is periodic rather than continuous, an index can overshoot a threshold before the transition fires. The worst-case overshoot is bounded by the poll interval plus the action’s own runtime:

tovershootIjob+tactiont_{\text{overshoot}} \le I_{\text{job}} + t_{\text{action}}

where IjobI_{\text{job}} is job_interval and tactiont_{\text{action}} is how long the entering action takes. Size rollover thresholds with that slack in mind — a min_primary_shard_size of 50 GB on a 5-minute interval can realistically peak higher before the roll completes.

Step-by-step: building a lifecycle policy

The four steps below stand up a complete tiered-retention policy for a telemetry-logs-* index set: node attributes so phases have somewhere to route, a template so new indices start hot under management, the policy itself, then verification of both the declared state and the physical placement.

1. Node configuration

Each phase’s allocation action targets a routing attribute, so that attribute must exist on the right nodes first. Declare it in opensearch.yml; the value is the exact string the policy references — a case or whitespace mismatch yields a shard that can never route to the tier.

YAML
# opensearch.yml — value matches the node's physical tier
node.name: os-data-hot-01
node.roles: [ data, ingest ]
node.attr.data: hot          # hot | warm | cold on their respective pools

2. Index template

Attach the policy at the template level so every new rollover index inherits it automatically — never rely on a human running the add API per index. The template also seeds the rollover alias the hot state needs.

HTTP
PUT _index_template/telemetry-logs
{
  "index_patterns": ["telemetry-logs-*"],
  "template": {
    "settings": {
      "plugins.index_state_management.policy_id": "telemetry-lifecycle",
      "plugins.index_state_management.rollover_alias": "telemetry-logs",
      "index.routing.allocation.require.data": "hot",
      "number_of_shards": 3,
      "number_of_replicas": 1
    }
  }
}

3. Policy JSON

This policy rolls over in the hot phase, force-merges and reduces replicas in warm, drops to zero replicas in cold, and hard-deletes at 30 days. The transition conditions time each move; the allocation actions bind each phase to the attributes from step 1. The exact ordering of force_merge before allocation in warm is deliberate — it lets one controlled relocation carry both the merge and the routing change, a pattern detailed in Data Tier Routing Patterns.

HTTP
PUT _plugins/_ism/policies/telemetry-lifecycle
{
  "policy": {
    "description": "Automated tiered retention for high-volume telemetry indices",
    "default_state": "hot",
    "states": [
      {
        "name": "hot",
        "actions": [
          {
            "rollover": {
              "min_index_age": "1d",
              "min_primary_shard_size": "45gb",
              "min_doc_count": 75000000
            }
          }
        ],
        "transitions": [
          { "state_name": "warm", "conditions": { "min_index_age": "3d" } }
        ]
      },
      {
        "name": "warm",
        "actions": [
          { "replica_count": { "number_of_replicas": 1 } },
          { "force_merge": { "max_num_segments": 1 } },
          { "allocation": { "require": { "data": "warm" } } }
        ],
        "transitions": [
          { "state_name": "cold", "conditions": { "min_index_age": "14d" } }
        ]
      },
      {
        "name": "cold",
        "actions": [
          { "replica_count": { "number_of_replicas": 0 } },
          { "allocation": { "require": { "data": "cold" } } }
        ],
        "transitions": [
          { "state_name": "delete", "conditions": { "min_index_age": "30d" } }
        ]
      },
      {
        "name": "delete",
        "actions": [ { "delete": {} } ]
      }
    ]
  }
}

For indices created before the template existed, attach the policy directly with the add API scoped to a pattern:

HTTP
POST _plugins/_ism/add/telemetry-logs-*
{
  "policy_id": "telemetry-lifecycle"
}

4. Verification

Confirm both that ISM picked the index up and that its declared routing matches its physical placement. The explain API reports the managed state; _cat/shards confirms the shards actually moved.

Shell
# Managed state, current phase, and any action failure
curl -s "https://<cluster>:9200/_plugins/_ism/explain/telemetry-logs-2026.07.04?pretty"

# Physical placement — the node column must sit on the phase's tier
curl -s "https://<cluster>:9200/_cat/shards/telemetry-logs-2026.07.04?v&h=index,shard,prirep,state,node"

A healthy index reports a state matching its age, action.failed: false, and shards resident on nodes carrying the matching node.attr.data value.

Production automation with Python

Attachment and drift correction belong in CI/CD, not in a runbook. The handler below applies a policy to a pattern with bounded retries, injects credentials from the environment, and verifies attachment by parsing the explain response — skipping the total_managed_indices integer summary that sits alongside the per-index keys. It exits non-zero on failure so a pipeline step fails loudly rather than silently leaving indices unmanaged.

Python
import os
import time
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry


class ISMManager:
    def __init__(self, host: str, policy_id: str, index_pattern: str):
        self.base_url = host.rstrip("/")
        self.policy_id = policy_id
        self.index_pattern = index_pattern
        self.session = requests.Session()
        self.session.auth = (os.getenv("OPENSEARCH_USER"), os.getenv("OPENSEARCH_PASS"))

        retry_strategy = Retry(
            total=3, backoff_factor=1, status_forcelist=[429, 500, 502, 503, 504]
        )
        self.session.mount("https://", HTTPAdapter(max_retries=retry_strategy))

    def attach_policy(self) -> dict:
        endpoint = f"{self.base_url}/_plugins/_ism/add/{self.index_pattern}"
        response = self.session.post(endpoint, json={"policy_id": self.policy_id})
        response.raise_for_status()
        return response.json()

    def verify_attachment(self) -> bool:
        """True when at least one managed index carries the expected policy."""
        endpoint = f"{self.base_url}/_plugins/_ism/explain/{self.index_pattern}"
        response = self.session.get(endpoint)
        response.raise_for_status()

        # The explain response keys index names at the top level;
        # `total_managed_indices` is an integer summary — skip it.
        for _idx, details in response.json().items():
            if isinstance(details, dict) and details.get("policy_id") == self.policy_id:
                return True
        return False


if __name__ == "__main__":
    manager = ISMManager(
        host=os.getenv("OPENSEARCH_ENDPOINT", "https://localhost:9200"),
        policy_id="telemetry-lifecycle",
        index_pattern="telemetry-logs-*",
    )

    try:
        manager.attach_policy()
        time.sleep(2)  # allow cluster-state propagation before verifying
        if manager.verify_attachment():
            print("Policy attached and verified.")
        else:
            raise SystemExit("Attachment reported success but no managed index carries the policy.")
    except requests.exceptions.RequestException as exc:
        raise SystemExit(f"ISM operation failed: {exc}")

Operational guardrails

Two policies with identical states behave very differently under load depending on cluster-level settings. Tune the poll cadence and the disk watermarks together — an aggressive job_interval fires transitions faster but adds coordinator overhead, while watermarks decide whether a migration wave completes or wedges an index read-only. Aligning these with the routing attributes above is what keeps Fallback Routing Strategies from having to catch a stalled transition in the first place.

Setting Recommended Why it matters
plugins.index_state_management.job_interval 5m (1m for fast rollover) Bounds transition overshoot; lower values add coordinator load
plugins.index_state_management.jitter 0.6 Spreads evaluation so all indices do not transition on the same tick
cluster.routing.allocation.disk.watermark.high 88% Above this, allocation/force_merge actions block until space frees
cluster.routing.allocation.disk.watermark.flood_stage 93% Crossing it locks indices read-only — a stuck cold migration is the usual cause
index.priority higher on hot indices Recovers active-ingest indices first after a restart

Give every action a retry block instead of relying on the default single attempt, so a transient watermark breach or a busy node does not strand a phase:

JSON
{
  "force_merge": { "max_num_segments": 1 },
  "retry": { "count": 3, "backoff": "exponential", "delay": "10m" }
}

Cross-cluster replication and tier routing alignment

When ISM runs alongside Cross-Cluster Replication (CCR), policy execution must respect the replication topology. Follower indices inherit the leader’s policy by default but operate under a write block, so any action that writes — rollover, force_merge, shrink — will fail on a follower. Attach a separate, read-optimized policy to followers via POST _plugins/_ism/add/<follower_index> that carries only tier-transition and replica actions; let rollover happen on the leader and propagate through replication.

Routing during warm and cold transitions depends on node attributes existing on the follower cluster too — a follower that lacks a data: cold node will strand the transition, which is exactly the shard-unassignment class that Node Role Allocation diagnoses. Finally, the _plugins/_ism/* endpoints are privileged: a service account that can rewrite a policy can delete production data on a schedule, so scope those endpoints to platform-engineering roles as described in Security & Access Boundaries.

Troubleshooting

Most lifecycle incidents present the same way — an index that will not leave its current phase. Diagnose with the explain API first; it names the failing step and its retry count. The pairs below cover the failure modes that account for the bulk of stuck transitions.

Transition never fires. The index sits in hot well past its min_index_age.

Shell
curl -s ".../_plugins/_ism/explain/<index>?pretty"        # inspect: is a policy even attached?
POST _plugins/_ism/add/<index> {"policy_id":"telemetry-lifecycle"}   # attach if policy_id is null

Action stuck on force_merge. The step retries forever and never advances.

Shell
curl -s ".../_cat/allocation?v"                            # check the node is below watermark.high
curl -s -X POST ".../_plugins/_ism/retry/<index>"          # re-drive the step once space is freed

Shards unassigned after a tier move. The transition ran but shards went UNASSIGNED.

Shell
curl -s -X POST ".../_cluster/allocation/explain" -H 'Content-Type: application/json' \
  -d '{"index":"<index>","shard":0,"primary":true}'        # reveals the decider that rejected placement
# Fix: add a node carrying the target node.attr.data value, or correct the allocation attribute

Policy detached after a template update. New indices are created unmanaged.

Shell
curl -s ".../_plugins/_ism/explain/<pattern>*" | grep -c null   # count unmanaged matches
# Fix: ensure the template still sets plugins.index_state_management.policy_id, then re-add by pattern

Index locked read-only. Writes fail with FORBIDDEN/12/index read-only.

Shell
curl -s ".../_cat/nodes?v&h=name,disk.used_percent"        # find the node past flood_stage
curl -s -X PUT ".../<index>/_settings" -d '{"index.blocks.read_only_allow_delete":null}'  # clear after freeing disk

Operational validation and execution monitoring

Beyond attachment, treat the explain API as the source of truth for lifecycle health:

Shell
GET _plugins/_ism/explain/telemetry-logs-2026.07.04

The response carries the current state, the last transition timestamp, and any action failure. For alerting, parse action.failed and fire a webhook when step.status stays failed across consecutive poll cycles rather than on a single miss — a one-off failure is usually a transient watermark breach that self-heals on retry. Pair that with index-template versioning so an OpenSearch upgrade cannot silently detach policies mid-flight. The end-to-end tuning, capacity, and recovery playbook lives in Best practices for OpenSearch index lifecycle management.

For the full setting reference and endpoint semantics, consult the official OpenSearch Index State Management documentation, and standardize policy-payload construction with Python’s native json module.

Frequently asked questions

How often does ISM actually evaluate a policy?

Every plugins.index_state_management.job_interval (default 5 minutes), the coordinator re-evaluates each managed index’s current-state transition conditions. An index can therefore overshoot a threshold by up to one interval plus the entering action’s runtime before it moves, so size rollover thresholds with that slack in mind rather than at the exact tier boundary.

What happens to an index if an action fails?

The index stays in its current state and the failed step retries on the next poll — nothing is skipped and no later action in the state runs until the failing one succeeds. Add an explicit retry block with exponential backoff so a transient cause (a node briefly above watermark.high, for example) does not need manual intervention. Use POST _plugins/_ism/retry/<index> to re-drive a step immediately once you have cleared the root cause.

Should I attach policies to indices or to templates?

Attach at the template level via plugins.index_state_management.policy_id so every new rollover index is managed from creation. Use the add API only to bring pre-existing indices under management or to correct drift. Relying on a human to run add per index is the most common way indices end up silently unmanaged.

Can the same policy manage cross-cluster replication followers?

Not the write-heavy one. Followers operate under a write block, so rollover, force_merge, and shrink fail on them. Attach a separate read-optimized policy to followers with only tier-transition and replica actions, and let rollover happen on the leader and propagate through replication.

Why did my index get locked read-only during a transition?

A cold or warm migration filled a target node past watermark.flood_stage (default 93%), which trips the read-only-allow-delete block. Free disk or add tier capacity, then clear index.blocks.read_only_allow_delete. Lowering the high watermark and sizing the target tier with headroom prevents the recurrence.

Up: OpenSearch ISM Architecture & Fundamentals