From a6d920ba5f006821ad49ee31caf94978e98d1311 Mon Sep 17 00:00:00 2001 From: fabrizzio-dotCMS Date: Wed, 1 Jul 2026 16:08:22 -0600 Subject: [PATCH 1/6] fix(search): guard active-index deletion and gate ES/OS delete cascade (#35640) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Index delete had two defects surfaced by QA (epic #35476, TC-016..020): TC-018 — deleting the currently active index was possible via the REST/AJAX endpoint. The maintenance UI only hides the Delete option for active indices (a client-side guard), so a direct DELETE /api/v1/esindex/{name} bypassed it and could leave the site with zero indices. Add a server-side guard in ContentletIndexAPIImpl.delete(): reject deletion of any index reported active or building by the phase-aware getCurrentIndex()/getNewIndex() (the same sources the UI uses), via DotStateException. ESIndexResource maps it to HTTP 400. The guard is fail-closed and can be bypassed with FEATURE_FLAG_ALLOW_ACTIVE_INDEX_DELETE. Guard reuses the phase-aware getters rather than VersionedIndices directly because VersionedIndices is empty in Phase 0 (rows are only written once migration starts), which would leave the most common prod state unprotected. TC-016 — delete cascades to the .os twin (phase-dispatched, since #35820). Make this an explicit, documented default gated by FEATURE_FLAG_INDEX_DELETE_CASCADE (default true). When off, delete is tag-dispatched (name ending in .os -> OS, otherwise ES) so only the named engine's index is removed and the twin is left intact — the ticket's documented no-cascade behavior. TC-016/017 cleanups in ESIndexResource: normalize the incoming name with removeClusterIdFromName so the endpoint accepts both the short name and the full physical name (with cluster prefix) instead of 404-ing on the latter; return a readable 404 body instead of an empty response; rename the inverted-sense indexExists helper to indexDoesNotExist. Tests: ContentletIndexAPIImplTest#delete_activeIndex_isRejected_unlessFeatureFlagOverrides and ContentletIndexAPIImplMigrationIntegrationTest#test_delete_phase1_cascadeOff_removesOnlyNamedEngine. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../business/ContentletIndexAPIImpl.java | 93 +++++++++++++++++-- .../rest/api/v1/index/ESIndexResource.java | 39 ++++++-- ...tIndexAPIImplMigrationIntegrationTest.java | 32 +++++++ .../business/ContentletIndexAPIImplTest.java | 50 ++++++++++ 4 files changed, 198 insertions(+), 16 deletions(-) diff --git a/dotCMS/src/main/java/com/dotcms/content/elasticsearch/business/ContentletIndexAPIImpl.java b/dotCMS/src/main/java/com/dotcms/content/elasticsearch/business/ContentletIndexAPIImpl.java index b456c46c059..a67989672b5 100644 --- a/dotCMS/src/main/java/com/dotcms/content/elasticsearch/business/ContentletIndexAPIImpl.java +++ b/dotCMS/src/main/java/com/dotcms/content/elasticsearch/business/ContentletIndexAPIImpl.java @@ -46,6 +46,7 @@ import com.dotcms.variant.model.Variant; import com.dotmarketing.business.APILocator; import com.dotmarketing.business.CacheLocator; +import com.dotmarketing.business.DotStateException; import com.dotmarketing.common.db.DotConnect; import com.dotmarketing.common.reindex.BulkProcessorListener; import com.dotmarketing.common.reindex.ReindexEntry; @@ -1668,16 +1669,92 @@ private void logSwitchover(final IndiciesInfo oldInfo, final String luckyServer) } + /** + * When {@code true}, bypasses the guard that blocks deletion of an active + * (working/live) or building (reindex) index. Off by default: deleting an in-use + * index leaves the site with nothing to serve reads from or reindex into. Intended + * only for emergency/scripted maintenance. + */ + public static final String FF_ALLOW_ACTIVE_INDEX_DELETE = "FEATURE_FLAG_ALLOW_ACTIVE_INDEX_DELETE"; + + /** + * Controls whether {@link #delete(String)} cascades to the index's twin in the other + * engine. On by default. + * + */ + public static final String FF_INDEX_DELETE_CASCADE = "FEATURE_FLAG_INDEX_DELETE_CASCADE"; + + /** + * Rejects deletion of an index that is currently active (working/live) or being + * rebuilt (a reindex slot). The protected set is resolved phase-aware via + * {@link #getCurrentIndex()} and {@link #getNewIndex()} — the same sources the + * maintenance UI uses to flag an index active/building — so this + * server-side guard mirrors exactly what the UI already hides. The UI hid the Delete + * option for these indices; a direct REST/AJAX call previously bypassed that and could + * wipe the only index (see issue #35640, TC-018). + * + *

Bypass with the {@value #FF_ALLOW_ACTIVE_INDEX_DELETE} feature flag.

+ * + * @throws DotStateException if the index is active/building (bypass off), or if the + * active set cannot be resolved (fail closed — a destructive + * op must not proceed on an unknown state). + */ + private void assertIndexIsDeletable(final String indexName) { + if (Config.getBooleanProperty(FF_ALLOW_ACTIVE_INDEX_DELETE, false)) { + return; + } + final String requested = indexAPI.removeClusterIdFromName(indexName); + final Set protectedIndices = new HashSet<>(); + try { + protectedIndices.addAll(getCurrentIndex()); // active working + live + protectedIndices.addAll(getNewIndex()); // building reindex slots + } catch (final DotDataException e) { + throw new DotStateException("Unable to verify whether index '" + indexName + + "' is active before deletion; refusing to delete.", e); + } + if (protectedIndices.contains(requested)) { + throw new DotStateException("Index '" + indexName + + "' is active or being rebuilt and cannot be deleted. Deactivate it first" + + " (or set " + FF_ALLOW_ACTIVE_INDEX_DELETE + "=true to override)."); + } + } + public boolean delete(String indexName) { - // Mirror createContentIndex: resolve the per-provider physical name (ES → bare, - // OS → .os tag) via ops.toPhysicalName so a router-driven delete targets the REAL - // OS index. Routing the bare logical name straight to OSIndexAPIImpl.delete would - // hit an untagged name that does not exist and orphan the actual .os index. - // Track the primary provider's result; shadow failures in dual-write phases are - // fire-and-forget (the primary ES delete must not be undone by an OS shadow miss). - final ContentletIndexOperations primary = router.readProvider(); + // Guard first: never delete an active/building index (issue #35640, TC-018). + assertIndexIsDeletable(indexName); + + // Cascade (default) deletes the index and its twin in the other engine together; + // when disabled, only the engine that owns the given name is touched. See + // FF_INDEX_DELETE_CASCADE (issue #35640, TC-016). + final boolean cascade = Config.getBooleanProperty(FF_INDEX_DELETE_CASCADE, true); + final ContentletIndexOperations primary; + final List targets; + if (cascade) { + // Phase-dispatched broadcast: fan out to every write provider so the .os twin + // is not orphaned. Track the read provider's result as the primary; shadow + // failures in dual-write phases are fire-and-forget. + primary = router.readProvider(); + targets = router.writeProviders(); + } else { + // Tag-dispatched: the vendor tag on the name decides the single target + // (.os → OS, otherwise ES). The twin in the other engine is left intact. + primary = IndexTag.OS.isTagged(indexName) ? router.osImpl() : router.esImpl(); + targets = List.of(primary); + } + boolean primaryResult = false; - for (final ContentletIndexOperations ops : router.writeProviders()) { + for (final ContentletIndexOperations ops : targets) { + // Resolve the per-provider physical name (ES → bare, OS → .os tag) via + // ops.toPhysicalName so the delete targets the REAL index. Routing a bare + // logical name straight to OSIndexAPIImpl.delete would hit an untagged name + // that does not exist and orphan the actual .os index. final String physicalName = ops.toPhysicalName(indexName); try { final boolean r = ops.indexAPI().delete(physicalName); diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v1/index/ESIndexResource.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/index/ESIndexResource.java index 1c88f79a599..6874d1dfc44 100644 --- a/dotCMS/src/main/java/com/dotcms/rest/api/v1/index/ESIndexResource.java +++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/index/ESIndexResource.java @@ -46,6 +46,7 @@ import com.dotcms.rest.annotation.NoCache; import com.dotcms.rest.api.v1.authentication.ResponseUtil; import com.dotmarketing.business.APILocator; +import com.dotmarketing.business.DotStateException; import com.dotmarketing.business.LayoutAPI; import com.dotmarketing.business.Role; import com.dotmarketing.common.reindex.IndexResourceHelper; @@ -386,13 +387,30 @@ public Response deleteIndex(@Context final HttpServletRequest request, @Context final InitDataObject init = auth(request, response); - if(indexExists(indexName) ){ - return Response.status(404).build(); + // Accept either the short name (as the UI sends it) or the full physical name with the + // cluster prefix (as `_cat/indices` reports it): normalize to the cluster-stripped form + // that listDotCMSIndices()/delete() expect, so a full physical name no longer 404s + // (issue #35640, TC-016). + final String resolvedName = indexAPI.removeClusterIdFromName(indexName); + + if(indexDoesNotExist(resolvedName) ){ + // Readable 404 body (no stack trace) so a mistyped/nonexistent name is clear + // (issue #35640, TC-017). + return Response.status(Response.Status.NOT_FOUND) + .entity(new ResponseEntityView<>("Index not found: " + indexName)).build(); + } + + try { + idxApi.delete(resolvedName); + } catch (final DotStateException e) { + // The index is active/building (or its state could not be verified): reject with a + // readable 400 instead of a stack trace. See issue #35640, TC-018. + Logger.warn(this, "Rejected deletion of index '" + resolvedName + "': " + e.getMessage()); + return Response.status(Response.Status.BAD_REQUEST) + .entity(new ResponseEntityView<>(e.getMessage())).build(); } - - idxApi.delete(indexName); - String message = "Index:" + indexName + " deleted"; + String message = "Index:" + resolvedName + " deleted"; sendAdminMessage(message, MessageSeverity.INFO,init.getUser(), 5000); @@ -541,8 +559,8 @@ public Response modIndex(@Context final HttpServletRequest request, @Context fin final InitDataObject init = auth(request, response); final IndexAction indexAction = IndexAction.fromString(action); - - if(indexExists(indexName) ){ + + if(indexDoesNotExist(indexName) ){ return Response.status(404).build(); } @@ -590,7 +608,12 @@ public Response getIndexStatus(@Context final HttpServletRequest request, } - private boolean indexExists(final String indexName) { + /** + * Returns {@code true} when the given index name is present in neither the open nor the + * closed dotCMS index list — i.e. the index does not exist. Named for the + * caller's guard ({@code if (indexDoesNotExist(...)) return 404}). + */ + private boolean indexDoesNotExist(final String indexName) { return !idxApi.listDotCMSIndices().contains(indexName) && !idxApi.listDotCMSClosedIndices().contains(indexName) ; } } diff --git a/dotcms-integration/src/test/java/com/dotcms/content/elasticsearch/business/ContentletIndexAPIImplMigrationIntegrationTest.java b/dotcms-integration/src/test/java/com/dotcms/content/elasticsearch/business/ContentletIndexAPIImplMigrationIntegrationTest.java index 7ff153c4561..01c0b40aa7f 100644 --- a/dotcms-integration/src/test/java/com/dotcms/content/elasticsearch/business/ContentletIndexAPIImplMigrationIntegrationTest.java +++ b/dotcms-integration/src/test/java/com/dotcms/content/elasticsearch/business/ContentletIndexAPIImplMigrationIntegrationTest.java @@ -444,6 +444,38 @@ public void test_delete_phase1_removesFromBothClusters() throws IOException, Dot Logger.info(this, "✅ delete Phase 1 — both removed: " + DUAL_WORKING); } + /** + * Given Scenario: Phase 1 (dual-write). {@code DUAL_WORKING} exists in both clusters. + * When : cascade is disabled ({@code FEATURE_FLAG_INDEX_DELETE_CASCADE=false}) and + * {@code delete(DUAL_WORKING)} is called with the BARE (ES) name. + * Then : only the ES index is removed; the OS {@code .os} twin is left intact. This is the + * tag-dispatched, no-cascade behavior gated by the feature flag (issue #35640, TC-016). + * Works in single-cluster mode because the OS physical name carries the {@code .os} tag. + */ + @Test + public void test_delete_phase1_cascadeOff_removesOnlyNamedEngine() + throws IOException, DotIndexException { + setPhase(1); + + contentletIndexAPI().createContentIndex(DUAL_WORKING, 1); + assertTrue("Pre: must exist in ES", esImpl().indexExists(DUAL_WORKING)); + assertTrue("Pre: must exist in OS", osIndexAPI.indexExists(physicalDualWorking)); + + Config.setProperty(ContentletIndexAPIImpl.FF_INDEX_DELETE_CASCADE, false); + try { + contentletIndexAPI().delete(DUAL_WORKING); // bare name → tag-dispatched to ES only + } finally { + Config.setProperty(ContentletIndexAPIImpl.FF_INDEX_DELETE_CASCADE, true); + } + + assertFalse("ES index must be gone (it was the named engine)", + esImpl().indexExists(DUAL_WORKING)); + assertTrue("OS .os twin must survive with cascade off", + osIndexAPI.indexExists(physicalDualWorking)); + + Logger.info(this, "✅ delete Phase 1 cascade OFF — only ES removed: " + DUAL_WORKING); + } + // ========================================================================= // Orphan bootstrap repair — bare cluster index missing from the store (#36237) // ========================================================================= diff --git a/dotcms-integration/src/test/java/com/dotcms/content/elasticsearch/business/ContentletIndexAPIImplTest.java b/dotcms-integration/src/test/java/com/dotcms/content/elasticsearch/business/ContentletIndexAPIImplTest.java index b85eff8cbb9..cd6c6b3d03a 100644 --- a/dotcms-integration/src/test/java/com/dotcms/content/elasticsearch/business/ContentletIndexAPIImplTest.java +++ b/dotcms-integration/src/test/java/com/dotcms/content/elasticsearch/business/ContentletIndexAPIImplTest.java @@ -7,6 +7,7 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; import com.dotcms.IntegrationTestBase; @@ -62,6 +63,8 @@ import com.dotmarketing.portlets.structure.model.Structure; import com.dotmarketing.portlets.templates.model.Template; import com.dotmarketing.sitesearch.business.SiteSearchAPI; +import com.dotmarketing.business.DotStateException; +import com.dotmarketing.util.Config; import com.dotmarketing.util.Logger; import com.dotmarketing.util.UUIDGenerator; import com.dotmarketing.util.UtilMethods; @@ -428,6 +431,53 @@ public void activateDeactivateIndex() throws Exception { indexAPI.activateIndex(oldActiveLive); } + /** + * Guard for issue #35640 (TC-018): {@link ContentletIndexAPI#delete(String)} must refuse + * to delete the currently active index (the maintenance UI hides Delete for active indices, + * but a direct REST/AJAX call previously bypassed that and could wipe the only index). The + * refusal is enforced via a {@link DotStateException} and can be overridden with the + * {@code FEATURE_FLAG_ALLOW_ACTIVE_INDEX_DELETE} feature flag. + * + * @see ContentletIndexAPIImpl#delete(String) + */ + @Test + public void delete_activeIndex_isRejected_unlessFeatureFlagOverrides() throws Exception { + + final String timeStamp = String.valueOf(new Date().getTime()); + final String workingIndex = IndexType.WORKING.getPrefix() + "_" + timeStamp; + + final String oldActiveWorking = indexAPI.getActiveIndexName(IndexType.WORKING.getPrefix()); + + assertTrue(indexAPI.createContentIndex(workingIndex)); + indexAPI.activateIndex(workingIndex); + + try { + // The active index is reported by getCurrentIndex(): delete must be rejected and + // the index must survive. + assertThrows(DotStateException.class, () -> indexAPI.delete(workingIndex)); + assertTrue("Active index must survive a rejected delete", + indexAPI.listDotCMSIndices().contains(workingIndex)); + + // Feature-flag bypass: the same delete now goes through. + Config.setProperty(ContentletIndexAPIImpl.FF_ALLOW_ACTIVE_INDEX_DELETE, true); + try { + assertTrue(indexAPI.delete(workingIndex)); + assertFalse(indexAPI.listDotCMSIndices().contains(workingIndex)); + } finally { + Config.setProperty(ContentletIndexAPIImpl.FF_ALLOW_ACTIVE_INDEX_DELETE, false); + } + } finally { + // Restore the prior active working pointer (best effort). + try { + if (oldActiveWorking != null) { + indexAPI.activateIndex(oldActiveWorking); + } + } catch (final Exception e) { + Logger.warn(this, "Unable to restore active working index after test", e); + } + } + } + /** * Testing {@link ContentletIndexAPI#isDotCMSIndexName(String)} * From 331176939bb50754af30b07f8d05d980e345a806 Mon Sep 17 00:00:00 2001 From: fabrizzio-dotCMS Date: Wed, 1 Jul 2026 16:50:29 -0600 Subject: [PATCH 2/6] fix(search): normalize index name in modIndex for API consistency (#35640) Apply the same removeClusterIdFromName normalization to the modIndex (activate/deactivate/clear/open/close) endpoint that deleteIndex already uses, so both accept the short name and the full physical name (with cluster prefix) instead of 404-ing on the latter. Addresses the AI review consistency finding. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../rest/api/v1/index/ESIndexResource.java | 25 +++++++++++-------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v1/index/ESIndexResource.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/index/ESIndexResource.java index 6874d1dfc44..49089678981 100644 --- a/dotCMS/src/main/java/com/dotcms/rest/api/v1/index/ESIndexResource.java +++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/index/ESIndexResource.java @@ -560,29 +560,34 @@ public Response modIndex(@Context final HttpServletRequest request, @Context fin final InitDataObject init = auth(request, response); final IndexAction indexAction = IndexAction.fromString(action); - if(indexDoesNotExist(indexName) ){ + // Same normalization as deleteIndex: accept both the short name and the full physical + // name (with cluster prefix) so this endpoint stays consistent and does not 404 on the + // latter (issue #35640, TC-016). + final String resolvedName = indexAPI.removeClusterIdFromName(indexName); + + if(indexDoesNotExist(resolvedName) ){ return Response.status(404).build(); } - - + + switch(indexAction){ case DEACTIVATE: - APILocator.getContentletIndexAPI().deactivateIndex(indexName); + APILocator.getContentletIndexAPI().deactivateIndex(resolvedName); break; case CLEAR: - APILocator.getESIndexAPI().clearIndex(indexName); + APILocator.getESIndexAPI().clearIndex(resolvedName); break; case OPEN: - APILocator.getESIndexAPI().openIndex(indexName); + APILocator.getESIndexAPI().openIndex(resolvedName); break; case CLOSE: - APILocator.getESIndexAPI().closeIndex(indexName); + APILocator.getESIndexAPI().closeIndex(resolvedName); break; default: - APILocator.getContentletIndexAPI().activateIndex(indexName); - + APILocator.getContentletIndexAPI().activateIndex(resolvedName); + } - String message = indexAction.name().toLowerCase() + " " + indexName; + String message = indexAction.name().toLowerCase() + " " + resolvedName; sendAdminMessage(message, MessageSeverity.INFO,init.getUser(), 5000); From 130e7f2d395033560644586f3c37f8a5e32fbb9b Mon Sep 17 00:00:00 2001 From: fabrizzio-dotCMS Date: Fri, 3 Jul 2026 16:10:31 -0600 Subject: [PATCH 3/6] fix(search): readable 404 body in modIndex, consistent with deleteIndex (#35640) modIndex returned an empty 404 body while deleteIndex returns a readable one. Make modIndex return the same "Index not found: {name}" body so both index endpoints are consistent. Addresses the AI review consistency finding. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../java/com/dotcms/rest/api/v1/index/ESIndexResource.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v1/index/ESIndexResource.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/index/ESIndexResource.java index 49089678981..44492f347d1 100644 --- a/dotCMS/src/main/java/com/dotcms/rest/api/v1/index/ESIndexResource.java +++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/index/ESIndexResource.java @@ -566,7 +566,9 @@ public Response modIndex(@Context final HttpServletRequest request, @Context fin final String resolvedName = indexAPI.removeClusterIdFromName(indexName); if(indexDoesNotExist(resolvedName) ){ - return Response.status(404).build(); + // Readable 404 body, consistent with deleteIndex (issue #35640, TC-017). + return Response.status(Response.Status.NOT_FOUND) + .entity(new ResponseEntityView<>("Index not found: " + indexName)).build(); } From e42c0d646e7d4098b96edfa223abbffa8272dcd2 Mon Sep 17 00:00:00 2001 From: fabrizzio-dotCMS Date: Fri, 3 Jul 2026 16:51:49 -0600 Subject: [PATCH 4/6] fix(search): return index delete/mod errors in the errors array (#35640) The active-index rejection (400) and not-found (404) responses put their message in the ResponseEntityView entity field instead of the standard errors array. Return them as ErrorEntity in the errors list (INDEX_NOT_DELETABLE / INDEX_NOT_FOUND) so clients read errors from the conventional place. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../java/com/dotcms/rest/api/v1/index/ESIndexResource.java | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v1/index/ESIndexResource.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/index/ESIndexResource.java index 44492f347d1..567ff16da6e 100644 --- a/dotCMS/src/main/java/com/dotcms/rest/api/v1/index/ESIndexResource.java +++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/index/ESIndexResource.java @@ -39,6 +39,7 @@ import com.dotcms.content.elasticsearch.util.ESReindexationProcessStatus; import com.dotcms.contenttype.model.type.ContentType; import com.google.common.annotations.VisibleForTesting; +import com.dotcms.rest.ErrorEntity; import com.dotcms.rest.InitDataObject; import com.dotcms.rest.ResourceResponse; import com.dotcms.rest.ResponseEntityView; @@ -397,7 +398,7 @@ public Response deleteIndex(@Context final HttpServletRequest request, @Context // Readable 404 body (no stack trace) so a mistyped/nonexistent name is clear // (issue #35640, TC-017). return Response.status(Response.Status.NOT_FOUND) - .entity(new ResponseEntityView<>("Index not found: " + indexName)).build(); + .entity(new ResponseEntityView<>(List.of(new ErrorEntity("INDEX_NOT_FOUND", "Index not found: " + indexName)))).build(); } try { @@ -407,7 +408,7 @@ public Response deleteIndex(@Context final HttpServletRequest request, @Context // readable 400 instead of a stack trace. See issue #35640, TC-018. Logger.warn(this, "Rejected deletion of index '" + resolvedName + "': " + e.getMessage()); return Response.status(Response.Status.BAD_REQUEST) - .entity(new ResponseEntityView<>(e.getMessage())).build(); + .entity(new ResponseEntityView<>(List.of(new ErrorEntity("INDEX_NOT_DELETABLE", e.getMessage())))).build(); } String message = "Index:" + resolvedName + " deleted"; @@ -568,7 +569,7 @@ public Response modIndex(@Context final HttpServletRequest request, @Context fin if(indexDoesNotExist(resolvedName) ){ // Readable 404 body, consistent with deleteIndex (issue #35640, TC-017). return Response.status(Response.Status.NOT_FOUND) - .entity(new ResponseEntityView<>("Index not found: " + indexName)).build(); + .entity(new ResponseEntityView<>(List.of(new ErrorEntity("INDEX_NOT_FOUND", "Index not found: " + indexName)))).build(); } From c290edc5017f4740207abef02f0784b72390977d Mon Sep 17 00:00:00 2001 From: fabrizzio-dotCMS Date: Fri, 3 Jul 2026 17:36:36 -0600 Subject: [PATCH 5/6] =?UTF-8?q?fix(search):=20make=20index=20delete=20casc?= =?UTF-8?q?ade=20strictly=20ES=E2=86=92OS,=20never=20reverse=20(#35640)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deleting by the .os-tagged name only removed the OS index because the ES-side toPhysicalName keeps the .os suffix and misses; but rather than make the cascade bidirectional, keep it one-directional by design: a shadow (.os) delete must never tumble the authoritative ES index. - Bare/logical name + cascade on → broadcast to both engines (ES + OS twin). - Bare name + cascade off → ES only. - .os-tagged name → always tag-dispatched OS-only, regardless of the flag. Also harden the active-index guard to compare on the logical (untagged) name on both sides, so deleting the active index's .os shadow (which would break Phase-2 reads) is blocked too. Test: test_delete_phase1_byOsName_removesOnlyOs. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../business/ContentletIndexAPIImpl.java | 59 +++++++++++-------- ...tIndexAPIImplMigrationIntegrationTest.java | 26 ++++++++ 2 files changed, 62 insertions(+), 23 deletions(-) diff --git a/dotCMS/src/main/java/com/dotcms/content/elasticsearch/business/ContentletIndexAPIImpl.java b/dotCMS/src/main/java/com/dotcms/content/elasticsearch/business/ContentletIndexAPIImpl.java index a67989672b5..a65e5e2b17a 100644 --- a/dotCMS/src/main/java/com/dotcms/content/elasticsearch/business/ContentletIndexAPIImpl.java +++ b/dotCMS/src/main/java/com/dotcms/content/elasticsearch/business/ContentletIndexAPIImpl.java @@ -1678,15 +1678,16 @@ private void logSwitchover(final IndiciesInfo oldInfo, final String luckyServer) public static final String FF_ALLOW_ACTIVE_INDEX_DELETE = "FEATURE_FLAG_ALLOW_ACTIVE_INDEX_DELETE"; /** - * Controls whether {@link #delete(String)} cascades to the index's twin in the other - * engine. On by default. + * Controls whether deleting the authoritative ES/logical index also removes its OS twin. + * On by default. The cascade is strictly ES→OS: it only fires when the + * caller passes a bare (untagged) name. An explicit {@code .os} name is always a + * tag-dispatched, OS-only delete regardless of this flag — a shadow delete must never + * tumble the authoritative ES index. *
    - *
  • true (default) — a logical index and its counterpart in the other - * engine are deleted together (phase-dispatched broadcast to every write provider), - * so the {@code .os} twin is never left orphaned.
  • - *
  • false — only the engine that owns the given name is touched - * (tag-dispatched: a name ending in {@code .os} → OpenSearch, otherwise - * Elasticsearch), leaving the twin intact (issue #35640, TC-016 documented behavior).
  • + *
  • true (default) — a bare name deletes the ES index and its OS + * {@code .os} twin together (phase-dispatched broadcast), so the twin is not orphaned.
  • + *
  • false — a bare name deletes only ES, leaving the OS twin intact + * (issue #35640, TC-016 documented behavior).
  • *
*/ public static final String FF_INDEX_DELETE_CASCADE = "FEATURE_FLAG_INDEX_DELETE_CASCADE"; @@ -1710,11 +1711,19 @@ private void assertIndexIsDeletable(final String indexName) { if (Config.getBooleanProperty(FF_ALLOW_ACTIVE_INDEX_DELETE, false)) { return; } - final String requested = indexAPI.removeClusterIdFromName(indexName); + // Compare on the LOGICAL (cluster-stripped, untagged) name on BOTH sides. The active + // set is bare in Phases 0-2 and .os-tagged in Phase 3, and the caller may pass either + // the bare or the .os name — normalizing both to the logical form makes the guard match + // regardless of phase or which twin's name was given (issue #35640). + final String requested = IndexTag.OS.untag(indexAPI.removeClusterIdFromName(indexName)); final Set protectedIndices = new HashSet<>(); try { - protectedIndices.addAll(getCurrentIndex()); // active working + live - protectedIndices.addAll(getNewIndex()); // building reindex slots + for (final String n : getCurrentIndex()) { // active working + live + protectedIndices.add(IndexTag.OS.untag(n)); + } + for (final String n : getNewIndex()) { // building reindex slots + protectedIndices.add(IndexTag.OS.untag(n)); + } } catch (final DotDataException e) { throw new DotStateException("Unable to verify whether index '" + indexName + "' is active before deletion; refusing to delete.", e); @@ -1730,23 +1739,27 @@ public boolean delete(String indexName) { // Guard first: never delete an active/building index (issue #35640, TC-018). assertIndexIsDeletable(indexName); - // Cascade (default) deletes the index and its twin in the other engine together; - // when disabled, only the engine that owns the given name is touched. See - // FF_INDEX_DELETE_CASCADE (issue #35640, TC-016). + // The cascade is strictly ES→OS: deleting the authoritative ES/logical index also + // removes its OS twin, but deleting an explicit .os index NEVER touches ES (a shadow + // delete must not tumble the authoritative index). See FF_INDEX_DELETE_CASCADE + // (issue #35640, TC-016). final boolean cascade = Config.getBooleanProperty(FF_INDEX_DELETE_CASCADE, true); + final boolean osTagged = IndexTag.OS.isTagged(indexName); final ContentletIndexOperations primary; final List targets; - if (cascade) { - // Phase-dispatched broadcast: fan out to every write provider so the .os twin - // is not orphaned. Track the read provider's result as the primary; shadow - // failures in dual-write phases are fire-and-forget. + if (osTagged || !cascade) { + // Tag-dispatched, single engine. An explicit .os name always targets OS only + // (never cascades back to ES). A bare name with cascade off targets ES only. + primary = osTagged ? router.osImpl() : router.esImpl(); + targets = List.of(primary); + } else { + // Bare name + cascade on: phase-dispatched broadcast to every write provider so + // the ES delete also removes its OS twin (ES→OS, no orphan shadow). The bare name + // lets each provider re-derive its OWN physical name (ES → bare, OS → .os). + // Track the read provider's result as the primary; shadow failures in dual-write + // phases are fire-and-forget. primary = router.readProvider(); targets = router.writeProviders(); - } else { - // Tag-dispatched: the vendor tag on the name decides the single target - // (.os → OS, otherwise ES). The twin in the other engine is left intact. - primary = IndexTag.OS.isTagged(indexName) ? router.osImpl() : router.esImpl(); - targets = List.of(primary); } boolean primaryResult = false; diff --git a/dotcms-integration/src/test/java/com/dotcms/content/elasticsearch/business/ContentletIndexAPIImplMigrationIntegrationTest.java b/dotcms-integration/src/test/java/com/dotcms/content/elasticsearch/business/ContentletIndexAPIImplMigrationIntegrationTest.java index 01c0b40aa7f..524202375d4 100644 --- a/dotcms-integration/src/test/java/com/dotcms/content/elasticsearch/business/ContentletIndexAPIImplMigrationIntegrationTest.java +++ b/dotcms-integration/src/test/java/com/dotcms/content/elasticsearch/business/ContentletIndexAPIImplMigrationIntegrationTest.java @@ -476,6 +476,32 @@ public void test_delete_phase1_cascadeOff_removesOnlyNamedEngine() Logger.info(this, "✅ delete Phase 1 cascade OFF — only ES removed: " + DUAL_WORKING); } + /** + * Given Scenario: Phase 1 (dual-write). {@code DUAL_WORKING} exists in both clusters. + * When : {@code delete()} is called with the {@code .os}-tagged name (as the QA/preview UI + * shows the OS index), with cascade on (default). + * Then : ONLY the OS index is removed; the authoritative ES twin is left intact. The cascade + * is strictly ES→OS — an explicit {@code .os} name is a tag-dispatched OS-only delete + * and never tumbles the ES index (issue #35640). + */ + @Test + public void test_delete_phase1_byOsName_removesOnlyOs() + throws IOException, DotIndexException { + setPhase(1); + + contentletIndexAPI().createContentIndex(DUAL_WORKING, 1); + assertTrue("Pre: must exist in ES", esImpl().indexExists(DUAL_WORKING)); + assertTrue("Pre: must exist in OS", osIndexAPI.indexExists(physicalDualWorking)); + + contentletIndexAPI().delete(IndexTag.OS.tag(DUAL_WORKING)); // delete by the .os name + + assertTrue("ES twin must survive a .os-targeted delete (no reverse cascade)", + esImpl().indexExists(DUAL_WORKING)); + assertFalse("OS .os index must be gone", osIndexAPI.indexExists(physicalDualWorking)); + + Logger.info(this, "✅ delete Phase 1 by .os name — OS only, ES preserved: " + DUAL_WORKING); + } + // ========================================================================= // Orphan bootstrap repair — bare cluster index missing from the store (#36237) // ========================================================================= From de67363ef450950a564c572c972553f8cb061d4d Mon Sep 17 00:00:00 2001 From: fabrizzio-dotCMS Date: Fri, 3 Jul 2026 20:56:54 -0600 Subject: [PATCH 6/6] fix(search): bidirectional index delete cascade, resilient, always clean DB (#35640) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per support requirements, index delete now: - Cascades bidirectionally (ES↔OS): deleting by either the bare or the .os name removes the index in every engine that holds it. The cascade broadcasts the logical (untagged) name so each provider re-derives its own physical name. FEATURE_FLAG_INDEX_DELETE_CASCADE=false falls back to single-engine (tag-dispatch). - Never interrupts on failure: each engine's cluster delete runs in its own try/catch, and the DB-pointer cleanup runs in a separate try/catch afterward, so a failure in one step never aborts the rest. - Always clears the indicies DB pointer: for each engine deleted, any indicies row that resolves to the deleted logical name is removed (ES store via IndiciesInfo/point, OS store via VersionedIndices/saveIndices), matched on the cluster-stripped, untagged name. This closes the QA finding where deleting an index left a dangling DB row. Tests: delete_clearsDbPointer, test_delete_phase1_byOsName_removesFromBothClusters. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../business/ContentletIndexAPIImpl.java | 140 ++++++++++++++---- ...tIndexAPIImplMigrationIntegrationTest.java | 12 +- .../business/ContentletIndexAPIImplTest.java | 47 ++++++ 3 files changed, 166 insertions(+), 33 deletions(-) diff --git a/dotCMS/src/main/java/com/dotcms/content/elasticsearch/business/ContentletIndexAPIImpl.java b/dotCMS/src/main/java/com/dotcms/content/elasticsearch/business/ContentletIndexAPIImpl.java index a65e5e2b17a..ecb34362ab9 100644 --- a/dotCMS/src/main/java/com/dotcms/content/elasticsearch/business/ContentletIndexAPIImpl.java +++ b/dotCMS/src/main/java/com/dotcms/content/elasticsearch/business/ContentletIndexAPIImpl.java @@ -1678,16 +1678,16 @@ private void logSwitchover(final IndiciesInfo oldInfo, final String luckyServer) public static final String FF_ALLOW_ACTIVE_INDEX_DELETE = "FEATURE_FLAG_ALLOW_ACTIVE_INDEX_DELETE"; /** - * Controls whether deleting the authoritative ES/logical index also removes its OS twin. - * On by default. The cascade is strictly ES→OS: it only fires when the - * caller passes a bare (untagged) name. An explicit {@code .os} name is always a - * tag-dispatched, OS-only delete regardless of this flag — a shadow delete must never - * tumble the authoritative ES index. + * Controls whether {@link #delete(String)} cascades to the index's twin in the other engine. + * On by default. The cascade is bidirectional: deleting a logical index by + * either its ES (bare) name or its OpenSearch ({@code .os}) name removes the index in every + * engine that holds it in the current phase (ES + OS twin during dual-write). *
    - *
  • true (default) — a bare name deletes the ES index and its OS - * {@code .os} twin together (phase-dispatched broadcast), so the twin is not orphaned.
  • - *
  • false — a bare name deletes only ES, leaving the OS twin intact - * (issue #35640, TC-016 documented behavior).
  • + *
  • true (default) — delete both twins together, so neither engine is + * left with an orphan copy.
  • + *
  • false — tag-dispatched, single engine only: a {@code .os} name + * deletes OpenSearch, any other name deletes Elasticsearch; the twin is left intact + * (issue #35640, TC-016).
  • *
*/ public static final String FF_INDEX_DELETE_CASCADE = "FEATURE_FLAG_INDEX_DELETE_CASCADE"; @@ -1739,52 +1739,138 @@ public boolean delete(String indexName) { // Guard first: never delete an active/building index (issue #35640, TC-018). assertIndexIsDeletable(indexName); - // The cascade is strictly ES→OS: deleting the authoritative ES/logical index also - // removes its OS twin, but deleting an explicit .os index NEVER touches ES (a shadow - // delete must not tumble the authoritative index). See FF_INDEX_DELETE_CASCADE - // (issue #35640, TC-016). + // Bidirectional cascade (default): deleting by either the ES (bare) or OS (.os) name + // removes the index in every engine that holds it. When disabled, only the engine that + // owns the given name is touched. See FF_INDEX_DELETE_CASCADE (issue #35640, TC-016). final boolean cascade = Config.getBooleanProperty(FF_INDEX_DELETE_CASCADE, true); - final boolean osTagged = IndexTag.OS.isTagged(indexName); final ContentletIndexOperations primary; final List targets; - if (osTagged || !cascade) { - // Tag-dispatched, single engine. An explicit .os name always targets OS only - // (never cascades back to ES). A bare name with cascade off targets ES only. - primary = osTagged ? router.osImpl() : router.esImpl(); - targets = List.of(primary); - } else { - // Bare name + cascade on: phase-dispatched broadcast to every write provider so - // the ES delete also removes its OS twin (ES→OS, no orphan shadow). The bare name - // lets each provider re-derive its OWN physical name (ES → bare, OS → .os). - // Track the read provider's result as the primary; shadow failures in dual-write - // phases are fire-and-forget. + final String nameForTargets; + if (cascade) { + // Broadcast the LOGICAL (untagged) name to every write provider so each re-derives + // its OWN physical name (ES → bare, OS → .os) and both twins are removed regardless + // of which name the caller passed. Track the read provider's result as the primary. primary = router.readProvider(); targets = router.writeProviders(); + nameForTargets = IndexTag.OS.untag(indexName); + } else { + // Tag-dispatched, single engine: .os → OS, otherwise ES. The twin is left intact. + primary = IndexTag.OS.isTagged(indexName) ? router.osImpl() : router.esImpl(); + targets = List.of(primary); + nameForTargets = indexName; } + // The logical (cluster-stripped, untagged) name is used to find and clear DB pointers. + final String logicalName = IndexTag.OS.untag(indexAPI.removeClusterIdFromName(indexName)); + boolean primaryResult = false; for (final ContentletIndexOperations ops : targets) { // Resolve the per-provider physical name (ES → bare, OS → .os tag) via // ops.toPhysicalName so the delete targets the REAL index. Routing a bare // logical name straight to OSIndexAPIImpl.delete would hit an untagged name // that does not exist and orphan the actual .os index. - final String physicalName = ops.toPhysicalName(indexName); + final String physicalName = ops.toPhysicalName(nameForTargets); + // (1) Delete the cluster index. A failure here must NOT abort the operation — + // keep going so the remaining engine and the DB pointers are still handled. try { final boolean r = ops.indexAPI().delete(physicalName); if (ops == primary) { primaryResult = r; } - } catch (Exception e) { + } catch (final Exception e) { Logger.error(this.getClass(), "Error while deleting index " + physicalName, e); if (ops == primary) { primaryResult = false; } // shadow failures are fire-and-forget in dual-write phases } + // (2) Always remove the indicies-table pointer for this engine, even if the cluster + // delete above failed, so no DB row is left dangling at a deleted index. + try { + clearStorePointer(ops, logicalName); + } catch (final Exception e) { + Logger.warn(this.getClass(), + "Could not clear the indicies DB pointer for " + physicalName, e); + } } return primaryResult; } + /** + * Removes any {@code indicies} row that points at {@code logicalName} in the store that backs + * {@code ops}: the OpenSearch provider clears the versioned store, any other provider clears + * the legacy ES store. Best-effort — the caller wraps this so a failure never aborts the + * delete (issue #35640). + */ + private void clearStorePointer(final ContentletIndexOperations ops, final String logicalName) + throws DotDataException { + if (ops == router.osImpl()) { + clearOsStorePointer(logicalName); + } else { + clearEsStorePointer(logicalName); + } + } + + /** + * True when {@code storedName} resolves to the same logical (cluster-stripped, untagged) name + * as {@code logicalName}. Lets a DB slot be matched regardless of the prefix/tag form it was + * stored in. + */ + private boolean matchesLogical(final String storedName, final String logicalName) { + return storedName != null + && logicalName.equals(IndexTag.OS.untag(indexAPI.removeClusterIdFromName(storedName))); + } + + /** Clears any legacy ES-store ({@link IndiciesInfo}) slot pointing at {@code logicalName}. */ + private void clearEsStorePointer(final String logicalName) throws DotDataException { + final IndiciesInfo info = legacyIndiciesAPI.loadIndicies(); + final IndiciesInfo.Builder builder = IndiciesInfo.Builder.copy(info); + boolean changed = false; + if (matchesLogical(info.getWorking(), logicalName)) { builder.setWorking(null); changed = true; } + if (matchesLogical(info.getLive(), logicalName)) { builder.setLive(null); changed = true; } + if (matchesLogical(info.getReindexWorking(), logicalName)) { builder.setReindexWorking(null); changed = true; } + if (matchesLogical(info.getReindexLive(), logicalName)) { builder.setReindexLive(null); changed = true; } + if (matchesLogical(info.getSiteSearch(), logicalName)) { builder.setSiteSearch(null); changed = true; } + if (changed) { + legacyIndiciesAPI.point(builder.build()); + } + } + + /** Clears any OS versioned-store ({@link VersionedIndices}) slot pointing at {@code logicalName}. */ + private void clearOsStorePointer(final String logicalName) throws DotDataException { + final Optional existingOpt = versionedIndicesAPI.loadDefaultVersionedIndices(); + if (existingOpt.isEmpty()) { + return; + } + final VersionedIndices existing = existingOpt.get(); + final VersionedIndicesImpl.Builder builder = VersionedIndicesImpl.builder(); + boolean changed = false; + // Re-add every slot except the one(s) that resolve to the deleted logical name. + if (existing.working().isPresent()) { + if (matchesLogical(existing.working().get(), logicalName)) { changed = true; } + else { builder.working(existing.working().get()); } + } + if (existing.live().isPresent()) { + if (matchesLogical(existing.live().get(), logicalName)) { changed = true; } + else { builder.live(existing.live().get()); } + } + if (existing.reindexWorking().isPresent()) { + if (matchesLogical(existing.reindexWorking().get(), logicalName)) { changed = true; } + else { builder.reindexWorking(existing.reindexWorking().get()); } + } + if (existing.reindexLive().isPresent()) { + if (matchesLogical(existing.reindexLive().get(), logicalName)) { changed = true; } + else { builder.reindexLive(existing.reindexLive().get()); } + } + if (existing.siteSearch().isPresent()) { + if (matchesLogical(existing.siteSearch().get(), logicalName)) { changed = true; } + else { builder.siteSearch(existing.siteSearch().get()); } + } + if (changed) { + versionedIndicesAPI.saveIndices(builder.build()); + } + } + public boolean optimize(List indexNames) { return indexAPI.optimize(indexNames); } diff --git a/dotcms-integration/src/test/java/com/dotcms/content/elasticsearch/business/ContentletIndexAPIImplMigrationIntegrationTest.java b/dotcms-integration/src/test/java/com/dotcms/content/elasticsearch/business/ContentletIndexAPIImplMigrationIntegrationTest.java index 524202375d4..01f2b89fa8c 100644 --- a/dotcms-integration/src/test/java/com/dotcms/content/elasticsearch/business/ContentletIndexAPIImplMigrationIntegrationTest.java +++ b/dotcms-integration/src/test/java/com/dotcms/content/elasticsearch/business/ContentletIndexAPIImplMigrationIntegrationTest.java @@ -480,12 +480,12 @@ public void test_delete_phase1_cascadeOff_removesOnlyNamedEngine() * Given Scenario: Phase 1 (dual-write). {@code DUAL_WORKING} exists in both clusters. * When : {@code delete()} is called with the {@code .os}-tagged name (as the QA/preview UI * shows the OS index), with cascade on (default). - * Then : ONLY the OS index is removed; the authoritative ES twin is left intact. The cascade - * is strictly ES→OS — an explicit {@code .os} name is a tag-dispatched OS-only delete - * and never tumbles the ES index (issue #35640). + * Then : BOTH twins are removed — the cascade is bidirectional, so deleting by the OS name + * also removes the ES twin (the tag is stripped to the logical name and broadcast to + * every provider). Regression for the one-directional-cascade bug (issue #35640). */ @Test - public void test_delete_phase1_byOsName_removesOnlyOs() + public void test_delete_phase1_byOsName_removesFromBothClusters() throws IOException, DotIndexException { setPhase(1); @@ -495,11 +495,11 @@ public void test_delete_phase1_byOsName_removesOnlyOs() contentletIndexAPI().delete(IndexTag.OS.tag(DUAL_WORKING)); // delete by the .os name - assertTrue("ES twin must survive a .os-targeted delete (no reverse cascade)", + assertFalse("ES twin must be gone when deleting by the .os name (bidirectional cascade)", esImpl().indexExists(DUAL_WORKING)); assertFalse("OS .os index must be gone", osIndexAPI.indexExists(physicalDualWorking)); - Logger.info(this, "✅ delete Phase 1 by .os name — OS only, ES preserved: " + DUAL_WORKING); + Logger.info(this, "✅ delete Phase 1 by .os name — both removed: " + DUAL_WORKING); } // ========================================================================= diff --git a/dotcms-integration/src/test/java/com/dotcms/content/elasticsearch/business/ContentletIndexAPIImplTest.java b/dotcms-integration/src/test/java/com/dotcms/content/elasticsearch/business/ContentletIndexAPIImplTest.java index cd6c6b3d03a..ae723cf7ce4 100644 --- a/dotcms-integration/src/test/java/com/dotcms/content/elasticsearch/business/ContentletIndexAPIImplTest.java +++ b/dotcms-integration/src/test/java/com/dotcms/content/elasticsearch/business/ContentletIndexAPIImplTest.java @@ -478,6 +478,53 @@ public void delete_activeIndex_isRejected_unlessFeatureFlagOverrides() throws Ex } } + /** + * Issue #35640: deleting an index must also remove its pointer row from the {@code indicies} + * DB table, so no row is left dangling at a deleted index. Uses the feature-flag bypass to + * delete an active index (whose pointer exists in the store) and asserts the pointer is gone. + * + * @see ContentletIndexAPIImpl#delete(String) + */ + @Test + public void delete_clearsDbPointer() throws Exception { + + final String timeStamp = String.valueOf(new Date().getTime()); + final String workingIndex = IndexType.WORKING.getPrefix() + "_" + timeStamp; + + final String oldActiveWorking = indexAPI.getActiveIndexName(IndexType.WORKING.getPrefix()); + + assertTrue(indexAPI.createContentIndex(workingIndex)); + indexAPI.activateIndex(workingIndex); + + // Sanity: the working pointer now references the new index. + final IndiciesInfo before = APILocator.getIndiciesAPI().loadIndicies(); + assertTrue("Pre: working pointer must reference the new index", + before.getWorking() != null && before.getWorking().endsWith(workingIndex)); + + // Delete it, bypassing the active-index guard — the DB pointer must be cleared too. + Config.setProperty(ContentletIndexAPIImpl.FF_ALLOW_ACTIVE_INDEX_DELETE, true); + try { + assertTrue(indexAPI.delete(workingIndex)); + } finally { + Config.setProperty(ContentletIndexAPIImpl.FF_ALLOW_ACTIVE_INDEX_DELETE, false); + } + + final IndiciesInfo after = APILocator.getIndiciesAPI().loadIndicies(); + assertTrue("Working pointer must no longer reference the deleted index", + after.getWorking() == null || !after.getWorking().endsWith(workingIndex)); + assertFalse("Deleted index must be gone from the cluster", + indexAPI.listDotCMSIndices().contains(workingIndex)); + + // Restore the prior active working pointer (best effort). + try { + if (oldActiveWorking != null) { + indexAPI.activateIndex(oldActiveWorking); + } + } catch (final Exception e) { + Logger.warn(this, "Unable to restore active working index after test", e); + } + } + /** * Testing {@link ContentletIndexAPI#isDotCMSIndexName(String)} *