feat(search): delete a site-search index across both engines (#35640) - #36439
Conversation
#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>
…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>
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>
…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>
…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>
…on (#35640) The /api/v1/esindex/cache endpoint passed the full mixed index list (ES bare + OS .os names) to both providers via router.writeReturning, so each engine received the other engine's names, hit index_not_found_exception on them, and the OS flush depended on the .os names being present in the list. Fix IndexAPIImpl.flushCaches to mirror the already-corrected optimize(): tag- dispatch the list by IndexTag.resolve and flush each provider only with the names it owns (ES untagged, OS .os), skipping empty subsets so Phase 0 never contacts OS and Phase 3 never contacts ES. Shard counts are aggregated across the providers actually contacted. The fix stays in the router; ESIndexAPI and OSIndexAPIImpl remain bare-symmetric for direct callers. Test: test_flushCaches_phase1_flushesBothEnginesWithoutCrossContamination. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
#35640) Per the transparent-mirror design principle (the operator sees a single index; every user-triggered index operation applies to the full ES+OS mirror), the delete cascade is not optional — turning it off would deliberately leave an orphan twin, which is exactly what the principle forbids. Remove FEATURE_FLAG_INDEX_DELETE_CASCADE and its single-engine branch; delete() always broadcasts the logical (untagged) name to every write provider. Drop the now-obsolete cascade-off test. Also tidy a raw-type ResponseEntityView in the cache-flush endpoint. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…to both engines (#35640) Per the transparent-mirror principle (the operator sees a single index; every user-triggered index op applies to the full ES+OS mirror), the single-name lifecycle ops must reach the real index on BOTH engines. They fanned the same name to both providers, so the OS leg used getNameWithClusterIDPrefix without the .os tag and silently missed the real OS index (only ES was affected). - Add IndexAPIImpl.providerName(provider, name): resolves the per-engine physical name (OS → .os-tagged, others → bare) so each provider targets its own index. Site-search is carved out — its OS copy is not .os-tagged, so it stays bare. - Route clearIndex, openIndex, closeIndex and updateReplicas through it (inside the existing router lambda, so PhaseRouter fan-out + fire-and-forget are preserved). Doc: OPENSEARCH_MIGRATION.md gains the transparent-mirror guiding principle; the ops table flips user-triggered lifecycle to "replicate = yes" (reindex stays the sole feasibility exception); the fan-out open-issue section is updated to resolved. Test: test_closeAndOpen_phase1_mirrorToBothEngines (close/open by bare name closes and reopens both twins; exercises the shared providerName routing). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Deleting a site-search index from the maintenance endpoints routed through the content delete path, which mis-tagged the OpenSearch name (site-search OS indices are plain-named, not .os-tagged) and would orphan the OS copy. Site search is already OS-aware and phase-routed, but had no per-index delete. - Add SiteSearchAPI.deleteIndex(name); implement in ESSiteSearchAPI and OSSiteSearchAPI (each deletes only its own engine via its direct index API). - SiteSearchAPIImpl fans it out via the site-search PhaseRouter and guards the active/default index (isDefaultIndex → DotStateException; deactivate first). - Branch ESIndexResource.deleteIndex and IndexAjaxAction.deleteIndex to the site-search API for sitesearch_* names (mirrors activate/deactivate). Site-search indices are not in listDotCMSIndices(), so the branch runs before the content existence check. Tests: SiteSearchDualWriteRouterIT#test_deleteIndex_removesFromBothBackends and #test_deleteIndex_activeIndex_isRejected. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Tick the box to add this pull request to the merge queue (same as
|
|
Claude finished @fabrizzio-dotCMS's task in 3m 3s —— View job Rollback-Safety Analysis — Complete ✅
Verdict: ✅ Safe to rollback — no category from the reference doc is triggered. Reviewed all 12 changed files against every category (C-1..C-4, H-1..H-8, M-1..M-4):
Label |
🤖 dotBot Review (Bedrock)Reviewed 12 file(s); 12 candidate(s) → 6 confirmed, 1 uncertain (unverified, kept for review).
Confirmed findings
🔎 Uncertain (could not confirm or disprove — review manually)
us.deepseek.r1-v1:0 · Run: #28820668426 · tokens: in: 79947 · out: 28895 · total: 108842 · calls: 31 · est. ~$0.264 |
…s on rejected delete (#35640) Extend test_deleteIndex_activeIndex_isRejected to assert the active index still exists in Elasticsearch as well as OpenSearch after the rejected delete, per AI review feedback. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Addressing the remaining AI-review findings (no inline threads for these):
|
Reviewer summary — AI review gate is a non-converging false positive; needs a human mergeStatus: all review threads are resolved (7/7). The only red check is the advisory Every finding has been verified against the current code and rebutted with evidence. None is a real defect. Summary: 1. "Missing permission check for site-search delete" ( 2. "Partial index deletion / no rollback across engines" ( The 5 findings from the prior run (license silent-return, non-atomic deletion, TOCTOU on the active-index guard, permission check, partial deletion) were the same class and were all rebutted + resolved. No code changes are warranted. Adding cross-cluster 2PC or a redundant permission check would contradict the migration model and add dead/incorrect code purely to satisfy a non-deterministic bot. Ask: please review the two source changes on their merits and merge past the advisory AI gate. Related content-side PR #36399 (same issue #35640) is fully green with all threads resolved. |
…h-delete # Conflicts: # docs/backend/OPENSEARCH_MIGRATION.md # dotCMS/src/main/java/com/dotcms/content/elasticsearch/business/ContentletIndexAPIImpl.java # dotCMS/src/main/java/com/dotcms/rest/api/v1/index/ESIndexResource.java # dotCMS/src/main/java/com/dotmarketing/portlets/cmsmaintenance/ajax/IndexAjaxAction.java # dotcms-integration/src/test/java/com/dotcms/content/elasticsearch/business/ContentletIndexAPIImplMigrationIntegrationTest.java
…, not an FF (#35640) Address review feedback (@nollymar): the active-index-delete bypass is a maintenance/ emergency override property, not a product feature flag. Removed it from the FeatureFlagName interface and renamed the constant/key from FEATURE_FLAG_ALLOW_ACTIVE_INDEX_DELETE to a plain ALLOW_ACTIVE_INDEX_DELETE property key on ContentletIndexAPIImpl. Updated the "feature flag" wording in javadoc and the guard error message to "config property", and the test references. Supersedes the earlier move into FeatureFlagName (@jcastro): centralizing was the right instinct, but since it is not a feature flag it should not live in that registry. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Problem
Deleting a site-search index from the maintenance endpoints (
DELETE /api/v1/esindex/{name}, the DWRIndexAjaxAction) routed through the content delete path. Site-search OS indices are plain-named (not.os-tagged — separate cluster, ownsiteSearchslot), so the content path computed a.osname that doesn't exist on OS and would orphan the OpenSearch site-search copy. Site search is already OS-aware and phase-routed, but had no per-index delete (onlydeleteFromIndex(doc)and bulkdeleteOldSiteSearchIndices).Changes
SiteSearchAPI.deleteIndex(name)— new method; implemented inESSiteSearchAPIandOSSiteSearchAPI. Each deletes only its own engine via that engine's direct index API (new ESIndexAPI()/OSIndexAPIImpl), never the neutral router — theSiteSearchAPIImplrouter is the single fan-out point (avoids a double dual-write).SiteSearchAPIImpl.deleteIndex— fans out via the site-searchPhaseRouterand guards the active index:isDefaultIndex(name)(phase-aware read) →DotStateException(deactivate first), mirroring the content active-index guard.sitesearch_*—ESIndexResource.deleteIndex(newdeleteSiteSearchIndexhelper: 404 viasiteSearchAPI.listIndices(), 400 on active, 500 on engine error) andIndexAjaxAction.deleteIndex(mirrors the existing activate/deactivate branch). Site-search indices are not inlistDotCMSIndices(), so the branch runs before the content existence check.Testing
SiteSearchDualWriteRouterIT#test_deleteIndex_removesFromBothBackends— create via the router, delete, gone from both ES and OS.SiteSearchDualWriteRouterIT#test_deleteIndex_activeIndex_isRejected— the active/default site-search index cannot be deleted (survives; deactivate first).Notes
issue-35640-index-delete-guard-and-cascade-ff(PR fix(search): guard active-index deletion and gate ES/OS delete cascade (#35640) #36399) because it editsESIndexResource.deleteIndex, which fix(search): guard active-index deletion and gate ES/OS delete cascade (#35640) #36399 rewrites. Retarget tomainonce fix(search): guard active-index deletion and gate ES/OS delete cascade (#35640) #36399 merges.🤖 Generated with Claude Code
This PR fixes: #35640