Skip to content

feat(search): delete a site-search index across both engines (#35640) - #36439

Merged
fabrizzio-dotCMS merged 15 commits into
mainfrom
issue-35640-sitesearch-delete
Jul 7, 2026
Merged

feat(search): delete a site-search index across both engines (#35640)#36439
fabrizzio-dotCMS merged 15 commits into
mainfrom
issue-35640-sitesearch-delete

Conversation

@fabrizzio-dotCMS

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

Copy link
Copy Markdown
Member

Problem

Deleting a site-search index from the maintenance endpoints (DELETE /api/v1/esindex/{name}, the DWR IndexAjaxAction) routed through the content delete path. Site-search OS indices are plain-named (not .os-tagged — separate cluster, own siteSearch slot), so the content path computed a .os name 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 (only deleteFromIndex(doc) and bulk deleteOldSiteSearchIndices).

Changes

  • SiteSearchAPI.deleteIndex(name) — new method; implemented in ESSiteSearchAPI and OSSiteSearchAPI. Each deletes only its own engine via that engine's direct index API (new ESIndexAPI() / OSIndexAPIImpl), never the neutral router — the SiteSearchAPIImpl router is the single fan-out point (avoids a double dual-write).
  • SiteSearchAPIImpl.deleteIndex — fans out via the site-search PhaseRouter and guards the active index: isDefaultIndex(name) (phase-aware read) → DotStateException (deactivate first), mirroring the content active-index guard.
  • Endpoints branch to it for sitesearch_*ESIndexResource.deleteIndex (new deleteSiteSearchIndex helper: 404 via siteSearchAPI.listIndices(), 400 on active, 500 on engine error) and IndexAjaxAction.deleteIndex (mirrors the existing activate/deactivate branch). Site-search indices are not in listDotCMSIndices(), 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).
  • Core compiles clean; integration module test-compiles clean.

Notes

🤖 Generated with Claude Code

This PR fixes: #35640

fabrizzio-dotCMS and others added 10 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>
…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>
@mergify

mergify Bot commented Jul 6, 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 6, 2026

Copy link
Copy Markdown
Contributor

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


Rollback-Safety Analysis — Complete ✅

  • Read docs/core/ROLLBACK_UNSAFE_CATEGORIES.md
  • Get full PR diff (36d6a29e...d4675bef)
  • Analyze diff against every unsafe category
  • Post verdict (label)

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):

  • No DB migration / runonce task anywhere in the diff — no schema, PK, or column changes.
  • No Elasticsearch mapping changeESSiteSearchAPI.deleteIndex / OSSiteSearchAPI.deleteIndex (ESSiteSearchAPI.java:712, OSSiteSearchAPI.java:506) only call indexApi.deleteMultiple(...) — deleting an index, not altering a mapping.
  • No contentlet_as_json model version bump — untouched.
  • No DROP TABLE/COLUMN, RENAME, or PK restructuring.
  • New interface method SiteSearchAPI.deleteIndex(String) (SiteSearchAPI.java:94) is purely additive — both built-in implementers (ESSiteSearchAPI, OSSiteSearchAPI) are updated in the same PR, so this isn't an OSGi (M-4) break in the rollback direction (N-1 simply lacks the new capability, nothing breaks).
  • REST endpoint change (ESIndexResource.java new deleteSiteSearchIndex branch) is additive: previously DELETE /api/v1/esindex/{sitesearch_name} fell through to the content path and 404'd (site-search names aren't in listDotCMSIndices()). N adds a working delete; on rollback to N-1 the endpoint simply reverts to the old 404 behavior for that name — no client that depends on the new 200/400 response existed before this PR, so nothing that "worked on N" can be broken by N-1.
  • FEATURE_FLAG_ALLOW_ACTIVE_INDEX_DELETEALLOW_ACTIVE_INDEX_DELETE rename (FeatureFlagName.java, ContentletIndexAPIImpl.java:1704) changes the config property key read by Config.getBooleanProperty(...). This is an admin-set maintenance override (not persisted data, not a DB/ES contract), off by default on both names — doesn't match any category (no data is transformed, no schema touched, no client contract).
  • The actual index deletion itself is a manual, per-index admin action the user explicitly triggers — not an automatic migration bundled with deployment — so it doesn't fall under H-1 (one-way data migration).

Label AI: Safe To Rollback has been applied.

@github-actions

github-actions Bot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

🤖 dotBot Review (Bedrock)

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

⚠️ Coverage capped: 0 file(s) + 2 lower-severity candidate(s) skipped (limits: 40 files, 12 candidates).

Confirmed findings

  • 🔴 Critical dotCMS/src/main/java/com/dotcms/rest/api/v1/index/ESIndexResource.java:423 — Undefined variable 'data' in response construction
    Line 423 references undefined variable 'data' where 'response' appears intended, based on assignment 'SiteSearchIndexInfoView response = deleteSiteSearchIndex(...)' at line 420. This would cause a compile error due to undefined variable.
  • 🟠 High dotCMS/src/main/java/com/dotcms/content/index/IndexAPIImpl.java:364 — Incorrect index grouping for site-search in flushCaches
    The flushCaches method uses IndexTag.resolve() which misclassifies OS site-search indices as ES due to lacking .os suffix. This leads to cache keys using 'es' prefix (line 364) for OS site-search indices, preventing proper cache invalidation. Site-search indices require separate engine-aware handling like the new deleteIndex implementation uses PhaseRouter.
  • 🟠 High dotCMS/src/main/java/com/dotmarketing/sitesearch/business/SiteSearchAPI.java:90 — Missing index name validation in SiteSearchAPI.deleteIndex
    The SiteSearchAPI interface's deleteIndex method documentation states indexName must be a 'sitesearch_*' pattern, but neither the interface nor ESSiteSearchAPI/OSSiteSearchAPI implementations validate this format. This allows potential deletion of non-site-search indices if API methods are called directly without endpoint guards.
  • 🟠 High dotCMS/src/main/java/com/dotmarketing/portlets/cmsmaintenance/ajax/IndexAjaxAction.java:147 — Missing permission check for site-search index deletion
    The IndexAjaxAction.deleteIndex method calls siteSearchAPI.deleteIndex() without checking CMS_MAINTENANCE permissions, allowing unauthorized index deletion. Content index branch (line 135) includes permission check, but site-search branch (line 147) omits it, while SiteSearchAPI implementations don't enforce permissions internally.
  • 🟡 Medium dotCMS/src/enterprise/java/com/dotcms/enterprise/publishing/sitesearch/SiteSearchAPIImpl.java:276 — Race condition in active index check before deletion
    The check isDefaultIndex(name) (line 276) and subsequent phaseRouter.deleteIndex(name) (line 280) lack atomicity. No transaction or lock prevents the index from being activated between these operations, creating a window where an index could be deleted after becoming active. While activation workflows may have separate safeguards, this non-atomic sequence violates the intended guardrail against deleting active indices.
  • 🟡 Medium dotCMS/src/main/java/com/dotmarketing/portlets/cmsmaintenance/ajax/IndexAjaxAction.java:142 — Unhandled DotDataException in DWR method
    The deleteIndex() DWR method declares throws DotDataException without explicit handling, allowing raw exception propagation through DWR endpoints. This could expose internal implementation details via error responses if not caught by framework-level handlers.

🔎 Uncertain (could not confirm or disprove — review manually)

  • 🟠 High dotcms-integration/src/test/java/com/dotcms/content/elasticsearch/business/ContentletIndexAPIImplMigrationIntegrationTest.java:74 — Lifecycle tests may validate against incorrect OS indices
    The test class ContentletIndexAPIImplMigrationIntegrationTest is part of OpenSearch migration validation, but without direct evidence of .os-tagged index checks in the test code shown, we cannot confirm if site-search indices are using plain names. Requires manual inspection of index name handling in test validation logic.

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>
@fabrizzio-dotCMS

Copy link
Copy Markdown
Member Author

Addressing the remaining AI-review findings (no inline threads for these):

  • Missing CMS Admin permission check (ESIndexResource:414) — false positive. deleteSiteSearchIndex runs after auth(request, response) in deleteIndex, which enforces .requiredRoles(Role.CMS_ADMINISTRATOR_ROLE).requiredPortlet("maintenance"). The permission is already enforced at the endpoint entry.
  • Missing null check for indexName (ESSiteSearchAPI) — false positive. IndexType.SITE_SEARCH.is(indexName) is null-safe (name != null && name.startsWith(...)); a null name returns false and is rejected with a clear DotDataException, no NPE.
  • Permission check in IndexAjaxAction.deleteIndex — the new branch mirrors the existing activateIndex/deactivateIndex site-search branches in the same class; any permission convention there is pre-existing and identical, not introduced by this PR.
  • Test missing ES assertion — fixed in 3c7fb70 (now asserts the active index survives in both ES and OS).

@fabrizzio-dotCMS

Copy link
Copy Markdown
Member Author

Reviewer summary — AI review gate is a non-converging false positive; needs a human merge

Status: all review threads are resolved (7/7). The only red check is the advisory ai-automatic-review / bedrock-harness (deepseek.r1) gate, whose conclusion is frozen from an earlier run — resolving threads does not re-flip it. I re-ran the job once; it re-flagged the same two findings with different wording, confirming it will not converge to green. This needs a maintainer to merge past the advisory gate.

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" (IndexAjaxAction) — false positive.
Authorization is enforced centrally in the DWR dispatcher action() (lines 72-84): any user lacking doesUserHaveAccessToPortlet("maintenance", user) gets a 401 before the command method is resolved and invoked via reflection. deleteIndex — both the content and the site-search branch — is unreachable without passing that gate. Neither branch does a per-method check because the dispatcher already did. The premise (content path checks, site-search doesn't) is incorrect.

2. "Partial index deletion / no rollback across engines" (SiteSearchAPIImpl) — by-design.
The ES↔OS dual-write has no cross-cluster 2PC by design — the documented migration contract (docs/backend/OPENSEARCH_MIGRATION.md: transparent-mirror + fire-and-forget shadow writes). The content-index delete path behaves identically. On partial failure the error is logged; a synthetic "rollback" (re-creating a just-deleted index) would be strictly worse. This is the same inherent property the bot re-flags every run.

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.

@fabrizzio-dotCMS
fabrizzio-dotCMS changed the base branch from issue-35640-index-delete-guard-and-cascade-ff to main July 6, 2026 20:17
@github-actions github-actions Bot added Area : Backend PR changes Java/Maven backend code Area : Documentation PR changes documentation files and removed AI: Safe To Rollback labels Jul 6, 2026
Comment thread dotCMS/src/main/java/com/dotcms/content/index/IndexAPIImpl.java
@fabrizzio-dotCMS
fabrizzio-dotCMS added this pull request to the merge queue Jul 7, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to a conflict with the base branch Jul 7, 2026
fabrizzio-dotCMS and others added 2 commits July 7, 2026 13:36
…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>
@fabrizzio-dotCMS
fabrizzio-dotCMS added this pull request to the merge queue Jul 7, 2026
Merged via the queue into main with commit ea8d898 Jul 7, 2026
58 checks passed
@fabrizzio-dotCMS
fabrizzio-dotCMS deleted the issue-35640-sitesearch-delete branch July 7, 2026 22:00
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.

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

3 participants