How to Replace an OpenSearch Index with an Alias Without Application Downtime

How to Replace an OpenSearch Index with an Alias Without Application Downtime

Replacing a concrete OpenSearch index with an identically named alias requires a temporary write block. It does not require application downtime.

A zero-downtime conversion depends on three properties:

  1. The application database remains the source of truth.

  2. Search synchronization runs through a durable queue and can be retried safely.

  3. OpenSearch removes the concrete index and creates the alias in one cluster-state update.

OpenSearch keeps reads available while the index is cloned. Symfony Messenger carries rejected synchronization messages across the write block and runs them again after the alias becomes writable. If search writes happen synchronously inside application requests, the same OpenSearch procedure causes a visible interruption.

These constraints still exist in OpenSearch 3.8 and Symfony 8.1. Current OpenSearch adds require_alias as a preventative control, but version upgrades do not make application configuration and cluster state change atomically.

The OpenSearch operations do not depend on Symfony. Any client can block an index, clone it, and replace its name with an alias. Symfony matters during the write block. In this implementation, application writes continued against the primary database while Messenger retained rejected search updates and retried them after the swap. Another stack can provide the same guarantee with a durable queue; without one, the migration is not zero downtime.

Why convert an index into an alias?

Assume an application stores products in a relational database and projects them into an OpenSearch index:

products

The application reads and writes that name directly. This works until the index must be rebuilt.

A mapping change, analyzer change, or complete reindex cannot always be applied safely in place. Rebuilding products directly either makes search incomplete while documents are repopulated or requires a maintenance window.

An alias separates the stable application name from the replaceable physical index:

products                  alias used by the application
products-2026-01-01       current physical index
products-2026-09-04       next physical index

The next index can be populated in the background. Once it is ready, one alias request moves products from the old physical index to the new one. Reads switch without changing application configuration and without observing a partially populated index.

That is the easy part. The difficult transition is the first one, when products is still a concrete index but must become an alias with the same name.

An index and an alias cannot have the same name

OpenSearch rejects an alias when an index already occupies its name. The OpenSearch 3.8 alias validator reports the reason directly:

an index exists with the same name as the alias

This means the desired final state cannot be created incrementally:

products                  concrete index
products                  alias

One of those resources must disappear before the other can exist.

Deleting the original index and creating the alias in separate requests introduces a more subtle failure. OpenSearch automatically creates missing indexes by default. If a background worker writes to products after the deletion but before the alias is created, OpenSearch can recreate products as a concrete index.

The migration then returns to the exact state it was trying to escape. The write succeeds, but it succeeds against a resource with the wrong identity.

This is why changing application configuration first is unsafe. Configuration may say that products is an alias while the cluster still contains an index—or contains nothing at all. Application configuration and OpenSearch cluster state do not change atomically.

The safe transition has no missing-name window

The existing index must be copied to a new physical name before products can become the alias:

Before:
products                  concrete index

During preparation:
products                  concrete index, temporarily write-blocked
products-2026-01-01       clone

After the swap:
products                  alias
products-2026-01-01       physical write index

The operation has five stages:

  1. Block writes to the existing index.

  2. Clone it to a timestamped physical index.

  3. Verify the clone.

  4. Remove the original index and create the alias in one alias update.

  5. Remove the write block and allow queued synchronization messages to retry.

Reads continue throughout. Writes to the primary application database also continue. Only the derived OpenSearch projection pauses.

Block writes before cloning

OpenSearch requires the clone source to have index.blocks.write enabled:

PUT /products/_settings
Content-Type: application/json

{
  "index.blocks.write": true
}

The block stabilizes the source while OpenSearch creates the target. Search requests continue to work, but indexing, updating, and deleting documents fail until the block is removed or the alias swap completes.

That failure should not be hidden. The Symfony worker must let it propagate so Messenger can retry the message. Treating a rejected OpenSearch write as successfully synchronized would create a permanent gap between the database and the search index.

The write block should exist only for the controlled migration. It is an operational state, not normal index configuration.

Clone the concrete index

Create a physical target whose name can remain behind the future alias:

POST /products/_clone/products-2026-01-01

A clone preserves the source mappings and index data. It is suitable when the immediate objective is to place the existing index behind an alias without rebuilding its schema.

It is not a substitute for reindexing when mappings or analysis settings must change. The first migration establishes the alias boundary. Future schema changes can create and populate a fresh physical index before switching the alias.

Wait until the target has recovered before continuing:

GET /_cluster/health/products-2026-01-01
    ?wait_for_status=yellow
    &timeout=60s

At minimum, compare the source and target document counts and inspect their mappings and settings. Equal counts do not prove semantic equivalence, but unequal counts establish that the migration must not proceed.

The target must also be tested through representative searches. A structurally valid index can still be unusable if aliases, routing assumptions, or application queries do not match its configuration.

Replace the index and add the alias atomically

The critical transition belongs in one _aliases request:

POST /_aliases
Content-Type: application/json

{
  "actions": [
    {
      "remove_index": {
        "index": "products"
      }
    },
    {
      "add": {
        "index": "products-2026-01-01",
        "alias": "products",
        "is_write_index": true
      }
    }
  ]
}

OpenSearch handles remove_index actions before validating alias additions in the same cluster-state update. The current implementation states why: removing indexes first avoids an error when an index is replaced by an alias with the same name.

There is no externally visible state in which products is absent between two requests. A worker sees either the blocked concrete index before the update or the alias after it. It cannot write into the gap and cause automatic index creation because the gap is not published as an intermediate cluster state.

After the swap, inspect the target setting and clear the temporary write block:

PUT /products-2026-01-01/_settings
Content-Type: application/json

{
  "index.blocks.write": false
}

The stable application name now resolves to the physical target:

products -> products-2026-01-01

Workers do not need a new index name. Their retried requests still target products, but that name now resolves through the alias.

Symfony Messenger carries writes across the block

The OpenSearch procedure guarantees continuous reads and an atomic name change. It does not guarantee that every indexing request succeeds on its first attempt.

During the write block, a search synchronization handler may receive a rejection similar to:

cluster_block_exception: index write blocked

If the handler runs synchronously inside an HTTP request, the application either returns an error or must buffer the work elsewhere. There is a real write interruption.

With Messenger, the application transaction can complete against the primary database and dispatch a synchronization message. The worker attempts the OpenSearch update, receives the temporary rejection, and leaves the message available for retry. Once the alias is active and writable, the retry succeeds.

A transport needs a retry window longer than the expected migration:

framework:
    messenger:
        failure_transport: failed

        transports:
            search:
                dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
                retry_strategy:
                    max_retries: 20
                    delay: 1000
                    multiplier: 2
                    max_delay: 30000

The exact values depend on the index size and operational procedure. The relevant property is not the number 20; it is that a normal clone and alias swap finish before temporary failures are exhausted into the failure transport.

Symfony 8.1 also supports explicit recoverable message exceptions and custom retry delays. Those controls make the temporary condition easier to classify, but they do not replace bounded recovery. An accidental write block or failed migration must eventually become visible rather than retry forever.

Retrying is not enough unless synchronization is idempotent

A queue prevents a transient rejection from becoming data loss. It can still apply messages more than once or out of order.

Suppose two changes occur during the block:

Message A: set product 42 price to 20
Message B: set product 42 price to 25

If B succeeds before a delayed retry of A, replaying the payload from A would restore the stale price of 20. The migration remained available but produced the wrong projection.

A safer handler treats the message as a request to synchronize product 42, then reads the current authoritative state from the database when it runs. Both A and B eventually write the current value of 25. Repetition and reordering no longer change the final result.

Other valid designs use monotonic entity versions or OpenSearch external versioning to reject stale updates. The mechanism can vary, but the invariant cannot:

Retrying an older synchronization message must not overwrite newer authoritative state.

Deletes require the same treatment. A retried update must not recreate a product that was deleted later. The handler needs a current-state decision: index the latest representation if it exists and remains searchable; otherwise remove it from the search projection.

Without this property, “the queue will retry it” is not a consistency argument.

Prevent the race from returning with require_alias

After the migration, writes intended for products should require that the target is an alias:

POST /products/_doc/42?require_alias=true
Content-Type: application/json

{
  "name": "Desk lamp",
  "price": 25
}

Bulk requests support the same protection:

POST /_bulk?require_alias=true

OpenSearch also supports _require_alias on individual bulk actions.

When require_alias is true, the request fails if products is missing or resolves directly to an index. OpenSearch cannot silently auto-create a concrete index and report a successful write.

This turns an architectural assumption into a server-enforced condition. A prompt, deployment note, or Symfony configuration can be ignored or applied in the wrong order. OpenSearch itself is the only component that can establish what the name represents at the moment of the write.

The cluster-level action.auto_create_index setting provides another boundary. It can disable automatic creation entirely or deny it for managed names. This is useful when indexes should only be created through migrations or index-management tooling. It is broader than require_alias, so its effect on system indexes and unrelated ingestion paths must be evaluated before changing it globally.

For alias-managed application writes, require_alias is the precise default.

Migration code must distinguish states before changing them

A migration command should not assume that every execution starts from the original state. Deployments fail, operators retry commands, and a process can stop after the alias swap but before local bookkeeping finishes.

Classify the cluster state first:

products is a concrete index
    Migration is required.

products is an alias to products-2026-01-01
    Migration has already completed.

products does not exist
    Abort; do not invent the intended source.

products is an alias to several indexes without a write index
    Abort; the state requires explicit repair.

Before the atomic swap, failure recovery can normally remove the incomplete clone and clear the source write block. After the swap, the old concrete index no longer exists. Recovery should finish validating and unblocking the new target rather than trying to repeat the destructive transition.

A dry-run mode should report the resolved concrete index, proposed target, current blocks, document counts, and exact alias actions. An optional snapshot adds another recovery path, but it does not make an unsafe operation order safe.

The command should also refuse a force option that deletes a conflicting index merely because an alias was expected. The conflict is evidence of an incomplete migration or an automatic-creation race. Deleting it before establishing where its documents came from converts a recoverable state mismatch into potential data loss.

The deployment order is part of the design

A safe rollout is ordered:

  1. Confirm that database writes and search synchronization are decoupled.

  2. Confirm that synchronization handlers are idempotent or version-protected.

  3. Extend retry duration beyond the expected migration window.

  4. Verify the failure transport and recovery commands.

  5. Run a dry run against the current cluster state.

  6. Write-block and clone the concrete index.

  7. Validate the clone.

  8. Atomically replace the concrete index with the alias.

  9. Clear the target write block.

  10. Confirm queued synchronization messages recover.

  11. Require aliases on normal indexing requests.

  12. Enable alias-managed reindexing in application configuration.

“Migrate first, configure second” is the important rule. If alias-aware application behavior is deployed before the alias exists, the application and cluster temporarily disagree about the meaning of products.

Current Symfony and OpenSearch releases do not eliminate this ordering problem. They provide better controls for surviving it and preventing an accidental index from replacing the intended alias.

What zero downtime means here

This migration provides the following properties:

PropertyResult
Application remains availableYes
Search reads remain availableYes
Primary database writes continueYes
Search updates are lostNo, if the queue retains and safely retries them
OpenSearch accepts every write immediatelyNo
Users observe the OpenSearch write blockNo, if search synchronization is asynchronous

Calling this a zero-downtime migration is accurate at the application boundary. Calling it an uninterrupted OpenSearch write migration is not. OpenSearch carries the read traffic through the clone and atomic name change; Messenger carries rejected writes across the block; idempotent synchronization prevents delayed messages from restoring stale data.

Make the wrong state fail

The dangerous outcome is not a failed indexing request. It is a successful request to a resource with the wrong identity.

If products disappears while automatic index creation remains enabled, OpenSearch can create a concrete index even though the application expects an alias. The write returns successfully and conceals the deployment error. require_alias reverses that behavior: the request fails until the cluster contains the resource the application was designed to use.

The same principle governs the migration. While the source is blocked, rejected writes remain visible and recoverable. After the swap, a missing alias produces an error instead of a replacement index. Those failures are deliberate boundaries around a state change that cannot be made atomic across Symfony and OpenSearch.

Sources