Skip to content

Commit a84ad3f

Browse files
committed
Fix missing search aliases after reindex by deleting the concrete index atomically within the alias swap (#28667)
(cherry picked from commit 70cad10)
1 parent d7e2d3d commit a84ad3f

7 files changed

Lines changed: 357 additions & 176 deletions

File tree

openmetadata-service/src/main/java/org/openmetadata/service/search/DefaultRecreateHandler.java

Lines changed: 110 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -86,11 +86,8 @@ public ReindexContext reCreateIndexes(Set<String> entities) {
8686
public void finalizeReindex(EntityReindexContext context, boolean reindexSuccess) {
8787
String entityType = context.getEntityType();
8888
String canonicalIndex = context.getCanonicalIndex();
89-
String activeIndex = context.getActiveIndex();
9089
String stagedIndex = context.getStagedIndex();
91-
Set<String> existingAliases = context.getExistingAliases();
92-
String canonicalAlias = context.getCanonicalAliases();
93-
Set<String> parentAliases = context.getParentAliases();
90+
Set<String> aliasesFromMapping = context.getExistingAliases();
9491

9592
SearchRepository searchRepository = Entity.getSearchRepository();
9693
SearchClient searchClient = searchRepository.getSearchClient();
@@ -147,21 +144,20 @@ public void finalizeReindex(EntityReindexContext context, boolean reindexSuccess
147144
// is strictly worse than the writes going back to the canonical
148145
// alias target. Operators need to retry the reindex either way.
149146
try {
147+
// The alias set was derived from indexMapping.json at recreate time via
148+
// getAliasesFromMapping; finalize just attaches that captured set. Nothing is read from the
149+
// live cluster, so the set is deterministic and matches the distributed promotion path.
150150
Set<String> aliasesToAttach = new HashSet<>();
151-
152-
existingAliases.stream()
153-
.filter(alias -> alias != null && !alias.isBlank())
154-
.forEach(aliasesToAttach::add);
155-
156-
if (!nullOrEmpty(canonicalAlias)) {
157-
aliasesToAttach.add(canonicalAlias);
151+
if (aliasesFromMapping != null) {
152+
aliasesFromMapping.stream()
153+
.filter(alias -> alias != null && !alias.isBlank())
154+
.forEach(aliasesToAttach::add);
158155
}
159156

160-
parentAliases.stream()
161-
.filter(alias -> alias != null && !alias.isBlank())
162-
.forEach(aliasesToAttach::add);
163-
164-
aliasesToAttach.removeIf(alias -> alias == null || alias.isBlank());
157+
if (aliasesToAttach.isEmpty()) {
158+
abortPromotionWithoutAliases(entityType, stagedIndex);
159+
return;
160+
}
165161

166162
Set<String> allEntityIndices = searchClient.listIndicesByPrefix(canonicalIndex);
167163
Set<String> oldIndicesToDelete = new HashSet<>();
@@ -171,44 +167,26 @@ public void finalizeReindex(EntityReindexContext context, boolean reindexSuccess
171167
}
172168
}
173169

174-
// After the first reindex, the canonical name is an alias on the previous staged, not a
175-
// concrete index. OpenSearch's listIndicesByPrefix returns that alias name as one of its
176-
// result keys, which then drives a delete-by-name attempt that fails with
177-
// "matches an alias, specify the corresponding concrete indices" and burns ~31s of
178-
// exponential backoff per entity (1+2+4+8+16s before giving up). With 60 entity types
179-
// a full reindex wastes ~30 minutes in cleanup. Drop the alias name from the cleanup set
180-
// when it is currently an alias — it does not need to be deleted; the swap moves the
181-
// alias atomically and the underlying old concrete is in oldIndicesToDelete already.
182-
if (!searchClient.getIndicesByAlias(canonicalIndex).isEmpty()) {
183-
oldIndicesToDelete.remove(canonicalIndex);
184-
}
170+
Set<String> concreteToRemove =
171+
resolveCanonicalRemoval(searchClient, canonicalIndex, oldIndicesToDelete);
185172

186173
LOG.debug(
187-
"finalizeReindex entity '{}': aliases={}, oldIndices={}, stagedIndex={}",
174+
"finalizeReindex entity '{}': aliases={}, oldIndices={}, stagedIndex={}, "
175+
+ "concreteToRemove={}",
188176
entityType,
189177
aliasesToAttach,
190178
oldIndicesToDelete,
191-
stagedIndex);
192-
193-
if (oldIndicesToDelete.contains(canonicalIndex)) {
194-
if (searchClient.indexExists(canonicalIndex)) {
195-
searchClient.deleteIndexWithBackoff(canonicalIndex);
196-
oldIndicesToDelete.remove(canonicalIndex);
197-
LOG.info("Cleaned up old index '{}' for entity '{}'.", canonicalIndex, entityType);
198-
}
199-
}
179+
stagedIndex,
180+
concreteToRemove);
200181

201-
if (!aliasesToAttach.isEmpty()) {
202-
boolean swapSuccess =
203-
searchClient.swapAliases(oldIndicesToDelete, stagedIndex, aliasesToAttach);
204-
if (!swapSuccess) {
205-
LOG.error(
206-
"Failed to atomically swap aliases for entity '{}'. Old indices will not be deleted.",
207-
entityType);
208-
return;
209-
}
210-
} else {
211-
LOG.warn("Entity '{}': aliasesToAttach is empty, skipping alias swap", entityType);
182+
boolean swapSuccess =
183+
searchClient.swapAliases(
184+
oldIndicesToDelete, stagedIndex, aliasesToAttach, concreteToRemove);
185+
if (!swapSuccess) {
186+
LOG.error(
187+
"Failed to atomically swap aliases for entity '{}'. Old indices will not be deleted.",
188+
entityType);
189+
return;
212190
}
213191

214192
LOG.info(
@@ -344,6 +322,11 @@ public void promoteEntityIndex(EntityReindexContext context, boolean reindexSucc
344322
Set<String> aliasesToAttach =
345323
getAliasesFromMapping(indexMapping, searchRepository.getClusterAlias());
346324

325+
if (aliasesToAttach.isEmpty()) {
326+
abortPromotionWithoutAliases(entityType, stagedIndex);
327+
return;
328+
}
329+
347330
Set<String> allEntityIndices = searchClient.listIndicesByPrefix(canonicalIndex);
348331
Set<String> oldIndicesToDelete = new HashSet<>();
349332
for (String oldIndex : allEntityIndices) {
@@ -352,36 +335,29 @@ public void promoteEntityIndex(EntityReindexContext context, boolean reindexSucc
352335
}
353336
}
354337

338+
Set<String> concreteToRemove =
339+
resolveCanonicalRemoval(searchClient, canonicalIndex, oldIndicesToDelete);
340+
355341
LOG.debug(
356-
"promoteEntityIndex '{}': aliases={}, oldIndices={}, stagedIndex={}",
342+
"promoteEntityIndex '{}': aliases={}, oldIndices={}, stagedIndex={}, concreteToRemove={}",
357343
entityType,
358344
aliasesToAttach,
359345
oldIndicesToDelete,
360-
stagedIndex);
361-
362-
if (oldIndicesToDelete.contains(canonicalIndex)) {
363-
if (searchClient.indexExists(canonicalIndex)) {
364-
searchClient.deleteIndexWithBackoff(canonicalIndex);
365-
oldIndicesToDelete.remove(canonicalIndex);
366-
LOG.info("Cleaned up old index '{}' for entity '{}'.", canonicalIndex, entityType);
367-
}
368-
}
346+
stagedIndex,
347+
concreteToRemove);
369348

370-
if (!aliasesToAttach.isEmpty()) {
371-
boolean swapSuccess =
372-
searchClient.swapAliases(oldIndicesToDelete, stagedIndex, aliasesToAttach);
373-
if (!swapSuccess) {
374-
LOG.error(
375-
"Failed to atomically swap aliases for entity '{}'. "
376-
+ "oldIndices={}, stagedIndex={}, aliases={}",
377-
entityType,
378-
oldIndicesToDelete,
379-
stagedIndex,
380-
aliasesToAttach);
381-
return;
382-
}
383-
} else {
384-
LOG.warn("Entity '{}': aliasesToAttach is empty, skipping alias swap", entityType);
349+
boolean swapSuccess =
350+
searchClient.swapAliases(
351+
oldIndicesToDelete, stagedIndex, aliasesToAttach, concreteToRemove);
352+
if (!swapSuccess) {
353+
LOG.error(
354+
"Failed to atomically swap aliases for entity '{}'. "
355+
+ "oldIndices={}, stagedIndex={}, aliases={}",
356+
entityType,
357+
oldIndicesToDelete,
358+
stagedIndex,
359+
aliasesToAttach);
360+
return;
385361
}
386362

387363
LOG.info(
@@ -419,6 +395,60 @@ public void promoteEntityIndex(EntityReindexContext context, boolean reindexSucc
419395
}
420396
}
421397

398+
/**
399+
* Aborts promotion when no aliases resolved for the staged index. Critically this skips the
400+
* old-index cleanup: deleting the previously serving index while no alias points at the staged
401+
* index would orphan the canonical alias and surface to users as
402+
* "Failed to find index openmetadata_*_search_index". The staged index is retained (its routing
403+
* is cleared by the caller's finally block) so a subsequent reindex can recover.
404+
*/
405+
private void abortPromotionWithoutAliases(String entityType, String stagedIndex) {
406+
LOG.error(
407+
"Entity '{}': no aliases resolved for staged index '{}'. Skipping alias swap and old-index "
408+
+ "cleanup to avoid orphaning the canonical alias. Staged index retained; reindex must "
409+
+ "be retried.",
410+
entityType,
411+
stagedIndex);
412+
ReindexingMetrics metrics = ReindexingMetrics.getInstance();
413+
if (metrics != null) {
414+
metrics.recordPromotionFailure(entityType);
415+
}
416+
}
417+
418+
/**
419+
* Resolves how the canonical index name participates in the alias swap and prunes it from {@code
420+
* oldIndicesToDelete}.
421+
*
422+
* <ul>
423+
* <li><b>Concrete index sharing the alias name</b> (the first-install / post-orphan shape): it
424+
* is returned so the caller hands it to {@link SearchClient#swapAliases(Set, String, Set,
425+
* Set)} for an atomic {@code remove_index}. Deleting it in a separate step before the swap
426+
* opens a window where the index is gone but the alias is not yet attached — if the alias
427+
* add then fails (e.g. the delete has not propagated, so OS/ES still sees an index with the
428+
* alias name), the canonical name resolves to nothing and users hit "Failed to find index
429+
* openmetadata_*_search_index".
430+
* <li><b>Already an alias</b>: it is simply dropped from the delete set — the swap moves it
431+
* atomically, and a delete-by-name on an alias would fail ("matches an alias") and burn
432+
* retry backoff.
433+
* </ul>
434+
*
435+
* @return the concrete indices (0 or 1) to remove atomically within the swap
436+
*/
437+
private Set<String> resolveCanonicalRemoval(
438+
SearchClient searchClient, String canonicalIndex, Set<String> oldIndicesToDelete) {
439+
Set<String> concreteToRemove = new HashSet<>();
440+
if (oldIndicesToDelete.contains(canonicalIndex)) {
441+
boolean isConcreteIndex =
442+
searchClient.getIndicesByAlias(canonicalIndex).isEmpty()
443+
&& searchClient.indexExists(canonicalIndex);
444+
if (isConcreteIndex) {
445+
concreteToRemove.add(canonicalIndex);
446+
}
447+
oldIndicesToDelete.remove(canonicalIndex);
448+
}
449+
return concreteToRemove;
450+
}
451+
422452
/**
423453
* Gets aliases from indexMapping.json configuration.
424454
*/
@@ -501,18 +531,18 @@ protected void recreateIndexFromMapping(
501531
applyBulkBuildSettings(searchClient, stagedIndexName, entityType);
502532
searchRepository.registerStagedIndex(entityType, stagedIndexName);
503533

504-
Set<String> existingAliases =
505-
activeIndexName != null ? searchClient.getAliases(activeIndexName) : new HashSet<>();
506-
507-
// Add the default index
508-
existingAliases.add(indexMapping.getAlias(clusterAlias));
509-
existingAliases.add(indexMapping.getIndexName(clusterAlias));
534+
// Aliases to attach come solely from indexMapping.json (short alias + parent aliases + raw
535+
// canonical index name) — never from whatever happens to be on the live index in ES. Reading
536+
// existing aliases off the cluster is non-deterministic (it propagates stray/leftover aliases),
537+
// adds a cluster round-trip and failure point, and diverges from the distributed promotion path
538+
// (promoteEntityIndex). Both paths now funnel through the same getAliasesFromMapping helper.
539+
Set<String> aliasesFromMapping = getAliasesFromMapping(indexMapping, clusterAlias);
510540
context.add(
511541
entityType,
512542
canonicalIndexName,
513543
activeIndexName,
514544
stagedIndexName,
515-
existingAliases,
545+
aliasesFromMapping,
516546
indexMapping.getAlias(clusterAlias),
517547
indexMapping.getParentAliases(clusterAlias));
518548

openmetadata-service/src/main/java/org/openmetadata/service/search/IndexManagementClient.java

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -98,16 +98,30 @@ public interface IndexManagementClient {
9898
void removeAliases(String indexName, Set<String> aliases);
9999

100100
/**
101-
* Atomically swap aliases from old indices to a new index.
102-
* This operation removes the specified aliases from any old indices and adds them to the new index
103-
* in a single atomic operation, ensuring zero-downtime during index promotion.
101+
* Atomically swap aliases from old indices to a new index, optionally deleting concrete indices
102+
* in the same request. Removing the aliases from old indices, deleting any {@code indicesToRemove}
103+
* (via {@code remove_index}) and adding the aliases to the new index all happen in a single atomic
104+
* {@code updateAliases} call.
105+
*
106+
* <p>{@code indicesToRemove} is for the first-install / post-orphan shape where the canonical name
107+
* (e.g. {@code table_search_index}) is still a <em>concrete</em> index sharing the alias name.
108+
* Deleting it separately before the swap would orphan the alias if the alias add then failed;
109+
* folding the delete into the atomic request means a failure is a no-op — the concrete index and
110+
* its live aliases survive — instead of leaving the canonical name pointing at nothing.
104111
*
105112
* @param oldIndices the set of old index names to remove aliases from
106113
* @param newIndex the new index name to add aliases to
107114
* @param aliases the set of aliases to swap
115+
* @param indicesToRemove concrete indices to delete atomically within the same request
108116
* @return true if the swap was successful, false otherwise
109117
*/
110-
boolean swapAliases(Set<String> oldIndices, String newIndex, Set<String> aliases);
118+
boolean swapAliases(
119+
Set<String> oldIndices, String newIndex, Set<String> aliases, Set<String> indicesToRemove);
120+
121+
/** Swap aliases without atomically removing any concrete index. */
122+
default boolean swapAliases(Set<String> oldIndices, String newIndex, Set<String> aliases) {
123+
return swapAliases(oldIndices, newIndex, aliases, Set.of());
124+
}
111125

112126
/**
113127
* Get all aliases for an index.

openmetadata-service/src/main/java/org/openmetadata/service/search/elasticsearch/ElasticSearchClient.java

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -263,8 +263,9 @@ public void removeAliases(String indexName, Set<String> aliases) {
263263
}
264264

265265
@Override
266-
public boolean swapAliases(Set<String> oldIndices, String newIndex, Set<String> aliases) {
267-
return indexManager.swapAliases(oldIndices, newIndex, aliases);
266+
public boolean swapAliases(
267+
Set<String> oldIndices, String newIndex, Set<String> aliases, Set<String> indicesToRemove) {
268+
return indexManager.swapAliases(oldIndices, newIndex, aliases, indicesToRemove);
268269
}
269270

270271
@Override

openmetadata-service/src/main/java/org/openmetadata/service/search/elasticsearch/ElasticSearchIndexManager.java

Lines changed: 26 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -330,35 +330,43 @@ public void removeAliases(String indexName, Set<String> aliases) {
330330
}
331331

332332
@Override
333-
public boolean swapAliases(Set<String> oldIndices, String newIndex, Set<String> aliases) {
333+
public boolean swapAliases(
334+
Set<String> oldIndices, String newIndex, Set<String> aliases, Set<String> indicesToRemove) {
334335
if (!isClientAvailable) {
335336
LOG.error("ElasticSearch client is not available. Cannot swap aliases.");
336337
return false;
337338
}
338-
if (aliases == null || aliases.isEmpty()) {
339-
LOG.debug("No aliases to swap for index {}", newIndex);
339+
Set<String> finalAliases = aliases == null ? Set.of() : aliases;
340+
Set<String> finalIndicesToRemove = indicesToRemove == null ? Set.of() : indicesToRemove;
341+
if (finalAliases.isEmpty() && finalIndicesToRemove.isEmpty()) {
342+
LOG.debug("No aliases to swap and no indices to remove for index {}", newIndex);
340343
return true;
341344
}
342-
if (oldIndices == null) {
343-
oldIndices = new HashSet<>();
344-
}
345-
346-
Set<String> finalOldIndices = oldIndices;
345+
Set<String> finalOldIndices = oldIndices == null ? new HashSet<>() : oldIndices;
347346
try {
348347
UpdateAliasesRequest request =
349348
UpdateAliasesRequest.of(
350349
updateBuilder -> {
351350
// First, remove aliases from all old indices
352351
for (String oldIndex : finalOldIndices) {
353-
for (String alias : aliases) {
352+
for (String alias : finalAliases) {
354353
updateBuilder.actions(
355354
actionBuilder ->
356355
actionBuilder.remove(
357356
removeBuilder -> removeBuilder.index(oldIndex).alias(alias)));
358357
}
359358
}
360-
// Then, add aliases to the new index
361-
for (String alias : aliases) {
359+
// Then delete any concrete index sharing the alias name, atomically, so the alias
360+
// add below cannot race a separate delete and orphan the canonical name.
361+
for (String indexToRemove : finalIndicesToRemove) {
362+
updateBuilder.actions(
363+
actionBuilder ->
364+
actionBuilder.removeIndex(
365+
removeIndexBuilder ->
366+
removeIndexBuilder.index(indexToRemove).mustExist(false)));
367+
}
368+
// Finally, add aliases to the new index
369+
for (String alias : finalAliases) {
362370
updateBuilder.actions(
363371
actionBuilder ->
364372
actionBuilder.add(addBuilder -> addBuilder.index(newIndex).alias(alias)));
@@ -370,25 +378,18 @@ public boolean swapAliases(Set<String> oldIndices, String newIndex, Set<String>
370378

371379
if (response.acknowledged()) {
372380
LOG.info(
373-
"Atomically swapped aliases {} from indices {} to index {}",
374-
aliases,
375-
finalOldIndices,
376-
newIndex);
381+
"Atomically swapped aliases {} to index {} (removed indices {}, detached from {})",
382+
finalAliases,
383+
newIndex,
384+
finalIndicesToRemove,
385+
finalOldIndices);
377386
return true;
378387
} else {
379-
LOG.warn(
380-
"Alias swap from indices {} to index {} was not acknowledged",
381-
finalOldIndices,
382-
newIndex);
388+
LOG.warn("Alias swap to index {} was not acknowledged", newIndex);
383389
return false;
384390
}
385391
} catch (Exception e) {
386-
LOG.error(
387-
"Failed to swap aliases {} from indices {} to index {}",
388-
aliases,
389-
finalOldIndices,
390-
newIndex,
391-
e);
392+
LOG.error("Failed to swap aliases {} to index {}", finalAliases, newIndex, e);
392393
return false;
393394
}
394395
}

0 commit comments

Comments
 (0)