Skip to main content
search at depth

Aliases, Reindexing, and Zero-Downtime Migrations

5 min read Chapter 40 of 60

Aliases, Reindexing, and Zero-Downtime Migrations

The documentation platform needs to add a code_language keyword field to all 5 million documents. The new field must be extracted from code blocks in the document body. This requires reindexing every document through a new pipeline. Search must remain available during the entire operation.

Alias Architecture

An alias is a virtual index name that points to one or more concrete indices. Aliases are the indirection layer that makes zero-downtime migrations possible.

The documentation platform uses three aliases per tenant:

AliasPurposePoints To
docs-{tenant}-readAll search queriesAll active indices for the tenant
docs-{tenant}-writeAll index operationsThe current write index only
docs-{tenant}-liveThe production-visible indexThe verified, serving index
// HARDENED: Alias-based index access pattern
// Application code never references concrete index names.

public class AliasManager {

    private final OpenSearchClient client;

    public AliasManager(OpenSearchClient client) {
        this.client = client;
    }

    public String readAlias(String tenantId) {
        return "docs-" + tenantId + "-read";
    }

    public String writeAlias(String tenantId) {
        return "docs-" + tenantId + "-write";
    }

    public void createIndexWithAliases(String tenantId, int version)
            throws IOException {
        String indexName = "docs-" + tenantId + "-v" + version;

        client.indices().create(c -> c
            .index(indexName)
            .aliases(readAlias(tenantId), a -> a.isWriteIndex(false))
            .aliases(writeAlias(tenantId), a -> a.isWriteIndex(true))
        );
    }

    // Atomic alias swap: search transitions from v1 to v2 in a single operation
    public void swapWriteAlias(String tenantId, String oldIndex,
            String newIndex) throws IOException {
        client.indices().updateAliases(ua -> ua
            .actions(a -> a.remove(r -> r
                .index(oldIndex)
                .alias(writeAlias(tenantId))
            ))
            .actions(a -> a.add(ad -> ad
                .index(newIndex)
                .alias(writeAlias(tenantId))
                .isWriteIndex(true)
            ))
        );
    }
}

Blue-Green Reindexing

// HARDENED: Blue-green reindex with verification before cutover

public class BlueGreenReindexer {

    private final OpenSearchClient client;
    private final AliasManager aliasManager;

    public BlueGreenReindexer(OpenSearchClient client,
            AliasManager aliasManager) {
        this.client = client;
        this.aliasManager = aliasManager;
    }

    public void reindexWithNewMapping(String tenantId, int currentVersion,
            int newVersion, String newMappingJson) throws Exception {

        String sourceIndex = "docs-" + tenantId + "-v" + currentVersion;
        String targetIndex = "docs-" + tenantId + "-v" + newVersion;

        // Step 1: Create the target index with new mapping
        client.indices().create(c -> c
            .index(targetIndex)
            .settings(s -> s
                .numberOfShards("2")
                .numberOfReplicas("0")         // No replicas during reindex
                .refreshInterval(t -> t.time("-1"))  // Disable refresh
            )
        );

        // Step 2: Reindex from source to target
        ReindexRequest reindexRequest = ReindexRequest.of(r -> r
            .source(s -> s.index(sourceIndex))
            .dest(d -> d
                .index(targetIndex)
                .pipeline("doc-enrichment-v" + newVersion)
            )
            .waitForCompletion(false)  // Run as task
        );

        // The reindex runs as a background task
        // Monitor via _tasks API
        var taskResponse = client.reindex(reindexRequest);

        // Step 3: Monitor reindex progress
        waitForReindexCompletion(tenantId, sourceIndex, targetIndex);

        // Step 4: Enable refresh and add replicas on the target
        client.indices().refresh(r -> r.index(targetIndex));
        client.indices().putSettings(ps -> ps
            .index(targetIndex)
            .settings(s -> s
                .refreshInterval(t -> t.time("5s"))
                .numberOfReplicas("1")
            )
        );

        // Step 5: Verify document counts match
        long sourceCount = client.count(c -> c.index(sourceIndex)).count();
        long targetCount = client.count(c -> c.index(targetIndex)).count();

        if (sourceCount != targetCount) {
            throw new ReindexException(
                "Document count mismatch: source=" + sourceCount +
                " target=" + targetCount);
        }

        // Step 6: Atomic alias swap
        client.indices().updateAliases(ua -> ua
            .actions(a -> a.remove(r -> r
                .index(sourceIndex)
                .alias(aliasManager.readAlias(tenantId))
            ))
            .actions(a -> a.add(ad -> ad
                .index(targetIndex)
                .alias(aliasManager.readAlias(tenantId))
            ))
            .actions(a -> a.remove(r -> r
                .index(sourceIndex)
                .alias(aliasManager.writeAlias(tenantId))
            ))
            .actions(a -> a.add(ad -> ad
                .index(targetIndex)
                .alias(aliasManager.writeAlias(tenantId))
                .isWriteIndex(true)
            ))
        );
    }

    private void waitForReindexCompletion(String tenantId,
            String sourceIndex, String targetIndex) throws Exception {
        long sourceCount = client.count(c -> c.index(sourceIndex)).count();

        while (true) {
            long targetCount = client.count(c -> c.index(targetIndex)).count();
            double progress = (double) targetCount / sourceCount * 100;

            if (targetCount >= sourceCount) {
                break;
            }

            Thread.sleep(10_000);
        }
    }
}
// FRAGILE: Reindexing without alias swap.
// Requires application downtime to switch the index name in config.
// Any writes during the reindex window go to the old index and are lost.

public void fragileReindex(String tenantId) throws IOException {
    client.reindex(r -> r
        .source(s -> s.index("docs-" + tenantId + "-v1"))
        .dest(d -> d.index("docs-" + tenantId + "-v2"))
    );
    // Now what? Application still points to v1.
    // Must restart application with new config. Documents written to v1
    // during reindex are not in v2.
}

Blue-green reindex showing source index serving reads while target index is populated, followed by atomic alias swap

The diagram shows the blue-green reindex timeline: Phase 1 (source serves all traffic while target populates), Phase 2 (verification: document counts, spot-check queries), Phase 3 (atomic alias swap: read and write aliases move to target), Phase 4 (source retained for rollback, deleted after confidence period).

Handling Writes During Reindex

Documents written during the reindex go to the source index (via the write alias). After the alias swap, these documents exist in the source but not the target. Two strategies handle this:

  1. Dual-write. Write to both source and target during reindex. Complex, error-prone, requires application changes.

  2. Catch-up reindex. After the initial reindex completes, run a second reindex with a timestamp filter for documents modified during the reindex window. Then swap aliases.

// HARDENED: Catch-up reindex for documents written during migration

public void catchUpReindex(String sourceIndex, String targetIndex,
        Instant reindexStartTime) throws IOException {

    client.reindex(r -> r
        .source(s -> s
            .index(sourceIndex)
            .query(q -> q.range(rng -> rng
                .field("indexed_at")
                .gte(JsonData.of(reindexStartTime.toString()))
            ))
        )
        .dest(d -> d.index(targetIndex))
    );
}

The Decision Rule

Never reference concrete index names in application code. All index access goes through aliases. This single constraint makes every future migration a zero-downtime operation.

Verify document counts before swapping aliases. A count mismatch indicates dropped documents during reindex—possibly from mapping conflicts, pipeline errors, or shard failures. Never swap aliases with unverified data.

Retain the source index for at least 48 hours after the alias swap. If the new mapping causes unexpected search quality degradation, swap the aliases back to the source index. Delete the source index only after the confidence period.