fix(search): guard active-index deletion and gate ES/OS delete cascade (#35640)#36399
Conversation
|
Tick the box to add this pull request to the merge queue (same as
|
|
Claude finished @fabrizzio-dotCMS's task in 13m 18s —— View job Code Review — PR #36399I reviewed the diff against New Issues
Notes (non-blocking)
Resolved (from prior review threads)
The 🟠 High · Branch: |
🤖 dotBot Review (Bedrock)Reviewed 6 file(s); 7 candidate(s) → 6 confirmed, 0 uncertain (unverified, kept for review). Confirmed findings
us.deepseek.r1-v1:0 · Run: #28821384283 · tokens: in: 64457 · out: 19422 · total: 83879 · calls: 17 · est. ~$0.192 |
#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
…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>
…uded (#35640) The doc claimed a user-triggered full reindex was "the one exception" to the transparent-mirror principle and only rebuilt ES. That is stale: from Phase 1 on the reindex fans out to OS too — initAndPointReindex creates the OS reindex slots (router fan-out), the journal worker dual-writes every reindexed document (DualIndexBulkRequest), and the switchover is phase-aware (fullReindexSwitchover mirrors the promotion; Phase 3 delegates to fullReindexSwitchoverOS). Only Phase 0 rebuilds ES alone (no shadow index exists yet). Reconcile all five places that carried the old claim (Accepted limitation, Full reindex behavior, Operations-to-Replicate table, Guiding principle, Deferred list): the "not viable at OS scale" note survives only as an operational caveat, not a code-level exclusion. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…n FeatureFlagName (#35640) Address review feedback (@jcastro-dotcms): the active-index-delete bypass flag should use the existing FeatureFlagName interface rather than a standalone string literal. Move the flag name to FeatureFlagName (next to FEATURE_FLAG_OPEN_SEARCH_PHASE) and have ContentletIndexAPIImpl reference it; the local FF_ALLOW_ACTIVE_INDEX_DELETE constant remains as a compile-time alias so existing {@value} javadoc and message references keep working. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…aths (#35640) Address review feedback (@swicken) — four real gaps in the TC-018 hardening: 1. Phases 1/2 protected set came from the ES store only (the guard reused the display-oriented getCurrentIndex/getNewIndex, which read OS only in Phase 3). A divergently-named active OS index — the one serving reads in Phase 2 — was not protected and could be deleted. The guard now collects the protected set from BOTH stores in the dual-write phases (new collectProtectedLogicalNames): Phase 0 = ES, Phases 1/2 = ES ∪ OS, Phase 3 = OS. 2. PUT ?action=clear (and legacy /clear, IndexAjaxAction.clearIndex) is delete + recreate-empty with no guard — same wipe-the-active-index hole as delete. Hoisted the check into a reusable ContentletIndexAPI.assertIndexNotActive(name, operation) and consult it from IndexAPIImpl.clearIndex; modIndex now returns a clean 400. 3. Legacy AJAX IndexAjaxAction.deleteIndex called ESIndexAPI.delete (IndexAPIImpl.delete), bypassing the guard, the bidirectional cascade, and the DB-pointer cleanup — TC-018 stayed open there and the .os twin was orphaned. Routed it through ContentletIndexAPI.delete like the REST endpoint. 4. clearOsStorePointer rebuilt an empty VersionedIndices when deleting the last populated slot; saveIndices contractually rejects that ("At least one index must be specified"), so the dangling pointer survived and initOSCatchup would resurrect the index on restart. Now removes the store row (removeVersion) when no slot remains. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
swicken
left a comment
There was a problem hiding this comment.
Changes look good, feedback correctly resolved. Approved!
…wicken review) Adds three phase-aware ITs to ContentletIndexAPIImplMigrationIntegrationTest (already registered in OpenSearchUpgradeSuite): - test_delete_phase2_divergentOsName_isProtectedByGuard — ES and OS stores diverge; the OS-store active index (serving Phase-2 reads) is now protected by the union guard even though its name is absent from the ES store. Fills the gap swicken flagged: the prior test only covered the same-timestamp twin. - test_clear_phase1_activeIndex_isRejectedUnlessBypass — clear (delete + recreate) is now rejected on the active index with both twins surviving, and goes through under the bypass flag. - test_delete_lastOsSlot_removesStoreRowInsteadOfDanglingPointer — deleting the last populated OS slot removes the store row (removeVersion) instead of leaving a dangling pointer that saveIndices would reject and initOSCatchup would resurrect. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Reconcile the #36423 delete fan-out fix with the transparent-mirror delete() rewrite that landed on main via #35640/#36399. Conflicts resolved by KEEPING main's behavior and layering only the #36423 noise fix on top: - ContentletIndexAPIImpl.delete(): keep main's guard (assertIndexNotActive), untag -> broadcast (bidirectional transparent mirror), and per-engine DB pointer cleanup (clearStorePointer). Add the #36423 refinement: the shadow leg exists-checks and SKIPS names its engine does not hold (logged via the shadow-write policy instead of an ERROR stack trace), and the catch splits logging into primary (ERROR + reflected in result) vs shadow (fire-and-forget policy log). The skip falls through so the DB pointer is still cleared. - Dropped the PR's tag-dispatch early-return for .os names: it contradicted main's deliberate bidirectional cascade (#35640) and would have bypassed the active-index guard and DB-pointer cleanup for tagged names. - Tests: kept main's cascade IT (test_delete_phase1_byOsName_removesFromBothClusters) and the PR's compatible bare-only-in-ES shadow-skip IT; removed the tag-dispatch IT. Rewrote the new unit test (ContentletIndexAPIImplDeleteTest) to assert the transparent-mirror + shadow-skip behavior instead of tag-dispatch. - Doc: unified the fan-out section to describe transparent-mirror routing (#35640) with the delete() expected-miss noise resolved (#36423); primary-failure propagation remains tracked in #36430. Verified: dotcms-core unit tests (ContentletIndexAPIImplDeleteTest 6/6, ContentletIndexAPIImplPhaseTest 14/14) and the migration IT (ContentletIndexAPIImplMigrationIntegrationTest: 30 run, 0 failures, 0 errors, 6 skipped two-cluster-only) pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Reconcile the #36430 delete-result / primary-failure-propagation fix with the transparent-mirror delete() rewrite that landed on main via #35640/#36399. - ContentletIndexAPIImpl.delete(): keep main's guard (assertIndexNotActive), untag -> broadcast (bidirectional transparent mirror), and per-engine DB pointer cleanup (clearStorePointer). Layer #36430's contract: after every provider is called, a genuine PRIMARY failure is re-thrown (matching PhaseRouter.writeBoolean) so the REST layer can no longer report success for a failed delete; an unacknowledged primary delete returns false. - A missing-index (index_not_found) on the primary is treated as an idempotent no-op instead of a propagated failure, via a vendor-neutral isIndexNotFound() check. This reconciles #36430 with main's #35640 lastOsSlot cleanup (delete of a dangling DB pointer whose cluster index is already gone) and the divergent-name delete where only the shadow engine held the index — deleting something already absent meets the delete goal. Genuine failures (engine down, auth) still surface. - ESIndexResource.deleteIndex(): keep main's name normalization (TC-016), site-search routing, readable 404 (TC-017) and DotStateException -> 400 (TC-018); additionally check the delete result and send an ERROR admin message + 500 when the delete threw or returned false (#36430), instead of unconditionally reporting the index deleted. - Tests: kept main's cascade IT and the 5 lifecycle/guard ITs; reworked the #36430 IT to assert a name-in-neither delete is a benign idempotent no-op (the genuine-failure propagation stays covered by the ContentletIndexAPIImplDeletePropagationTest unit test). Verified: dotcms-core unit tests (ContentletIndexAPIImplDeletePropagationTest 5/5, ContentletIndexAPIImplPhaseTest 14/14) and the migration IT (ContentletIndexAPIImplMigrationIntegrationTest: 30 run, 0 failures, 0 errors, 6 skipped two-cluster-only) pass. 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 — bidirectional cascade (
ContentletIndexAPIImpl.delete).os) name removes the index in every engine that holds it — the mirror is never left half-deleted. Unconditional (no feature flag).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).Mirror the rest of the fan-out family (
IndexAPIImpl)flushCaches/optimizepartition the index list by vendor tag so each engine flushes/optimizes only the names it owns (no cross-engineindex_not_found).clearIndex/openIndex/closeIndex/updateReplicasresolve a per-engine physical name viaproviderName(impl, name)(OS →.os, others → bare) so the lifecycle op reaches the real index on both engines instead of silently hitting only ES. Site-search is carved out (its OS copy is not.os-tagged).OPENSEARCH_MIGRATION.md: adds the transparent-mirror guiding principle, flips the ops table so user-triggered lifecycle replicates to OS (reindex stays the sole feasibility exception), and marks the fan-out routing open-issue resolved.Testing
ContentletIndexAPIImplTest#delete_activeIndex_isRejected_unlessFeatureFlagOverrides— active index survives a rejected delete; FF bypass then allows it.ContentletIndexAPIImplMigrationIntegrationTest— Phase 1: delete by bare name and by.osname both remove both twins;flushCachestargets each engine cleanly.ContentletIndexAPIImplTest#delete_clearsDbPointer— delete clears theindiciesDB pointer.ContentletIndexAPIImplMigrationIntegrationTest#test_closeAndOpen_phase1_mirrorToBothEngines— close/open by the bare name closes and reopens both twins (validates the sharedproviderNamerouting for clear/open/close/updateReplicas).openapi.yamldiff.Release Notes (as-merged behavior)
Hardening of the search-index delete/manage endpoints (
/api/v1/esindex/*).Behavior changes
working/liveindex (or one being rebuilt) returns HTTP 400 with a clear message instead of proceeding — a direct REST/AJAX call previously bypassed the UI guard and could leave the instance with zero indexes (TC-018). Deactivate first, then delete..os) name removes the copy in both engines, so no orphan copy is left behind (TC-016).indiciestable, so no stale pointer to a deleted index remains.deleteand activate/deactivate/clear/open/close accept both the short name and the full physical name (with cluster prefix); the full name previously returned a spurious 404 (TC-016/017).errorsarray (codesINDEX_NOT_FOUND/INDEX_NOT_DELETABLE) with a readable body./api/v1/esindex/cache) targets each engine correctly. The flush now routes each index to its own engine instead of sending every index to both, so the OpenSearch cache is reliably flushed and noindex_not_founderrors are raised during a migration.New configuration flags
FEATURE_FLAG_ALLOW_ACTIVE_INDEX_DELETEfalsefalse, deleting an active/building index is rejected. Settrueonly for emergency/scripted maintenance to bypass the guard.Out of scope (follow-ups)
SiteSearchAPI.deleteIndex).🤖 Generated with Claude Code
This PR fixes: #35640