Aliases, Reindexing, and Zero-Downtime Migrations
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:
| Alias | Purpose | Points To |
|---|---|---|
docs-{tenant}-read | All search queries | All active indices for the tenant |
docs-{tenant}-write | All index operations | The current write index only |
docs-{tenant}-live | The production-visible index | The 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.
}
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:
-
Dual-write. Write to both source and target during reindex. Complex, error-prone, requires application changes.
-
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.