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..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
@@ -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,33 +1669,208 @@ 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. 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) — 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";
+
+ /**
+ * 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;
+ }
+ // 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 {
+ 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);
+ }
+ 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);
+
+ // 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 ContentletIndexOperations primary;
+ final List targets;
+ 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 : router.writeProviders()) {
- final String physicalName = ops.toPhysicalName(indexName);
+ 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(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/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..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;
@@ -46,6 +47,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 +388,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<>(List.of(new ErrorEntity("INDEX_NOT_FOUND", "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<>(List.of(new ErrorEntity("INDEX_NOT_DELETABLE", e.getMessage())))).build();
}
-
- idxApi.delete(indexName);
- String message = "Index:" + indexName + " deleted";
+ String message = "Index:" + resolvedName + " deleted";
sendAdminMessage(message, MessageSeverity.INFO,init.getUser(), 5000);
@@ -541,30 +560,37 @@ public Response modIndex(@Context final HttpServletRequest request, @Context fin
final InitDataObject init = auth(request, response);
final IndexAction indexAction = IndexAction.fromString(action);
-
- if(indexExists(indexName) ){
- return Response.status(404).build();
+
+ // 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) ){
+ // Readable 404 body, consistent with deleteIndex (issue #35640, TC-017).
+ return Response.status(Response.Status.NOT_FOUND)
+ .entity(new ResponseEntityView<>(List.of(new ErrorEntity("INDEX_NOT_FOUND", "Index not found: " + indexName)))).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);
@@ -590,7 +616,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..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
@@ -444,6 +444,64 @@ 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);
+ }
+
+ /**
+ * 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 : 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_removesFromBothClusters()
+ 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
+
+ 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 — both 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..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
@@ -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,100 @@ 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);
+ }
+ }
+ }
+
+ /**
+ * 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)}
*