fix(sitesearch): preserve the custom index alias across a crawl (#36983) - #37010
Open
fabrizzio-dotCMS wants to merge 15 commits into
Open
fix(sitesearch): preserve the custom index alias across a crawl (#36983)#37010fabrizzio-dotCMS wants to merge 15 commits into
fabrizzio-dotCMS wants to merge 15 commits into
Conversation
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>
5 tasks
Contributor
|
Claude finished @fabrizzio-dotCMS's task in 3m 19s —— View job Code Review — PR #37010
New Issues
Resolved / Not an issue
Notes (non-blocking)
Once the missing · branch |
…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>
…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>
…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>
Contributor
|
Semgrep found 6
The method identified is susceptible to injection. The input should be validated and properly If this is a critical or high severity finding, please also link this issue in the #security channel in Slack. |
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.jspresolved 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'sindexAlias. 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 thesitesearch-ph-3→sitesearch_20260810160529swap 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-unusedESIndexAPIimport.SiteSearchJobImpl— when the storedindexAliasis 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, whilegetAliasToIndexMap()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 setlistIndices()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 bysitesearch_<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 aNOTE— 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
_statsdocs.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 reads3.06instead of looking normal.The denominator is O(1):
pg_class.reltuples×1 − null_fracoflive_inodefrompg_stats— two catalog lookups, no table access. An exactCOUNTis a sequential scan (verified withEXPLAIN: no index-only path counts non-nulllive_inodewithout 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
@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 legitimatesitesearch-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-coreinstall: BUILD SUCCESS ·dotcms-integrationtest-compile: BUILD SUCCESS_counton each cluster.MainSuite1a.Docs (
OPENSEARCH_MIGRATION.md)OS_MIGRATION_INDEX_VISIBILITY_ROLE_KEYrole, defaultos_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).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