Skip to content

fix(search): guard active-index deletion and gate ES/OS delete cascade (#35640)#36399

Open
fabrizzio-dotCMS wants to merge 6 commits into
mainfrom
issue-35640-index-delete-guard-and-cascade-ff
Open

fix(search): guard active-index deletion and gate ES/OS delete cascade (#35640)#36399
fabrizzio-dotCMS wants to merge 6 commits into
mainfrom
issue-35640-index-delete-guard-and-cascade-ff

Conversation

@fabrizzio-dotCMS

@fabrizzio-dotCMS fabrizzio-dotCMS commented Jul 1, 2026

Copy link
Copy Markdown
Member

Problem

QA on #35640 (epic #35476, TC-016–020) surfaced defects in the index delete path (DELETE /api/v1/esindex/{name}ContentletIndexAPIImpl.delete()):

  • TC-018 (High): deleting the currently active index was possible via REST/AJAX. 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.
  • TC-016: the open design question of whether delete should cascade to the .os twin — undocumented and not configurable.
  • TC-016/017: the endpoint returned a false 404 when given the full physical name (with cluster prefix), returned an empty 404 body, and had an inverted-sense helper (indexExists returned true when the index did not exist).

Changes

TC-018 — active-index guard (ContentletIndexAPIImpl.delete)

  • Rejects deletion of any index reported active/building by the phase-aware getCurrentIndex()getNewIndex() — the same sources the maintenance UI uses to flag active/building — via DotStateException, mapped to HTTP 400 in ESIndexResource.
  • Fail-closed if the active set can't be resolved.
  • Bypass: FEATURE_FLAG_ALLOW_ACTIVE_INDEX_DELETE (default false).
  • 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 — cascade feature flag (ContentletIndexAPIImpl.delete)

TC-016/017 — cleanups (ESIndexResource)

  • Normalize the incoming name with removeClusterIdFromName in both deleteIndex and modIndex, so the endpoints accept both the short name and the full physical name (no more false 404).
  • Readable 404 body instead of an empty response.
  • Rename indexExistsindexDoesNotExist (matches its actual meaning).

Testing

  • ContentletIndexAPIImplTest#delete_activeIndex_isRejected_unlessFeatureFlagOverrides — active index survives a rejected delete; FF bypass then allows it.
  • ContentletIndexAPIImplMigrationIntegrationTest#test_delete_phase1_cascadeOff_removesOnlyNamedEngine — Phase 1, cascade OFF: bare name deletes ES only, .os twin survives (sibling to the existing cascade-ON test).
  • Core compiles clean; integration module test-compiles clean; no openapi.yaml diff.

Out of scope (follow-ups)

  • Cascade for site-search indices (separate subsystem, deferred in the migration doc).
  • UI confirmation dialog on delete (requested by @jcastro-dotcms).

🤖 Generated with Claude Code

@mergify

mergify Bot commented Jul 1, 2026

Copy link
Copy Markdown

Tick the box to add this pull request to the merge queue (same as @mergifyio queue).

  • Queue this pull request

@claude

claude Bot commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Claude finished @fabrizzio-dotCMS's task in 1m 58s —— View job


Rollback-Safety Analysis — Complete ✅

  • Read docs/core/ROLLBACK_UNSAFE_CATEGORIES.md
  • Get full PR diff (base 3f0c5681...de67363e)
  • Analyze diff against every unsafe category
  • Post verdict (label)

Verdict: ✅ Safe to Rollback — label AI: Safe To Rollback applied.

Scope reviewed:

  • ContentletIndexAPIImpl.java — new assertIndexIsDeletable() guard, new FF_ALLOW_ACTIVE_INDEX_DELETE / FF_INDEX_DELETE_CASCADE flags, rewritten delete() cascade logic, new clearStorePointer/clearEsStorePointer/clearOsStorePointer helpers.
  • ESIndexResource.javaremoveClusterIdFromName normalization in deleteIndex/modIndex, structured 404/400 error bodies, indexExistsindexDoesNotExist rename (private method, not DB).
  • Two test files — test-only, no runtime impact.

Why no category matches:

  • No runonce DB migration task, no schema change (ADD/DROP/RENAME/ALTER COLUMN), no PK/constraint restructuring → rules out C-1, C-4, H-1, H-2, H-3, M-1.
  • No ESMappingAPIImpl/ESMappingUtilHelper/ESMappingConstants touch, no putMapping() call, no reindex trigger → rules out C-2.
  • No CURRENT_MODEL_VERSION / ImmutableContentlet / ToCurrentVersionConverter change → rules out C-3.
  • No new ContentType field type, no storage-provider default change, no stored-procedure drop → rules out H-4, H-5, H-6.
  • delete()'s new DB-pointer clearing (clearEsStorePointer/clearOsStorePointer) calls existing legacyIndiciesAPI.point() / versionedIndicesAPI.saveIndices() APIs that N-1 already understands — it removes stale pointers to now-deleted indices rather than introducing a new storage shape, so N-1 reads the same tables the same way.
  • No viewtool (ViewTool) touched → rules out H-8.
  • The REST changes (deleteIndex/modIndex) only add structured error bodies on 404/400 paths and fix an inverted existence check + false-404 on full physical names — these are additive/corrective, not a renamed/removed field or changed success-response shape that could break an already-deployed client → does not meet the M-3 bar ("renamed field", "removed type", "changed structure" that breaks a co-deployed consumer).
  • No OSGi-exported interface signature changed (ContentletIndexAPI.delete(String) keeps its signature) → rules out M-4.

Net effect: this PR only changes in-process guard/cascade/error-handling logic in the index-delete path. Rolling back to N-1 simply restores the pre-fix behavior (TC-016/017/018 defects reappear) — it does not corrupt data, break mappings, or leave N-1 unable to start or serve requests.

@github-actions

github-actions Bot commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

🤖 dotBot Review (Bedrock)

Reviewed 4 file(s); 3 candidate(s) → 2 confirmed, 0 uncertain (unverified, kept for review).

Confirmed findings

  • 🟡 Medium dotCMS/src/main/java/com/dotcms/rest/api/v1/index/ESIndexResource.java:576 — modIndex endpoint lacks error handling for index operation exceptions
    The modIndex method's switch cases (lines 576-592) call index operations without try-catch blocks. Exceptions like ElasticsearchClientException from deactivateIndex/clearIndex/openIndex/etc would propagate as unhandled exceptions, resulting in generic 500 errors instead of structured error responses. Contrast with deleteIndex which handles DotStateException and others with specific status codes (400/404/500) and error entities.
  • 🟡 Medium dotCMS/src/main/java/com/dotcms/rest/api/v1/index/ESIndexResource.java:576 — Race condition between index existence check and delete operation
    The code checks if the index exists via indexAPI.indexDoesNotExist() then proceeds to delete. However, there's no atomicity between check and deletion - another process could delete the index in between, leading to potential exceptions from trying to delete a non-existent index. While Elasticsearch/OpenSearch may handle non-existent deletes gracefully, the REST endpoint currently doesn't explicitly handle this case, which could surface 500 errors to callers.

us.deepseek.r1-v1:0 · Run: #28692793584 · tokens: in: 28891 · out: 10726 · total: 39617 · calls: 9 · est. ~$0.097

Comment thread dotCMS/src/main/java/com/dotcms/rest/api/v1/index/ESIndexResource.java Outdated
fabrizzio-dotCMS and others added 2 commits July 2, 2026 10:12
#35640)

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) <noreply@anthropic.com>
…5640)

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) <noreply@anthropic.com>
@fabrizzio-dotCMS fabrizzio-dotCMS force-pushed the issue-35640-index-delete-guard-and-cascade-ff branch from 73fa8a3 to 3311769 Compare July 2, 2026 16:12
@fabrizzio-dotCMS fabrizzio-dotCMS changed the base branch from issue-36396-esraw-phase-aware to main July 2, 2026 16:12
// latter (issue #35640, TC-016).
final String resolvedName = indexAPI.removeClusterIdFromName(indexName);

if(indexDoesNotExist(resolvedName) ){

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 [High] 404 response in modIndex lacks error message body

The modIndex method throws NotFoundInDbException which returns an empty 404 response body, while deleteIndex uses ResponseUtil.mapErrorResponse() to return structured JSON errors. This inconsistency breaks client error handling expectations.

…ex (#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) <noreply@anthropic.com>
@github-actions github-actions Bot added the Area : Backend PR changes Java/Maven backend code label Jul 3, 2026
// (.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);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 [High] Cascade delete ignores secondary provider failures

In ContentletIndexAPIImpl.java line 1750, when cascade is enabled, the method deletes the index across all writeProviders but only checks the primary provider's result. If secondary providers (e.g. OpenSearch) fail to delete their indices, the overall operation still reports success based on the primary provider's success. This can leave orphaned indices in secondary systems while returning a successful response.

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) <noreply@anthropic.com>
// 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();
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 [High] Unhandled exceptions in index deletion may result in 500 errors

The deleteIndex method in ESIndexResource.java only catches DotStateException and DotSecurityException. Other exceptions (e.g., IOException from Elasticsearch client, runtime errors) will propagate unchecked, resulting in 500 Internal Server Error responses. This violates REST error handling conventions and exposes internal details.

…35640)

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) <noreply@anthropic.com>
// 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();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 [High] Unhandled exceptions in index deletion

The try-catch block in ESIndexResource.deleteIndex() only handles specific exception types (DotSecurityException, DotStateException, IllegalArgumentException). Other exceptions from idxApi.delete() like ES client errors or runtime exceptions would propagate as uncaught exceptions, resulting in raw 500 errors exposing internal details. This violates error handling best practices and could leak sensitive stack trace information to API consumers.

}
if (protectedIndices.contains(requested)) {
throw new DotStateException("Index '" + indexName
+ "' is active or being rebuilt and cannot be deleted. Deactivate it first"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 [High] Cascade deletions may leave orphaned indices when secondary provider fails

The code performs fire-and-forget deletions on secondary providers after primary ES deletion. When FEATURE_FLAG_INDEX_DELETE_CASCADE=true, failure in OpenSearch deletion leaves orphaned index while API returns success. Current implementation only returns result from first provider deletion (line 1733), not verifying cascade completions.

…ean DB (#35640)

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) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

AI: Safe To Rollback Area : Backend PR changes Java/Maven backend code

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

1 participant