Skip to content

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

Merged
fabrizzio-dotCMS merged 15 commits into
mainfrom
issue-35640-index-delete-guard-and-cascade-ff
Jul 7, 2026
Merged

fix(search): guard active-index deletion and gate ES/OS delete cascade (#35640)#36399
fabrizzio-dotCMS merged 15 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 — bidirectional cascade (ContentletIndexAPIImpl.delete)

  • Per the transparent-mirror principle (the operator sees a single index), delete broadcasts the logical (untagged) name to every write provider, so deleting by either the ES (bare) or OS (.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)

  • 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).

Mirror the rest of the fan-out family (IndexAPIImpl)

  • flushCaches / optimize partition the index list by vendor tag so each engine flushes/optimizes only the names it owns (no cross-engine index_not_found).
  • clearIndex / openIndex / closeIndex / updateReplicas resolve a per-engine physical name via providerName(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).
  • Doc 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 .os name both remove both twins; flushCaches targets each engine cleanly.
  • ContentletIndexAPIImplTest#delete_clearsDbPointer — delete clears the indicies DB pointer.
  • ContentletIndexAPIImplMigrationIntegrationTest#test_closeAndOpen_phase1_mirrorToBothEngines — close/open by the bare name closes and reopens both twins (validates the shared providerName routing for clear/open/close/updateReplicas).
  • Core compiles clean; integration module test-compiles clean; no openapi.yaml diff.

Release Notes (as-merged behavior)

Hardening of the search-index delete/manage endpoints (/api/v1/esindex/*).

Behavior changes

  • Active/building indexes can no longer be deleted. Deleting the active working/live index (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.
  • Index delete cascades between engines (ES ↔ OpenSearch). During dual-write, deleting by either the ES name or the OpenSearch (.os) name removes the copy in both engines, so no orphan copy is left behind (TC-016).
  • The database pointer is always cleaned up. Delete removes the index's row from the indicies table, so no stale pointer to a deleted index remains.
  • Delete is resilient. A failure deleting from one engine no longer aborts the operation — the remaining engine and the DB cleanup still run.
  • Endpoints accept the full physical index name. delete and 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).
  • Cleaner error responses. 404 and 400 return the message in the standard errors array (codes INDEX_NOT_FOUND / INDEX_NOT_DELETABLE) with a readable body.
  • Index cache flush (/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 no index_not_found errors are raised during a migration.
  • Index lifecycle ops mirror to both engines. Clear / open / close / replica-count changes now apply to the index in both engines during a migration, not just Elasticsearch — the operator sees a single index and the action applies to the whole mirror (transparent-mirror principle).

New configuration flags

Property Default Effect
FEATURE_FLAG_ALLOW_ACTIVE_INDEX_DELETE false When false, deleting an active/building index is rejected. Set true only for emergency/scripted maintenance to bypass the guard.

Out of scope (follow-ups)

🤖 Generated with Claude Code

This PR fixes: #35640

@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 13m 18s —— View job


Code Review — PR #36399

I reviewed the diff against origin/main. The active-index guard, phase-aware union of protected names, bidirectional cascade, per-engine flush/lifecycle routing, and the AJAX reroute all look correct and are well-tested. One real bug in the new ES-store pointer cleanup, plus a couple of minor notes.

New Issues

  • 🟠 High: dotCMS/src/main/java/com/dotcms/content/elasticsearch/business/ContentletIndexAPIImpl.java:1855 (clearEsStorePointer) — silently drops the reindexLive pointer.
    The method rebuilds the ES store via IndiciesInfo.Builder.copy(info), but IndiciesInfo.Builder.copy (IndiciesInfo.java:67-75) copies working, live, reindexWorking, and siteSearchit does not copy reindexLive. So whenever a delete matches any slot (changed = true) and legacyIndiciesAPI.point(builder.build()) runs, an in-progress reindexLive pointer is persisted as null, corrupting reindex state even though that index was not the delete target.
    Reachability: matching working/live/reindexWorking requires the FEATURE_FLAG_ALLOW_ACTIVE_INDEX_DELETE bypass (those are in the protected set), so the trigger is a bypassed delete of an active index while a full reindex is running — exactly the "emergency/scripted maintenance" scenario the bypass exists for. The result is a half-cleared ES store with the live reindex pointer wiped.
    Note the OS counterpart (clearOsStorePointer, same file) rebuilds explicitly slot-by-slot and correctly preserves everything — the ES path is the inconsistent one. Fix: either add builder.setReindexLive(info.getReindexLive()) to Builder.copy, or (matching the OS path) rebuild clearEsStorePointer explicitly instead of via copy. Fix this →

Notes (non-blocking)

  • 🟡 Medium: dotCMS/src/main/java/com/dotcms/content/index/IndexAPIImpl.java:459 (clearIndex) — the active-index guard is enforced, but there is no integration test asserting the OS twin is also cleared/guarded by physical name through providerName. test_clear_phase1_activeIndex_isRejectedUnlessBypass covers the reject + bypass path; the bypassed-clear assertion only checks both twins still exist (recreated), not that the OS leg was actually cleared vs. left untouched. Minor coverage gap, not a defect.

  • 🟡 Medium: dotCMS/src/main/java/com/dotcms/content/index/IndexAPIImpl.java:370 (flushCaches) — Collectors.groupingBy(IndexTag::resolve) will throw NPE if the input list ever contains a null element (resolve(null)). The public callers (listDotCMSIndices()) won't produce nulls, so this is defensive-only; worth a filter(Objects::nonNull) if you want it fully robust. Assumption: no caller passes a list with null entries — verified for the current call sites.

Resolved (from prior review threads)

  • ContentletIndexAPIImpl.java — divergent-name active OS index now protected via collectProtectedLogicalNames() (ES ∪ OS union in Phases 1/2). Confirmed in the diff + test_delete_phase2_divergentOsName_isProtectedByGuard.
  • IndexAPIImpl.clearIndex?action=clear hole closed; guard hoisted into assertIndexNotActive and consulted before dispatch.
  • IndexAjaxAction.deleteIndex:150 — now routes through ContentletIndexAPI.delete, inheriting guard + cascade + pointer cleanup.
  • clearOsStorePointer last-slot case — falls back to removeVersion(OPENSEARCH_3X) instead of a contractually-rejected empty saveIndices. Covered by test_delete_lastOsSlot_....
  • ESIndexResource modIndex/deleteIndex — removeClusterIdFromName normalization + structured ErrorEntity 404/400 bodies; the "unhandled → 500" flags are by-design (mapper-sanitized 500 for genuine backend failures).
  • flushCaches cross-contamination — per-vendor tag partitioning confirmed.

The 🟠 High reindexLive drop is the only item I'd consider blocking; the rest is optional polish.

· Branch: issue-35640-index-delete-guard-and-cascade-ff

@github-actions

github-actions Bot commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

🤖 dotBot Review (Bedrock)

Reviewed 6 file(s); 7 candidate(s) → 6 confirmed, 0 uncertain (unverified, kept for review).

Confirmed findings

  • 🟠 High dotCMS/src/main/java/com/dotcms/content/index/IndexAPIImpl.java — Unhandled exceptions in flushCaches lead to partial execution
    The flushCaches method in IndexAPIImpl.java does not handle exceptions thrown by individual provider.flush() calls. If one provider's flush fails (e.g. Elasticsearch connection issue), the exception propagates immediately, preventing subsequent providers from executing their flush. This violates the transparent-mirror principle by leaving some engine caches unflushed while others succeed. The code should wrap each provider.flush() in try-catch blocks to ensure all providers attempt their flush regardless of individual failures.
  • 🟠 High dotCMS/src/main/java/com/dotcms/rest/api/v1/index/ESIndexResource.java:576 — Unhandled exceptions in modIndex operations leak 500 errors
    The modIndex method in ESIndexResource.java catches DotStateException and DotSecurityException but allows other exceptions (e.g., runtime errors from indexAPI.activate/deactivate) to propagate, resulting in unhandled 500 errors. This violates the PR's goal of clean error responses and leaves potential error paths unmanaged.
  • 🟡 Medium dotCMS/src/main/java/com/dotcms/rest/api/v1/index/ESIndexResource.java:312 — Unhandled exceptions in deleteIndex lead to 500 errors
    The deleteIndex method in ESIndexResource.java catches only DotStateException, allowing other exceptions (e.g., ElasticsearchClientException) to propagate. This results in 500 Internal Server Error responses instead of proper error handling. Evidence: The try-catch block around indexAPI.delete() only handles DotStateException, leaving other exceptions uncaught.
  • 🟡 Medium dotCMS/src/main/java/com/dotcms/rest/api/v1/index/ESIndexResource.java:312 — Test gap: modIndex error cases lack coverage
    The modIndex method in ESIndexResource.java handles error cases like activating a non-existent index or an already active index, but the test search revealed no existing tests for modIndex in the test directory. This leaves potential error paths untested, risking undetected regressions.
  • 🟡 Medium dotcms-integration/src/test/java/com/dotcms/content/elasticsearch/business/ContentletIndexAPIImplMigrationIntegrationTest.java:495 — Potential NPE in test assertion on missing map key
    The test asserts result.get("failedShards") equals 0 using isEqualTo(), which would throw a NullPointerException if the key is absent. AssertJ's containsEntry handles missing keys safely by checking both presence and value.
  • 🟡 Medium dotcms-integration/src/test/java/com/dotcms/content/elasticsearch/business/ContentletIndexAPIImplTest.java:500 — Assertion may produce false negatives due to substring match
    The test uses endsWith to check if an index was deleted, which could incorrectly pass if another index exists with a matching suffix. For example, deleting 'index' would still match 'myindex'. The assertion should use exact equality (equals) to ensure only the deleted index is checked.

us.deepseek.r1-v1:0 · Run: #28821384283 · tokens: in: 64457 · out: 19422 · total: 83879 · calls: 17 · est. ~$0.192

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
…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
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>
Comment thread dotCMS/src/main/java/com/dotcms/content/index/IndexAPIImpl.java
Comment thread dotCMS/src/main/java/com/dotcms/rest/api/v1/index/ESIndexResource.java Outdated
…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>
Comment thread dotCMS/src/main/java/com/dotcms/content/index/IndexAPIImpl.java
…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 swicken left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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>
@fabrizzio-dotCMS fabrizzio-dotCMS added this pull request to the merge queue Jul 7, 2026
Merged via the queue into main with commit f8defeb Jul 7, 2026
62 checks passed
@fabrizzio-dotCMS fabrizzio-dotCMS deleted the issue-35640-index-delete-guard-and-cascade-ff branch July 7, 2026 18:19
fabrizzio-dotCMS added a commit that referenced this pull request Jul 8, 2026
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>
fabrizzio-dotCMS added a commit that referenced this pull request Jul 9, 2026
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>
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 Area : Documentation PR changes documentation files

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

[QA-G6] OpenSearch Migration — Index Delete: ES vs OS Behavior (TC-016–TC-020)

3 participants