fix(sitesearch): preserve the custom index alias across a crawl (#36983) - #37010
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>
|
Claude finished @fabrizzio-dotCMS's task in 4m 42s —— View job PR Review —
|
…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>
|
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>
… 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>
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 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 reads3.06instead of looking normal.The denominator is an exact
COUNT, one statement returning both the working and live figures fromcontentlet_version_info. That is aParallel 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 thepg_class.reltuplesestimate instead, which is O(1) but drifts a few points betweenANALYZEruns; 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
prepareJob, which holds a monitor onSiteSearchJobImpl.class— a JVM-wide lock every concurrent crawl on the node contends for — and each measurement costs one sequential scan ofcontentlet_version_infoplus six engine round-trips (twogetIndicesStats, four document counts). Two changes:run(), betweenprepareJoband the publish loop. Nothing in the measurement depends on whatprepareJobcomputes — 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_PERCENTnow defaults to0(off); set it to95to 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.jspwas missing itscom.dotmarketing.util.Loggerimport (@claude's review). The scriptlet added in section 1 callsLogger.warn, and neither the JSP norinit.jspimported 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.CUSTOM_INJECTION-2onContentIndexMirrorReconciler— 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.
getIndexMetaDatarecovered 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, becauseswitchIndexskips 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 —persistResolvedAliaswrites the recovery into the job detail (SiteSearchJobProxyis a QuartzStatefulJobover a JDBC store, so the data map is re-persisted after execution), andaliasForNewIndexrefuses to adopt asitesearch_<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 asSiteSearchMirrorReconciler.isIndexNameShapedrather than restated, so detector and preventer cannot drift.IN_SYNC. A failed count query is reported as-1, andverdictForrelied on that comparing unequal. It does not when both engines fail:-1 == -1looked 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 grantedindices:monitor/statsbut notindices:data/read/countresolves existence and fails counting. Now checked explicitly, so unknown degrades to drift. Kept asCOUNT_DRIFTrather than a newVerdictconstant: the enum is serialized in the endpoint's JSON and documented, andverdictFor's javadoc already promised this behaviour — only the both-failed case escaped it.esIndexedPercent: 0.0. Zero says "this engine lost all its content"; the truth is "there is no such index here", whichexistsandMISSING_COUNTERPARTalready 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.onFailureon itsTry, 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.javahad been rewritten CRLF → LF end to end, turning a 58-line addition into a 474-line diff and taking the whole interface'sgit blamewith it. Original endings restored; the diff is now 58 insertions, 0 deletions.ContentIndexMirrorReconciler.statuses()still callsgetIndicesStats()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
@Hidden+ admin-plus-migration-role gate and is absent from the OpenAPI schema, so no regeneration applies; the new SQL is a fixed literalCOUNTovercontentlet_version_infowith 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
Trythat swallowsThrowable, 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 stalesitesearch_<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. Notetest_unknownName_isKeptAsTheAliasOfANewIndexpasses 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 asIN_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 legitimatesitesearch-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-coreinstall: BUILD SUCCESS ·dotcms-integrationtest-compile: BUILD SUCCESS_counton each cluster.MainSuite1a.-Dmaven.build.cache.enabled=falseand are counted from theTests run:line — the build cache will otherwise skip surefire entirely and still report BUILD SUCCESS.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).DOTCMS_SHADOW_WRITE_LOG_LEVELin 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.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
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.SiteSearchJobImpl.run()callsHibernateUtil.startTransaction()and there is no matchingcommitTransaction()anywhere — the session is closed in thefinally, so the transaction spans the entire publish loop, which is minutes. A long-lived transaction holds backxminand 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