Skip to content

fix(sitesearch): preserve the custom index alias across a crawl (#36983) - #37010

Merged
fabrizzio-dotCMS merged 20 commits into
mainfrom
issue-36983-sitesearch-alias-phase-aware
Aug 13, 2026
Merged

fix(sitesearch): preserve the custom index alias across a crawl (#36983)#37010
fabrizzio-dotCMS merged 20 commits into
mainfrom
issue-36983-sitesearch-alias-phase-aware

Conversation

@fabrizzio-dotCMS

@fabrizzio-dotCMS fabrizzio-dotCMS commented Aug 11, 2026

Copy link
Copy Markdown
Member

Proposed Changes

Fixes Bug 1 of #36983 (QA-G17) — the custom alias of a Site Search index destroyed by a crawl in the OpenSearch read phases — plus the two defects that surfaced while diagnosing it: alias resolution that goes blind in the phase it does not read from, and a readiness report that could not answer the questions support was actually asking it.


1 · Bug 1 — the alias is overwritten by a crawl

Root cause. site_search_job_schedule.jsp resolved index aliases through the content-index router (new ESIndexAPI()), which is not site-search .os-aware. In Phases 2/3 the physical index lives in OpenSearch tagged with .os, so the lookup misses and the index selector falls back to the raw internal index name — which is then saved as the job's indexAlias. From there the crawl does the damage: it deletes the old index (taking the real alias with it) and re-applies the job's stored string as the new index's alias, so a dead index's NAME becomes the alias. That is the sitesearch-ph-3sitesearch_20260810160529 swap QA reported. #36797 rerouted the Indices tab and 6 other callers to the phase-aware API but missed this JSP.

  • site_search_job_schedule.jsp — resolve aliases via the phase-aware, .os-aware Site Search API, as the Indices tab already does. Dropped the now-unused ESIndexAPI import.
  • SiteSearchJobImpl — when the stored indexAlias is actually a raw index name, recover that index's real alias (or none) instead of carrying the raw name forward to the publisher. Server-side guard, so jobs already saved with a raw name are repaired on their next run rather than needing to be recreated by hand.
  • site_search.jsp — the scheduler's alias field now accepts up to 255 chars (the engine's name limit) instead of 60. A crawl-built name (sitesearch_<timestamp>_<uuid>) is 62 chars, so the old cap rejected it with "Invalid Index alias" and left the index permanently un-schedulable.

2 · The aggregated alias view — Bug 3, and the downgrade case

Testing a downgrade (3 → 2 → 1) surfaced the structural half of the same defect: listIndices() is a union of both engines in the dual-write phases, while getAliasToIndexMap() resolves against a single engine (the read provider). Any index living only on the other engine appears in the list with a blank alias — which is Bug 3, in both of its reported forms:

  • Phase 2 + an index created in Phase 0 (Elasticsearch only) → alias invisible.

  • Phase 1 + an index created in Phase 3 (OpenSearch only, after a downgrade) → alias invisible.

  • SiteSearchAPI#getAliasToIndexMapAllEngines() — resolves over the same provider set listIndices() uses, with the read provider applied last so it wins a mirror desync and the management view never contradicts what a search would hit. Single-provider phases (0 and 3) do not consult the idle engine.

  • The single-engine getAliasToIndexMap() stays as is — searching must resolve an alias against the engine that will serve the query. The split is documented so the two do not get merged later.

  • Switched to the new view: the Indices tab, the crawl index selector, the Search tab selector (so Bug 2's symptom — raw index IDs in the dropdown — is gone too) and SiteSearchJobImpl, where an invisible alias made the crawl treat an existing index as new and drop its alias.

3 · Making the readiness endpoint answer support's questions

The endpoint is the source of truth support consults during a migration. Three gaps, each found by using it against a live stack:

  • Site Search rows now carry the alias, per engine. An operator knows these indices by alias, never by sitesearch_<timestamp>_<uuid>. Per engine on purpose: an index can hold its alias on one side and not the other, and that asymmetry is what must be visible before promoting a phase. An alias that is itself shaped like an index name gets a NOTE — the fingerprint of the defect fixed above, which cannot be repaired retroactively, so this is the only way to find the indices that still need theirs restored.

  • Content counts come from a count query, not _stats docs.count. That counter is per-shard and only advances on shard refresh, so it trailed a just-written document by seconds: a technician checking whether a publish reached OpenSearch read the previous number and concluded the write was lost. A source of truth must never report a number the engine can already contradict. getIndicesStats() stays, but only to decide existence — one call per engine over the whole set, so an absent index is never confused with an unreachable engine. Both halves of the report now count the same way.

  • New: content completeness against the DATABASE (databaseDocCount, esIndexedPercent, osIndexedPercent). Every completeness signal until now compared one engine against the other, which stops being an answer exactly where it matters most: in Phase 3 there is no second engine, so a mirror that was never rebuilt reads unremarkably. The case that prompted this reads 3.06 instead of looking normal.

    The denominator is an exact COUNT, one statement returning both the working and live figures from contentlet_version_info. That is a Parallel Seq Scan — measured at 15 ms for 171k rows, 21 ms for 394k, 22 ms for 453k, so roughly 10 ms of fixed parallel-scan setup plus ~25 ns per row. An earlier revision used the pg_class.reltuples estimate instead, which is O(1) but drifts a few points between ANALYZE runs; that surfaced as an indexed percentage slightly over 100%, which reads as a defect in a report whose whole job is to be trusted. With exact counts, 100% means complete and any excess is real — documents in the index that no longer exist in the database. Splitting the statement in two was also measured and is slower (41 ms vs 28 ms): the live half stays a sequential scan regardless, since nearly every row has a live version.

    Completeness never changes the verdict: the verdict states the ES↔OS relationship, this states completeness against the database. Two facts, reported side by side.

4 · Review follow-ups

  • The crawl's incomplete-content-index warning is now opt-in and off the global lock (@ihoffmann-dot's finding). It measured on every crawl from inside prepareJob, which holds a monitor on SiteSearchJobImpl.class — a JVM-wide lock every concurrent crawl on the node contends for — and each measurement costs one sequential scan of contentlet_version_info plus six engine round-trips (two getIndicesStats, four document counts). Two changes:
    • Moved to run(), between prepareJob and the publish loop. Nothing in the measurement depends on what prepareJob computes — it reads the content indices, not this job's — so it had no reason to hold that lock. Still before the crawl it warns about.
    • SITE_SEARCH_CRAWL_MIN_CONTENT_INDEXED_PERCENT now defaults to 0 (off); set it to 95 to get the previous behaviour while investigating. It buys a diagnostic, not behaviour — the crawl runs identically either way — so a recurring cost on every install's crawl schedule was the wrong trade for a message only someone diagnosing a migration reads. A second guard skips Phase 0 even when enabled: with no second engine there is no mirror that could have been left behind. Both guards are plain config reads, before anything touches the database or an engine.
  • site_search_job_schedule.jsp was missing its com.dotmarketing.util.Logger import (@claude's review). The scriptlet added in section 1 calls Logger.warn, and neither the JSP nor init.jsp imported it — JSPs translate on first request, so this would have failed to compile and broken the Schedule Site Search Job dialog, the very screen this PR fixes. Its two sibling JSPs already carried the import.
  • Semgrep CUSTOM_INJECTION-2 on ContentIndexMirrorReconciler — false positive, and now structurally evident: the statement is a fully literal constant with no interpolation, no parameters and nothing caller-supplied.

5 · Second review pass, before merge

A full re-review of the branch turned up six more, all fixed here. The first one is the reason this did not merge on the previous round.

  • The Bug-1 repair only survived one run — and the second run recreated the damage. getIndexMetaData recovered the index's real alias when the job detail held a raw index name, but nothing wrote the recovery back, and the job detail is the job's only memory between runs. So the crawl that used the recovered alias also deleted the index whose name was stored. On the next run that string resolved to neither an alias nor an existing index, the job concluded it was brand new, and: (a) stamped the dead index's name onto the new index as its alias — exactly the defect this PR exists to fix, recreated by the guard meant to prevent it; and (b) abandoned the index built on the previous run, which keeps the user's real alias and silently stops being crawled, because switchIndex skips its whole body when there is no old index to swap from. Net effect: the custom alias survives, pointing at an index that quietly goes stale, while the job crawls a different one under a machine-generated name. Fixed in two halves so neither can return alone — persistResolvedAlias writes the recovery into the job detail (SiteSearchJobProxy is a Quartz StatefulJob over a JDBC store, so the data map is re-persisted after execution), and aliasForNewIndex refuses to adopt a sitesearch_<timestamp>_<uuid> string as a new index's alias, which also covers a job nobody ever repaired. The shape check is the one the readiness report already used to flag these, now shared as SiteSearchMirrorReconciler.isIndexNameShaped rather than restated, so detector and preventer cannot drift.
  • An unmeasurable count could read as IN_SYNC. A failed count query is reported as -1, and verdictFor relied on that comparing unequal. It does not when both engines fail: -1 == -1 looked like a perfect match, so the report answered "in sync / safe to advance" about a mirror it never measured. No outage needed — a search user granted indices:monitor/stats but not indices:data/read/count resolves existence and fails counting. Now checked explicitly, so unknown degrades to drift. Kept as COUNT_DRIFT rather than a new Verdict constant: the enum is serialized in the endpoint's JSON and documented, and verdictFor's javadoc already promised this behaviour — only the both-failed case escaped it.
  • An absent copy reported esIndexedPercent: 0.0. Zero says "this engine lost all its content"; the truth is "there is no such index here", which exists and MISSING_COUNTERPART already state. It would have lit up every content row in Phase 3, where the Elasticsearch copies are legitimately gone — in the report this PR frames as support's source of truth. Now omitted.
  • The opt-in diagnostic failed silently. No onFailure on its Try, so an operator who switched the check on specifically to diagnose a migration could not tell "the index is complete" from "the measurement blew up". Every other quiet-failure path added here already logged.
  • SiteSearchAPI.java had been rewritten CRLF → LF end to end, turning a 58-line addition into a 474-line diff and taking the whole interface's git blame with it. Original endings restored; the diff is now 58 insertions, 0 deletions.
  • Left as-is: with the check enabled, ContentIndexMirrorReconciler.statuses() still calls getIndicesStats() on both engines, so a Phase-3 crawl pays an Elasticsearch round-trip. Bounded by the opt-in default and by the client's own timeouts, and not worth an engine-conditional branch in a diagnostic.

Checklist

  • Tests
  • Translations
  • Security Implications Contemplated (none — alias resolution and reporting only; the readiness endpoint keeps its existing @Hidden + admin-plus-migration-role gate and is absent from the OpenAPI schema, so no regeneration applies; the new SQL is a fixed literal COUNT over contentlet_version_info with no parameters and no user input)

Additional Info

Tests — 97/97 green

Every guard added in sections 4 and 5 was verified by removing it and watching the matching test fail, then restoring it. A test that cannot fail is not evidence, and two of these are the kind that pass by accident: the opt-in guards sit behind a vavr Try that swallows Throwable, so an assertion thrown from the supplier would be absorbed and the test would go green against the very regression it guards — they assert the supplier was never consulted instead.

  • SiteSearchJobAliasResolutionTest (new, unit) — 18 cases. Four pin the alias-resolution rules (real alias carried through, raw index name → real alias, raw index name with no alias → no alias, unknown name → new-index alias). Six cover the two halves of the section-5 fix: the recovered alias is written back to the job detail, an already-correct alias is not rewritten, an index with no alias leaves the job detail alone, a stale sitesearch_<timestamp> is refused as a new index's alias, a real alias is still adopted, and an existing index gets none at creation time. The rest cover the incomplete-content-index warning and its two guards. Note test_unknownName_isKeptAsTheAliasOfANewIndex passes unchanged — a name a person chose is unaffected; only the machine-generated shape is refused.
  • ContentIndexMirrorReconcilerTest (existing) — 7 new cases: the count comes from the query and not from stats (the stats entries carry a poison count, so a regression fails loudly), a failed count reported as -1, both counts failing never reading as IN_SYNC, an absent copy reporting no indexed percentage, completeness against the database, completeness absent without a denominator, and a complete mirror not flagged.
  • SiteSearchRouterReconciliationTest (existing) — 4 new cases on the aggregated view: it includes the engine the phase does not read from, the read provider wins a conflicting alias, Phase 0 never consults OpenSearch, and the single-engine method stays on the read provider.
  • SiteSearchMirrorReconcilerTest (new, unit) — 6 cases: alias per engine, alias missing on one engine only, no alias at all, index-name-shaped alias flagged without changing the verdict, a legitimate sitesearch-prefixed alias NOT flagged, one alias lookup per engine.
  • MigrationReadinessServiceTest, MigrationReadinessResourceTest, OSSiteSearchAliasMapTest, ESIndexHelperTest — unchanged and green.
  • SiteSearchJobImplTest (existing IT, MainSuite1a) — new end-to-end case: after a full crawl the custom alias must follow onto the new index, and the replaced index's name must never become an alias.

Validation run

  • dotcms-core install: BUILD SUCCESS · dotcms-integration test-compile: BUILD SUCCESS
  • Verified live against a Phase-1 stack: dual-write confirmed document by document on both engines, and the endpoint's numbers reconciled against _count on each cluster.
  • The new IT was not executed locally (needs the integration stack); it runs in CI via MainSuite1a.
  • Unit runs use -Dmaven.build.cache.enabled=false and are counted from the Tests run: line — the build cache will otherwise skip surefire entirely and still report BUILD SUCCESS.

Docs (OPENSEARCH_MIGRATION.md)

  • The two alias views and when to use each; a phase change never builds counterparts retroactively.
  • How to read the readiness report: the access gate (CMS admin and the OS_MIGRATION_INDEX_VISIBILITY_ROLE_KEY role, default os_migration_qa — 403 otherwise), the order to read the fields in, a per-field table, and what a count cannot tell you (re-publishing is an update, and a dual-write mirror only receives what changes from that point on).
  • Two worked examples: the downgrade case, and activating a pre-migration backup content index — whose OpenSearch counterpart is never built, is silent in Phase 1, masked by the read fallback in Phase 2 and customer-visible as lost content in Phase 3, and which only a full reindex repairs.
  • A consolidated configuration table under the Executive Summary: every setting that changes migration behaviour was documented inline in whichever section happened to use it — the phase flag near the top, DOTCMS_SHADOW_WRITE_LOG_LEVEL in a gotcha 1200 lines down, the readiness role key in the endpoint section — so nothing answered "what can I configure for this migration?". All four, with their shipped defaults verified against the code and a link to the section explaining each.
  • The Site Search crawl inherits the content index. Found during this work: a Phase-3 crawl produced an index with 14 documents instead of ~443, because the bundlers build their corpus from conAPI.searchIndex — a phase-routed search over the content index, which held 21 of 685 live documents on OpenSearch. The crawl reports success, the bundlers swallow search failures at debug level, and the damage outlives its cause: reindexing afterwards does not repair the Site Search index already built from the empty one. States the ordering rule this implies — in Phases 2/3, full content reindex first, Site Search crawl second.

Scope
Bug 1 in full. Bugs 2 and 3 turned out to be the same root cause seen from other screens, and the aggregated alias view fixes their symptoms as well. They are left open in the issue for QA to re-verify against a build rather than closed from here.

Follow-ups not included here

  • A guard on ESSiteSearchPublisher.switchIndex: it currently activates the new index and deletes the old one unconditionally, so a truncated crawl destroys the good copy — with no ES copy to recover from in Phase 3. With the completeness metric now available, that guard has a defensible criterion instead of an invented threshold. Worth its own PR.
  • The whole crawl runs inside one open transaction. Noticed while measuring the above: SiteSearchJobImpl.run() calls HibernateUtil.startTransaction() and there is no matching commitTransaction() anywhere — the session is closed in the finally, so the transaction spans the entire publish loop, which is minutes. A long-lived transaction holds back xmin and delays vacuuming database-wide, and pins a pool connection for the duration. Entirely pre-existing and untouched here, but it dwarfs any query this PR adds, and it is the thing actually worth looking at if crawl DB impact is a concern.

🤖 Generated with Claude Code

This PR fixes: #36983

This PR fixes: #36983

The Site Search job scheduler resolved index aliases through the
content-index router (ESIndexAPI), which is not site-search .os-aware. In
Phases 2/3 the physical index lives in OpenSearch tagged with .os, so the
lookup missed and the index selector fell back to the raw internal index
name — which was then saved as the job's `indexAlias`.

From there a full crawl destroyed the alias: it deletes the old index
(taking the real alias with it) and re-applies the job's stored string to
the new index, so a dead index's NAME became the new index's alias.

- site_search_job_schedule.jsp: resolve aliases via the phase-aware
  SiteSearchAPI#getAliasToIndexMap(), like the Indices tab already does.
- SiteSearchJobImpl: when the stored value is a raw index name, recover
  that index's real alias (or none) instead of carrying the raw name
  forward. This also repairs jobs already saved with a raw name.
- site_search.jsp: the scheduler's alias field caps at 255 chars (the
  engine limit) instead of 60 — a crawl-built name is 62 chars, so the
  old cap made those indices impossible to schedule at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@claude

claude Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

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


PR Review — fix(sitesearch): preserve the custom index alias across a crawl (#36983)

Reviewed the full diff against origin/main (base 966e9d5c): the two alias-resolution methods, SiteSearchJobImpl, both mirror reconcilers, MirrorStatus, the four JSPs, and the readiness endpoint.

New Issues

No blocking issues found. The core fixes hold up:

  • SiteSearchJobImpl.getIndexMetaData (L541–566) — the raw-index-name → real-alias recovery (aliasOf) is correct, and resolvedAlias is threaded consistently into newAlias, the log line, and config.setIndexAlias(...). The new-index case still carries the requested alias forward.
  • SiteSearchAPIImpl.getAliasToIndexMapAllEngines (L216–226) — union over writeProviders() with readProvider() applied last is sound; the single-provider short-circuit for phases 0/3 is right. getAliasToIndexMap() correctly left single-engine for search.
  • incompleteContentIndexWarning (L459–476)readsOpenSearch = phase.isReadEnabled() matches the enum (true for Phase 2/3), threshold/phase guards are correct, and construction + measurement are fully wrapped in Try, so a bean-lookup or DB failure can never break a crawl. Advisory-only, as documented.
  • ContentIndexMirrorReconcilercountQuietly returns -1 on failure (degrades verdict, never a false green); incompleteNote/indexedPercentOf guard null/≤0/-1 denominators. Existence still from one stats snapshot per engine, count from a live query — the intended split.
  • JSPsgetAliasToIndexMapAllEngines() + phase-aware defaultIndexName().orElse(null) are used consistently; each resolution is wrapped in try/catch with a Logger.warn, degrading to "no default marker" rather than failing the page. HashMap/Map.Entry are already in scope in each file.

Resolved

  • ContentIndexMirrorReconciler.java:247 — Semgrep CUSTOM_INJECTION-2 (6 findings). The count query is now a fully literal constant (DATABASE_COUNTS_SQL), no interpolation or caller input. Correctly addressed in 57b10b8.
  • SiteSearchJobImpl.java@ihoffmann-dot's concern that the content-index check ran on every crawl inside the prepareJob SiteSearchJobImpl.class monitor. Now moved off the lock (runs after prepareJob returns, before the publish loop) and made opt-in via SITE_SEARCH_CRAWL_MIN_CONTENT_INDEXED_PERCENT (default 0 = off), so no install pays the seq-scan + per-engine count cost by default.

Notes (non-blocking, no action required)

  • The coverage denominator (COUNT(*) / COUNT(live_inode) on contentlet_version_info) assumes that table's row grain matches the index document grain. The author reports it validated exactly (686/685) on a live stack, and the metric is explicitly order-of-magnitude (distinguishing 3% from 97%), so any small drift is within its stated tolerance.
  • test_site_search.jsp still declares IndiciesInfo info at the top; after this change its getSiteSearch() use is gone. Harmless if now unused — worth a glance but not a bug.

Overall: a well-scoped, well-tested change. Error paths are handled defensively (everything on the diagnostic paths is swallowed and logged, never propagated to indexing), the alias-resolution split is clearly documented, and the two prior review concerns are addressed. LGTM.

· branch issue-36983-sitesearch-alias-phase-aware

…ness endpoint (#36983)

An operator knows a site-search index by its alias, never by its
sitesearch_<timestamp>_<uuid> name, so the readiness report was hard to
act on. Each Site Search row now carries the alias each engine has
attached to the index.

Per engine on purpose: an index can hold its alias on one side and not
the other (created before dual-write started, counterpart built later),
and that asymmetry is exactly what has to be visible before promoting a
phase. One alias lookup per engine covers the whole set.

An alias that is itself shaped like an index name gets a NOTE appended to
`recommendation`: that is the fingerprint of the crawl overwrite fixed in
this same PR, which cannot be repaired retroactively — this is the only
way to find the indices that still need their alias restored. It does not
change `verdict`: the verdict measures data integrity, and a damaged
alias costs no data, so it must not block a phase change.

Content rows are unaffected — `alias` is null there and omitted from the
JSON.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions github-actions Bot added the Area : Documentation PR changes documentation files label Aug 11, 2026
…iews (#36983)

listIndices() is a UNION of both engines in the dual-write phases, while
getAliasToIndexMap() resolves against a single engine (the read
provider). Any index living only on the other engine therefore appears in
the list with a blank alias — two mirror images of one defect:

- Phase 2 + an index created in Phase 0 (Elasticsearch only).
- Phase 1 + an index created in Phase 3 (OpenSearch only), which is what
  a tester hits after downgrading 3 -> 2 -> 1.

Adds SiteSearchAPI#getAliasToIndexMapAllEngines(), resolved over the same
provider set listIndices() uses, with the read provider applied last so
it wins a mirror desync and the view never contradicts what a search
would hit. The single-engine method stays as is: searching must resolve
against the engine that serves the query.

Switched to it: the Indices tab, the crawl index selector, the Search tab
selector (which also stops showing raw index IDs there) and
SiteSearchJobImpl — where an invisible alias made the crawl treat an
existing index as new and drop its alias, the same loss this PR fixes for
the raw-name case.

Docs: the two alias views and when to use each; how to read the readiness
report (including the admin + migration-role gate, 403 otherwise) with
worked examples for the downgrade case and for activating a
pre-migration backup content index, whose OpenSearch counterpart is never
built and which only a full reindex repairs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…36983)

The field table gave the sign convention but not the formula, and omitted
+100.0 (original empty, mirror holds data) — which is the value the
downgrade example prints, so a reader could not reconcile the two. Also
names which verdict each sign blocks: negative blocks advance, positive
blocks rollback.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…nts (#36983)

The report claims exact counts but did not say the two halves measure
differently: Site Search issues a count query while the content half
reads _stats primaries.docs.count, a per-shard counter that only moves on
refresh. A just-written document is searchable while the content row
still shows the old number (~1-3s locally), which reads as a lost write.

Documents the lag and its bound, that the endpoint is a phase-change
advisory and not a write monitor, how to confirm a single write by
fetching the document instead of the count, and the two traps that make
the count misleading: re-publishing is an update (id is
identifier_lang_variant) so the count does not move, and a dual-write
mirror only receives what changes from that point on.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
fabrizzio-dotCMS and others added 3 commits August 11, 2026 14:15
…unter (#36983)

The readiness report is what support consults to answer "did this write
reach OpenSearch". It read the content document counts from _stats
docs.count — a per-shard counter that only advances on shard refresh, so
it trailed a just-written document by seconds. In that window the
document is already searchable while the report still shows the previous
number, which reads as a lost write and sends a technician chasing a
non-bug (or dismissing a real one). A source of truth must not report a
number the engine can already contradict.

The count now comes from ContentletIndexOperations.getIndexDocumentCount,
per index, on each engine leaf — the same way the Site Search half has
always counted, so both halves finally answer alike. getIndicesStats()
stays, but only to decide existence: one call per engine over the whole
set, so both slots are settled from a single snapshot and an absent index
is never confused with an unreachable engine.

A failing count is reported as -1, the established unmeasurable marker,
rather than propagated: it compares unequal, so the verdict degrades to
out-of-sync and safeToRollback to false — never to a false green.

The stats entries in the unit test now carry a poison count, so reading
the number from stats again fails loudly instead of silently
reintroducing the lag.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…6983)

A Phase-3 crawl produced a Site Search index with 14 documents instead of
~443. Not a crawl defect: the bundlers build from conAPI.searchIndex, a
phase-routed search over the CONTENT index, so in Phases 2/3 the corpus
comes from OpenSearch. With the content mirror unreindexed (685 live docs
on ES, 21 on OS) the crawl could only find 14.

Documents the mechanism with the call site, and why it is worse than the
read-time cliff already described: the crawl reports success, the
bundlers swallow search failures at debug level, and the damage outlives
its cause — reindexing the content store afterwards does not repair the
Site Search index already built from the empty one, and the readiness row
reads healthy because the index exists on the right engine (the defect is
inside it, not in its shape).

States the ordering rule this implies: in Phases 2/3, full content
reindex first, Site Search crawl second.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…36983)

Every completeness signal in the readiness report compared one engine
against the other, which stops being an answer exactly where it matters
most: in Phase 3 there is no second engine, so a mirror that was never
rebuilt reads unremarkably — and everything downstream inherits its
emptiness silently, a Site Search crawl included, since it builds its
corpus from a query against this index.

Content rows now carry expectedDocCount plus esCoveragePercent /
osCoveragePercent: each engine measured against the DATABASE, the source
of truth that is identical in every phase. The case that prompted this
reads 3.06 instead of looking normal. When a copy is materially
incomplete the recommendation names it and spells out the fallout. It
never changes the verdict — the verdict states the ES<->OS relationship,
which is a different fact.

The denominator is O(1): pg_class.reltuples times 1 - null_frac of
live_inode from pg_stats, two catalog lookups and no table access. An
exact COUNT is a sequential scan (verified with EXPLAIN — no index-only
path counts non-null live_inode without walking the table), which on a
customer-sized table would mean a multi-second query on every refresh.
The estimate measured 689/682 against an exact 686/685, well inside what
this metric claims: it exists to tell 3% from 97%, never 99% from 100%. A
never-analyzed table reports reltuples = -1, treated as unknown (fields
omitted) rather than as an empty index.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@semgrep-dotcms

Copy link
Copy Markdown
Contributor

Semgrep found 6 CUSTOM_INJECTION-2 findings:

The method identified is susceptible to injection. The input should be validated and properly
escaped.

If this is a critical or high severity finding, please also link this issue in the #security channel in Slack.

fabrizzio-dotCMS and others added 2 commits August 11, 2026 16:05
…ex (#36983)

A crawl does not read the database: the bundlers build the bundle from
ContentletAPI#searchIndex, a phase-routed search over the CONTENT index.
So it can only find what that index holds — a Phase-3 crawl against an
OpenSearch mirror that was never rebuilt silently produced a Site Search
index with 14 documents instead of ~443, reported success, and left an
artifact that a later content reindex does NOT repair.

Before crawling, the job now measures the coverage of the content index
it is about to read — the read engine's copy against the database, the
metric the readiness endpoint already reports — and logs a WARN naming
the index, the engine, the percentage and the fact that reindexing
afterwards will not fix the result.

Advisory only, deliberately: it never stops the crawl, and a failure to
measure is swallowed, because a diagnostic must not be able to break
indexing. Only the engine the phase actually reads from is checked, so an
incomplete OpenSearch mirror stays silent in Phases 0/1 where the crawl
queries a complete Elasticsearch — warning there would train operators to
ignore the message. Threshold configurable via
SITE_SEARCH_CRAWL_MIN_CONTENT_COVERAGE_PERCENT (default 95, 0 disables).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The denominator came from pg_class.reltuples, a planner estimate that
drifts a few points between ANALYZE runs — it surfaced as coverage of
100.59% on a complete index, which reads as a defect and costs a support
question every time.

Now counted exactly: SELECT COUNT(*), COUNT(live_inode) FROM
contentlet_version_info. One row per (identifier, lang, variant_id), the
same unit as an index document, so a complete index reads exactly 100.0 —
verified against a live install at 686/685, matching the index counts
exactly.

The earlier claim that this required a sequential scan was wrong: both
aggregates resolve through index-only scans (COUNT(live_inode) over
idx_contentlet_vi_live with an IS NOT NULL condition), reading narrow
btrees rather than the heap. It runs on an admin-only endpoint on demand
and once per crawl, never on a write path.

With an exact denominator, above 100% now means something real —
documents in the index the database no longer has — instead of sampling
noise.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ured numbers (#36983)

The previous commit claimed both aggregates resolve through index-only
scans. They do not: asking for COUNT(live_inode) in the same statement
needs the column, so PostgreSQL runs a Parallel Seq Scan. The index-only
plan seen earlier was from running the two counts separately, on a
686-row table where any plan looks the same.

Measured on local copies at real sizes: 15 ms / 171k rows, 21 ms / 394k,
22 ms / 453k — roughly linear at ~50 ns per row on a warm cache.

Also records why it stays one statement: split, COUNT(*) alone does drop
to a Parallel Index Only Scan (17 ms), but the live half remains a
sequential scan regardless — nearly every row has a live version, so the
index buys the planner nothing — and the two together measured 41 ms
against 28 ms for the combined form.

No behaviour change; the query is unchanged and is the fastest of the
three measured shapes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…they are (#36983)

"expectedDocCount" did not say where the number came from and
"coveragePercent" was jargon — both cost a question every time someone
read the report, which is the opposite of what a support tool should do.

  databaseDocCount: 686
  es: { docCount: 686 }, esIndexedPercent: 100.00
  os: { docCount: 21  }, osIndexedPercent: 3.06

The three now read as one count taken from three places, and
"indexedPercent" states the question it answers: what percentage of the
content is indexed there. Config key renamed to match:
SITE_SEARCH_CRAWL_MIN_CONTENT_INDEXED_PERCENT.

Rename only — no behaviour change. The endpoint is @hidden and absent
from the OpenAPI schema, so no published contract moves.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…#36983)

Follows the rename: the prose still said "coverage" where the payload now
says esIndexedPercent / osIndexedPercent, which would have sent a reader
looking for a field that does not exist.

Also makes the two worked examples carry the new fields: the backup-index
example now shows databaseDocCount with 100.0 / 0.0, and notes that this
pair is the part of the row that survives into Phase 3 where driftPercent
has nothing left to compare; the downgrade example states why a Site
Search row has none of them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…legacy pointer (#36983)

Seven places asked IndiciesInfo#getSiteSearch() which index is the
default — the Elasticsearch-era pointer. From Phase 3 on that is stale:
activateIndex fans out to OpenSearch alone, so the default moves in
VersionedIndices while the legacy row freezes at whatever was default
before the migration (and the Phase-3 cleanup deliberately preserves that
row, so it never even goes null). Every screen reading it showed the
wrong index as default: the Search tab preselected it and marked it
"(Default)", the Indices tab highlighted the wrong row and offered "Make
Default" on the index that already was one.

Adds SiteSearchAPI#defaultIndexName(), routed to the read provider — the
legacy pointer in Phases 0/1, VersionedIndices (with its legacy fallback)
in Phases 2/3. Both leaves already had the logic; isDefaultIndex now
delegates to it so "which index is the default" has one definition per
engine. The JSPs resolve it once above their loops rather than per row.

Also removes an NPE: SiteSearchAjaxAction#getIndexStatus dereferenced
getSiteSearch() directly, so it 500'd whenever no default had ever been
set (a fresh install, or after deleting every index).

No behaviour change in Phases 0/1, where the read provider is
Elasticsearch and the answer is the same pointer as before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…stant (#36983)

Semgrep flagged the statement as a possible injection (CUSTOM_INJECTION-2)
because it was assembled by concatenation. There is no injection — every
fragment was a literal and nothing caller-supplied ever reached it — but
a scanner cannot know that from a concatenation, and neither can a reader
skimming the method.

Moved to a static final text block, so the statement is fixed by
construction: no interpolation, no parameters, nothing to taint. Same
SQL, same plan, same numbers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment thread dotCMS/src/main/java/com/dotcms/publishing/job/SiteSearchJobImpl.java Outdated
fabrizzio-dotCMS and others added 5 commits August 12, 2026 15:20
… lock (#36983)

The incomplete-content-index warning measured on every crawl from inside
prepareJob, which holds a monitor on SiteSearchJobImpl.class — a JVM-wide
lock every concurrent crawl on the node contends for. Measuring costs a
sequential scan of contentlet_version_info plus a stats call and a count
query per engine, and none of it depends on what prepareJob computes.

* Moved the call to run(), between prepareJob and the publish loop: out of
  the class-wide monitor, still before the crawl it warns about.
* Skipped outright when the migration has not started. In Phase 0 there is
  no second engine whose copy could have been left behind — the failure the
  check exists to catch — so a non-migrating install now pays nothing for it.
* site_search_job_schedule.jsp: added the missing com.dotmarketing.util.Logger
  import. The scriptlet added for the alias fix calls Logger.warn, and neither
  the JSP nor init.jsp imported it, so translating the page failed and broke
  the Schedule Site Search Job dialog. Its two sibling JSPs already carry it.

Test: the phase-0 gate is pinned by recording whether the supplier is
consulted, not by having it throw — the measurement runs inside a vavr Try,
which swallows Throwable, so a thrown assertion would be absorbed and pass
against the very regression it guards. Verified it fails with the gate removed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The check bought a diagnostic, not behaviour — the crawl runs identically
whether it fires or not — but every install paid for it on every crawl: one
sequential scan of contentlet_version_info plus six engine round-trips. That
is the wrong trade for a message only someone diagnosing a migration reads.

SITE_SEARCH_CRAWL_MIN_CONTENT_INDEXED_PERCENT now defaults to 0 (off). Set it
to 95 to get the previous behaviour while investigating, then set it back.
The phase guard stays as a second gate: Phase 0 is skipped even when enabled.

Both guards are plain config reads and run before anything touches the
database or an engine.

Test: the default is pinned by recording whether the mirror supplier is
consulted, so "off" means no measurement rather than merely no warning; the
phase guard keeps its own equivalent test with the check switched on.

Docs: OPENSEARCH_MIGRATION.md documents the switch, the measured cost, and
why it is opt-in.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every setting that changes migration behaviour was documented inline in the
section that happened to use it — the phase flag near the top, the shadow-write
log level in a gotcha 1200 lines down, the readiness role key in the endpoint
section, and the new Site Search crawl threshold in the crawl section. Nothing
answered "what can I configure for this migration?".

One table under Executive Summary with all four, their shipped defaults and a
link to the section that explains each. Defaults verified against the code, not
the surrounding prose. Also notes that the OpenSearch connection settings are
separate, and that OS_HOSTNAME/PORT/PROTOCOL fall back to their ES_ equivalents
— which is why an install that never configured OpenSearch still resolves an
endpoint instead of failing loudly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…36983)

The server-side guard recovered an index's real alias when the job detail
held a raw index name, but never wrote the recovery down — and the job detail
is the job's only memory between runs.

So the repair lasted exactly one crawl. That crawl deletes the index whose
name is stored, so the next run resolves the stored string to neither an alias
nor an existing index, concludes it is a brand-new job, and:

  * stamps the dead index's name onto the new index as its alias — the exact
    damage this issue is about, recreated by the code meant to prevent it; and
  * abandons the index it built on the previous run, which keeps the user's
    real alias and silently stops being crawled, because switchIndex skips
    everything when there is no old index to swap from.

Net effect: the custom alias survives, pointing at an index that quietly goes
stale, while the job crawls a different one under a machine-generated name.

Two halves, so neither can come back on its own:

* persistResolvedAlias — writes the recovered alias back to the job detail.
  SiteSearchJobProxy is a Quartz StatefulJob over a JDBC store, so the data map
  is re-persisted after execution, including after a failure (harmless: an
  alias is a stable handle whether or not the crawl reached the switch).
* aliasForNewIndex — refuses to adopt a sitesearch_<timestamp>_<uuid> string
  as a new index's alias, so a stale name cannot be resurrected even by a job
  nobody repaired. Reuses the shape check the readiness report already uses to
  flag these (now SiteSearchMirrorReconciler.isIndexNameShaped) rather than
  restating it, so detector and preventer cannot drift.

An index with no alias at all is deliberately left alone: its name changes on
every full crawl, so there is nothing stable to persist. The second guard is
what stops that case from resurrecting a dead name.

Test: 6 new cases, 95/95 green. The stale-name guard was verified by disabling
it and watching test_staleIndexNameIsNotAdoptedAsANewIndexAlias fail.
test_unknownName_isKeptAsTheAliasOfANewIndex still passes unchanged — a real
alias a person chose is unaffected; only the generated shape is refused.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…report (#36983)

Four follow-ups from the pre-merge review.

* MirrorStatus.verdictFor — an unmeasurable count is never IN_SYNC. A failed
  count is reported as -1, and the code relied on that comparing unequal. It
  does not when BOTH engines fail: -1 == -1 read as a perfect match, so the
  report answered "in sync / safe to advance" about a mirror it never measured.
  Reachable without any outage — a search user granted indices:monitor/stats
  but not indices:data/read/count resolves existence and fails counting. Now
  checked explicitly, so unknown degrades to drift.

* MirrorStatus.indexedPercentOf — an absent copy reports no percentage rather
  than 0.0. Zero says "this engine lost all its content"; the truth is "there
  is no such index here", which `exists` and MISSING_COUNTERPART already state.
  It would have lit up every content row in Phase 3, where the Elasticsearch
  copies are legitimately gone.

* SiteSearchJobImpl.incompleteContentIndexWarning — the swallowed failure now
  logs. The check is opt-in; whoever switched it on did so to learn something,
  and silence made "index is complete" indistinguishable from "the measurement
  blew up". Every other quiet-failure path here already logged.

* SiteSearchAPI.java — restored the file's original CRLF endings. An
  end-to-end rewrite turned a 58-line addition into a 474-line diff and took
  the whole interface's git blame with it; the diff is now 58 insertions, 0
  deletions.

Test: 2 new cases (both counts failing, absent copy), 97/97 green. Both were
verified by removing their guard and watching them fail.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@fabrizzio-dotCMS
fabrizzio-dotCMS added this pull request to the merge queue Aug 13, 2026
Merged via the queue into main with commit ad09f03 Aug 13, 2026
68 checks passed
@fabrizzio-dotCMS
fabrizzio-dotCMS deleted the issue-36983-sitesearch-alias-phase-aware branch August 13, 2026 14:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

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-G17] Site Search portlet: alias overwritten after crawl, index selector shows IDs, cross-phase alias visibility gaps (Phase 2–3)

2 participants