Skip to content

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

Open
fabrizzio-dotCMS wants to merge 15 commits into
mainfrom
issue-36983-sitesearch-alias-phase-aware
Open

fix(sitesearch): preserve the custom index alias across a crawl (#36983)#37010
fabrizzio-dotCMS wants to merge 15 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 coverage against the DATABASE (expectedDocCount, esCoveragePercent, osCoveragePercent). 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 O(1): pg_class.reltuples × 1 − null_frac of live_inode from pg_stats — two catalog lookups, 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 the metric claims, since it exists to tell 3% from 97%, never 99% from 100%. A never-analyzed table (reltuples = -1) is reported as unknown, not as an empty index.

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

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 reads only PostgreSQL catalog views with no user input)

Additional Info

Tests — 58/58 green

  • SiteSearchJobAliasResolutionTest (new, unit) — 4 cases pinning 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.
  • 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.
  • ContentIndexMirrorReconcilerTest (existing) — 5 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, coverage against the database, coverage absent without a denominator, and a complete mirror not flagged.
  • 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.

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.
  • 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-up 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 coverage metric now available, that guard has a defensible criterion instead of an invented threshold. Worth its own PR.

🤖 Generated with Claude Code

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 3m 19s —— View job


Code Review — PR #37010

  • Gather context (diff, changed files)
  • Review Java changes (reconcilers, SiteSearchJobImpl, SiteSearchAPI)
  • Review the flagged Semgrep injection finding
  • Review JSP changes
  • Post review

New Issues

  • 🟠 High: dotCMS/src/main/webapp/html/portlet/ext/sitesearch/site_search_job_schedule.jsp:79 — The new line calls Logger.warn(this.getClass(), …), but this JSP does not import com.dotmarketing.util.Logger, and neither init.jsp (its only include) nor auth_backend_check.jsp imports it. The two sibling JSPs touched in the same PR (site_search_index_stats.jsp:12, test_site_search.jsp:9) both add/carry the explicit <%@page import="com.dotmarketing.util.Logger"%>; this one was missed. JSP translation happens on first request, so this is a translation-time compile error that will break the Schedule Site Search Job dialog — the very screen this PR is fixing. Add the import. Fix this →

Resolved / Not an issue

  • ✅ Semgrep CUSTOM_INJECTION-2 on ContentIndexMirrorReconciler.java:247False positive. DATABASE_COUNTS_SQL is a fully literal text block with no interpolation, no parameters, and nothing caller-supplied; it's passed verbatim to DotConnect().setSQL(...). No user input reaches it. Safe to triage as /fp in Semgrep.

Notes (non-blocking)

  • The alias-recovery logic in SiteSearchJobImpl.getIndexMetaData (aliasOf reverse lookup over getAliasToIndexMapAllEngines()) and the read-provider-wins merge in SiteSearchAPIImpl.getAliasToIndexMapAllEngines() both look correct and are well covered by the new unit tests. MigrationPhase.current().isReadEnabled() correctly maps to "OpenSearch serves reads" (phases 2/3), so incompleteContentIndexWarning checks the right engine's percentage.
  • The engine-independent DB coverage metric, -1/null handling, and the "advisory, never a gate" warning path all fail safe (failures swallowed, degrade to out-of-sync rather than false-green). Good.
  • SiteSearchAPI.java shows as a full-file rewrite (+266/-208) but the only semantic additions are getAliasToIndexMapAllEngines() (default) and defaultIndexName(); the rest is line-ending/whitespace churn. Harmless, but it inflates the diff and makes future git blame noisier.

Once the missing Logger import is added, this is good to merge.

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

1 participant