fix(migration): Site Search highlights, pointer preservation and phase-neutral index tests (#36360) - #36886
fix(migration): Site Search highlights, pointer preservation and phase-neutral index tests (#36360)#36886fabrizzio-dotCMS wants to merge 6 commits into
Conversation
…36360) Site Search search-result snippets lost their `<em>` emphasis in every phase where OpenSearch serves reads. The OpenSearch path already asked the engine to highlight the `content` field, but the neutral `SearchHit` had nowhere to carry the fragments, so `OSSiteSearchAPI` dropped them with an explicit TODO and set an empty array. The weekly phase sweep catches this as an `ArrayIndexOutOfBoundsException` in `ContentletIndexAPIImplTest.testSearch` under Phase 3: the search itself returns hits, only the highlight is gone. - `SearchHit` gains a `getHighlights` component (field -> fragments) plus a `highlightsFor(field)` convenience accessor that never returns null. - Both adapters populate it: OpenSearch already models highlights in that exact shape, while Elasticsearch's `HighlightField` is flattened from its `Text[]` inside the adapter so the vendor type does not leak. - `OSSiteSearchAPI` reads the fragments back, and the highlighted field name and fragment size become constants instead of being spelled out twice. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
) `activateIndex` populated its builder only for a `working_*` or `live_*` name. Any other input fell through every branch: in phases 0/1/2 that was a silent no-op, and in Phase 3 it saved an EMPTY `VersionedIndices`, so the caller got `At least one index must be specified when saving versioned indices for version: 3.X` from deep inside the store — an error naming neither the input nor the real problem. The blank name is easy to reach: `removeClusterIdFromName(null)` returns "", so an absent store pointer degrades into an index literally named the empty string, which then flows into activate/deactivate. Reject the name up front with a message that says what was passed and what is accepted. `ESIndexResource` already maps `DotStateException` to a 400, and both production callers route site-search names to `SiteSearchAPI` before reaching here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…h ITs (#36360) The weekly OpenSearch phase sweep runs these suites under every migration phase, where six failing classes were asserting Elasticsearch-shaped facts rather than real defects. Each assertion is rewritten to stay meaningful in every phase instead of being skipped: - Alias lookups go through `SiteSearchAPI.getAliasToIndexMap()`. The content-index router keys OpenSearch entries by their `.os`-tagged physical name and deliberately kept that contract in #36797, which rerouted the six production callers but left the tests behind. - Default-site-search checks use `SiteSearchAPI.isDefaultIndex()`, and content pointer checks use the phase-aware `getActiveIndexName()`. `IndiciesAPI.loadIndicies()` only ever reads the legacy non-versioned rows, so it reports nothing once activation writes the versioned store. - Name comparisons strip the vendor tag (`IndexTag.strip`) instead of comparing raw, since reporting `.os` from Phase 3 is the intended display contract. `endsWith(logicalName)` in particular can never match a `.os`-suffixed name. - `SiteSearchJobImplTest` clears indices through `SiteSearchAPI.deleteIndex()`, which is idempotent per engine, rather than the content router: `listIndices()` aggregates both engines during the dual-write phases, so a name can exist on only one of them and the content delete propagates `index_not_found`. - `optimize` is handed the physical names the engine holds, matching production callers that feed it names from `getIndices()` — it dispatches by vendor tag. - `ESIndexAPITest.testDeleteOldIndices` normalizes the retention listing to logical names; it already skipped Phase 3 but read OpenSearch-tagged names in Phase 2 while holding ES-flavored ones. Retention itself is correct: each provider preserves its active set from its own store. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ch pointer (#36360) The site-search slot shares the OpenSearch version row with the content pointers but is owned by SiteSearchAPI, and `saveIndices()` is a delete-by-version followed by a re-insert. Six of the ten places that rebuild a `VersionedIndices` for content slots omitted `siteSearch` from the builder, which does not leave the slot alone — it ERASES it. So in the phases where the OpenSearch store is written, a reindex start, a switchover, an abort, an activate or a deactivate of a CONTENT index silently deactivated the active site-search index. Adds `preserveSiteSearch(builder, existing)` and calls it from every rebuild that was missing the carry-over: `pointOS`, `fullReindexSwitchover`'s OS mirror, `fullReindexSwitchoverOS`, both `fullReindexAbort` branches (Phase 3 and the Phase 1/2 mirror), the Phase 3 and mirror branches of `activateIndex`, and `mirrorDeactivateToOsStore`. The four rebuilds that already carried the slot (`abortStrandedOsReindex`, `clearOsStorePointer`) are unchanged. Caught by `ESSiteSearchAPITest.testFullReindexKeepsDefaultSiteSearchIndex` and `testReindexAbortKeepsDefaultSiteSearchIndex` under Phase 3 — exactly what their names assert. Both were masked before: they failed earlier on an alias assertion that went through the content router. Verified: ESSiteSearchAPITest 7/7 under Phase 3 against a live ES + OpenSearch 3.4 stack. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ons (#36360) Three refinements found while verifying the phase sweep fixes against a live ES + OpenSearch stack: - `test_createSiteSearchIndex_shouldBePossibleToAddMoreThan100` created 115 indices and deleted none. Those leftovers pollute every later site-search assertion in the same JVM: the alias map then spans 100+ indices and takes three batched round-trips per lookup. It now cleans up in a finally block. - The alias assertions carry a diagnostic message with the resolved map, so a future failure says which alias was missing instead of just `expected:<x> but was:<null>`. - The two reindex tests built their index name as `<prefix><timestamp>`, without the `_` separator that every other test and the Site Search job itself use. Such a name never resolves its alias through the site-search alias map — the index-name timestamp parsing keys off that separator — so the tests were asserting against a name shape production never produces. Normalized to `<prefix>_<timestamp>`. Verified: 7/7 under Phase 1 (where the alias resolves through the Elasticsearch provider), and green under Phases 2 and 3. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Claude finished @fabrizzio-dotCMS's task in 8m 0s —— View job ReviewReviewed the diff against Response to @wezell's review comment (performance of eager highlights on all search results)The concern is valid to raise, but the eager population does not add cost to the content-search read path:
So the highlight map is effectively already pay-for-what-you-use — it's empty and free for content, populated only for site search. Lazy-loading on-demand wouldn't reduce work on the content path (the empty map is a shared singleton via New IssuesNone blocking.
Notes (non-blocking, no action needed)
Overall: LGTM once the Javadoc/guard mismatch above is reconciled (non-blocking). |
|
For what it’s worth, I think we could separate the content search results from the site search results. Content search results are super important and should be small, performant, and fast, almost records. Site search results are different and can be heavier. Really two different functions |
…efix (#36360) Both nested top_hits assertions matched only the literal `hit id:` / `tree hit id:` prefix. That prefix is template text emitted whenever the #foreach iterates, and `$!{...}` is Velocity's quiet notation, so an unresolvable `$hit.id` renders as the empty string and the assertion stayed green — the exact silent-empty failure mode #36026 was about. Match a value after the prefix instead, mirroring the docCount assertion next to it and the hit-id assertions already used by searchVtl_rendersHitFieldsAndTiming / rawVtl_rendersHitFieldsAndTiming. This is the guard for the neutral hit's Velocity-facing accessors on the aggregation path, where hits are built through Aggregation.from rather than the top-level SearchHits.from. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Thanks — you're right about the direction, so I built it: #36899 separates the two shapes, stacked on this branch. Details below, plus what the field actually costs today and why the lazy variant would have gone the wrong way. The separation (#36899)
public sealed interface SearchHit permits ContentSearchHit, SiteSearchHit {
// ... the six neutral accessors ...
default List<String> highlightsFor(String field) { return List.of(); }
}
The Callers don't choose the shape either — Blast radius turned out to be nil outside the domain package: What the field costs todayWorth recording since it shaped how I scoped this: on the content path the shared field was already free rather than a regression. Highlighting only produces data if the query asks for it, and the only three places that ask are all Site Search ( So #36899 is worth doing for the reason you gave second — cohesion, content results not advertising a Site Search concern — rather than to recover measurable overhead. Side benefit: Why not lazyThe fragments only exist inside the vendor hit ( Two things I'd flag on the split
Also worth noting on your framing: the split is already half-there in the direction you describe. The heavy carrier is Happy either way on sequencing: land this PR and #36899 behind it, or if you'd rather the field never land at all, I can pull the highlights commit out of here and ship the two pointer fixes first. The tradeoff on that second path is that the Site Search phase-2/3 test stays red until the split merges. |
Summary
The weekly OpenSearch Phase Sweep (Sunday 2026-08-02,
main@ce9fd437) failed 6 of 18 jobs. Triaging those failures turned up three real product defects in the ES→OpenSearch migration — lost Site Search highlights, an opaque activation failure, and content-index writes silently deactivating the Site Search index — while the rest were tests asserting Elasticsearch-shaped facts rather than actual regressions. This fixes the three defects and makes the affected assertions meaningful in every migration phase instead of skipping them.Issue #36360.
Changes
Backend — Site Search highlights (
SearchHit,OSSiteSearchAPI)<em>emphasis wherever OpenSearch serves reads. The OpenSearch path already asked the engine to highlight thecontentfield, but the neutralSearchHithad nowhere to carry the fragments, so they were dropped with an explicit TODO.SearchHitgains agetHighlightscomponent (field → fragments) plus ahighlightsFor(field)accessor that never returns null. Both adapters populate it; Elasticsearch'sHighlightFieldis flattened from itsText[]inside the adapter so the vendor type does not leak.Backend — activation diagnostics (
ContentletIndexAPIImpl.activateIndex)VersionedIndices, so the caller gotAt least one index must be specified when saving versioned indices for version: 3.Xfrom deep inside the store — naming neither the input nor the problem.ESIndexResourcealready mapsDotStateExceptionto a 400, and both production callers route site-search names toSiteSearchAPIbefore reaching here.Backend — Site Search pointer preservation (
ContentletIndexAPIImpl)siteSearchslot shares the OpenSearch version row with the content pointers, andsaveIndices()is a delete-by-version followed by a re-insert. Six of the ten places that rebuild aVersionedIndicesfor content slots omittedsiteSearch, which does not leave the slot alone — it erases it. So a reindex start, switchover, abort, activate or deactivate of a content index silently deactivated the active Site Search index.preserveSiteSearch(builder, existing)and calls it from every rebuild that was missing the carry-over. The four that already carried the slot are unchanged.Tests — phase-neutral assertions (5 integration classes)
SiteSearchAPI.getAliasToIndexMap(). The content-index router keys OpenSearch entries by their.os-tagged physical name and deliberately kept that contract in fix(sitesearch): resolve aliases & index stats .os-aware in OpenSearch read phases (#36360) #36797, which rerouted the six production callers but left the tests behind.SiteSearchAPI.isDefaultIndex(); content pointer checks use the phase-awaregetActiveIndexName().IndiciesAPI.loadIndicies()only ever reads the legacy non-versioned rows, so it reports nothing once activation writes the versioned store.IndexTag.strip) rather than comparing raw — reporting.osfrom Phase 3 is the intended display contract, andendsWith(logicalName)can never match a.os-suffixed name.SiteSearchJobImplTestclears indices throughSiteSearchAPI.deleteIndex()(idempotent per engine) instead of the content router. This was the actual cause of the "Site Search returns 0 results in Phase 3" failures:OSIndexAPIImpl.delete()applies the cluster prefix but not the.ostag and treats index-not-found as a successful no-op, so the old loop deleted nothing while reporting success, and the test then searched a stale leftover index.optimizereceives the physical names the engine holds, matching production callers that feed it names fromgetIndices()— it dispatches by vendor tag.ESIndexAPITest.testDeleteOldIndicesnormalizes the retention listing to logical names. Retention itself is correct: each provider preserves its active set from its own store.test_createSiteSearchIndex_shouldBePossibleToAddMoreThan100now deletes its 115 indices; leaving them behind polluted every later alias assertion in the same JVM. The two reindex tests build their index name with the_separator that production and every other test use.SearchHitTestfor the highlight mapping and the empty-map default.Testing
Verified against a live stack (PostgreSQL + Elasticsearch 7.10 + OpenSearch 3.4) — the five affected classes, 61 tests, under each migration phase:
The 5 skipped in Phase 3 are the ES-retention tests the class itself skips there (Elasticsearch is decommissioned); they all run and pass in Phase 2, so that fix is covered.
To reproduce:
Unit tests:
./mvnw test -pl :dotcms-core -Dtest=SearchHitTest→ 5/5.One pre-existing flake, unrelated to these classes, reproduced in phases 2 and 3:
ContentletIndexAPIImplTest.test_that_live_and_working_content_makes_it_into_the_indextimes out after 10s waiting for a document to become visible and passes on retry. Possibly a refresh/visibility gap on the OpenSearch write path — not investigated here.Breaking Changes
None.
SearchHitgains a record component, so its canonical constructor arity changes; the only construction sites are its own builder and the two vendor adapters, all in this repo.activateIndexnow throwsDotStateExceptionfor a name that maps to no content slot, where it previously did nothing — the REST layer already renders that as a 400, and no production caller passes such a name.Deliberately out of scope
OSIndexAPIImpl.deleteomits the.ostag). That silence is worse than the Phase 1 exception, and it is the same bug family as the open fix(sitesearch): reconcile ES/OS index mirrors on the write path (#36360) #36825 — changing it in parallel would conflict.removeClusterIdFromName(null)returns"", turning "no index" into an index named the empty string. This is what made the activation failure reachable; changing the contract touches every caller and cannot be validated without the full battery. The new guard makes the symptom loud instead of silent._separator never resolves its alias — the index-name timestamp parsing keys off it, and that same parsing feeds retention. Production always generatessitesearch_<timestamp>, so this is only reachable with an arbitrary name.🤖 Generated with Claude Code