fix(search): guard active-index deletion and gate ES/OS delete cascade (#35640)#36399
fix(search): guard active-index deletion and gate ES/OS delete cascade (#35640)#36399fabrizzio-dotCMS wants to merge 6 commits into
Conversation
|
Tick the box to add this pull request to the merge queue (same as
|
|
Claude finished @fabrizzio-dotCMS's task in 1m 58s —— View job Rollback-Safety Analysis — Complete ✅
Verdict: ✅ Safe to Rollback — label Scope reviewed:
Why no category matches:
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. |
🤖 dotBot Review (Bedrock)Reviewed 4 file(s); 3 candidate(s) → 2 confirmed, 0 uncertain (unverified, kept for review). Confirmed findings
us.deepseek.r1-v1:0 · Run: #28692793584 · tokens: in: 28891 · out: 10726 · total: 39617 · calls: 9 · est. ~$0.097 |
#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>
73fa8a3 to
3311769
Compare
| // latter (issue #35640, TC-016). | ||
| final String resolvedName = indexAPI.removeClusterIdFromName(indexName); | ||
|
|
||
| if(indexDoesNotExist(resolvedName) ){ |
There was a problem hiding this comment.
🟠 [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>
| // (.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); | ||
| } |
There was a problem hiding this comment.
🟠 [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(); | ||
| } |
There was a problem hiding this comment.
🟠 [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(); |
There was a problem hiding this comment.
🟠 [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" |
There was a problem hiding this comment.
🟠 [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>
Problem
QA on #35640 (epic #35476, TC-016–020) surfaced defects in the index delete path (
DELETE /api/v1/esindex/{name}→ContentletIndexAPIImpl.delete()):.ostwin — undocumented and not configurable.indexExistsreturnedtruewhen the index did not exist).Changes
TC-018 — active-index guard (
ContentletIndexAPIImpl.delete)getCurrentIndex()∪getNewIndex()— the same sources the maintenance UI uses to flag active/building — viaDotStateException, mapped to HTTP 400 inESIndexResource.FEATURE_FLAG_ALLOW_ACTIVE_INDEX_DELETE(defaultfalse).VersionedIndicesdirectly, becauseVersionedIndicesis 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)FEATURE_FLAG_INDEX_DELETE_CASCADE(defaulttrue).router.writeProviders()— deletes the index and its twin (current [DEFECT] Confusing empty OS index appears in indices portlet on Phase 1 migration bootstrap #35820 behavior, no orphan)..os→ OpenSearch, otherwise Elasticsearch) — deletes only the named engine's index, leaving the twin intact (the documented no-cascade behavior).TC-016/017 — cleanups (
ESIndexResource)removeClusterIdFromNamein bothdeleteIndexandmodIndex, so the endpoints accept both the short name and the full physical name (no more false 404).indexExists→indexDoesNotExist(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,.ostwin survives (sibling to the existing cascade-ON test).openapi.yamldiff.Out of scope (follow-ups)
🤖 Generated with Claude Code