@@ -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
0 commit comments