Skip to content

feat(opensearch): extract vendor-neutral SearchAPI and phase-aware router#35609

Open
fabrizzio-dotCMS wants to merge 18 commits into
mainfrom
issue-34609/search-layer-neutral-api
Open

feat(opensearch): extract vendor-neutral SearchAPI and phase-aware router#35609
fabrizzio-dotCMS wants to merge 18 commits into
mainfrom
issue-34609/search-layer-neutral-api

Conversation

@fabrizzio-dotCMS
Copy link
Copy Markdown
Member

@fabrizzio-dotCMS fabrizzio-dotCMS commented May 7, 2026

Summary

Extracts a vendor-neutral SearchAPI interface to decouple the search layer from Elasticsearch-specific types (SearchResponse, ESSearchResults). A phase-aware router (SearchAPIImpl) delegates reads to ES (phases 0–1) or OS (phases 2–3) using the existing PhaseRouter<T> pattern. Addresses PR review point #1 by wiring search/searchRaw into ContentletAPIInterceptor and marking the legacy ES methods for removal.

Changes

New search layer (com.dotcms.content.index)

  • SearchAPI — vendor-neutral interface replacing ESSeachAPI for search, searchRaw, searchRelated
  • SearchAPIImpl — phase-aware router via PhaseRouter<SearchAPI>
  • ESSearchAPIImpl — ES adapter: delegates to legacy ESSeachAPI, converts SearchResponse → neutral DTOs
  • OSSearchAPIImpl — native OS implementation using SearchRequest._DESERIALIZER + VersionedIndicesAPI
  • Neutral DTOs: ContentSearchResponse, ContentSearchResults, SearchHits, SearchHit, TotalHits, AggregationBucket

Interceptor & hook wiring (addresses review point #1)

  • ContentletAPIInterceptor — added search() and searchRaw() overrides with full pre/post hook dispatch and community-license guard on searchRaw
  • ContentletAPIPreHook / ContentletAPIPostHook — added search()/searchRaw() default no-op methods; marked esSearch/esSearchRaw hooks as @Deprecated

Deprecations

  • ContentletAPI.esSearch / esSearchRaw — upgraded to @Deprecated(forRemoval = true), replaced by search() / searchRaw() default methods delegating to APILocator.getSearchAPI()
  • ESContentletAPIImpl.esSearch / esSearchRaw — marked @Deprecated(forRemoval = true)

Caller migrations

  • ContentTypeAPIImpl, BrowserAPIImpl, ESContentTool, WorkflowHelper, PageResource — migrated from getEsSearchAPI() to getSearchAPI()
  • ESContentletAPIImplgetRelatedChildren/getRelatedParents migrated to neutral SearchHit API

Tests

  • Added OSSearchAPIImplIntegrationTest (registered in OpenSearchUpgradeSuite)
  • Migrated ESMappingAPITest, ES6UpgradeTest, ContentletAPITest, PageResourceTest, ContentTypeDestroyAPIImplTest to neutral API

Deferred (separate PRs)

  • ESContentResourcePortlet — relies on SearchResponse.toString() for ES wire-format JSON; marked with FIXME(OS-cutover)

Testing

# Compile
./mvnw compile -pl :dotcms-core -DskipTests

# OS integration tests (requires opensearch-upgrade container on http://localhost:9201)
./mvnw verify -pl :dotcms-integration \
    -Dcoreit.test.skip=false \
    -Dopensearch.upgrade.test=true \
    -Dit.test=OSSearchAPIImplIntegrationTest

# Spot-check migrated callers
./mvnw verify -pl :dotcms-integration \
    -Dcoreit.test.skip=false \
    -Dit.test=ESMappingAPITest,ContentTypeDestroyAPIImplTest,ContentletAPITest

Manual:

  • Verify findRecentContent / getEntriesByContentTypes return correct results against ES
  • Verify BrowserAPIImpl search still filters inodes correctly
  • Verify Velocity $ESContent.search(...) and $ESContent.raw(...) work in templates
  • Verify WorkflowHelper step aggregations display correctly in the workflow UI

Breaking Changes

None. All changes are backward-compatible: esSearch/esSearchRaw are deprecated but still functional until ESContentResourcePortlet is migrated and they are removed.

🤖 Generated with Claude Code

…uter (#34609)

Decouples ESSearchAPIImpl from Elasticsearch vendor types behind a neutral
SearchAPI interface, completing the Search Layer migration task.

New classes:
- SearchAPI: vendor-neutral interface (search, searchRaw, searchRelated overloads)
- SearchAPIImpl: phase-aware router delegating to ES (phases 0-1) or OS (phases 2-3)
- ContentletSearchAPIES: ES implementation returning neutral DTOs
- OSSearchAPIImpl: OS implementation using SearchRequest._DESERIALIZER + VersionedIndicesAPI
- AggregationBucket, ContentSearchResponse, ContentSearchResults: neutral domain DTOs
- OSSearchAPIImplIntegrationTest: live OS integration test suite

Updated callers to use APILocator.getSearchAPI() instead of getEsSearchAPI():
- ContentTypeAPIImpl (findRecentContent, getEntriesByContentTypes)
- ESContentletAPIImpl (getRelatedChildren, getRelatedParents)
- BrowserAPIImpl (processSingleESQuery)
- ESContentTool (search, raw)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@claude
Copy link
Copy Markdown
Contributor

claude Bot commented May 7, 2026

Claude finished @fabrizzio-dotCMS's task in 6m 39s —— View job


PR review — vendor-neutral SearchAPI

Functional / behavioral risks

1. OS path silently drops SearchRequest body fieldsOSSearchAPIImpl.executeSearch deserializes the user's JSON into a SearchRequest template, then manually copies 9 fields (query, aggregations, source, from, size, sort, highlight, postFilter, trackTotalHits) into a new builder. Anything else in the user's JSON — scroll, search_after, terminate_after, timeout, version, explain, min_score, indices_boost, runtime_mappings, stored_fields, suggest, docvalue_fields, script_fields, stats — is silently dropped. Legacy ES uses SearchSourceBuilderUtil.getSearchSourceBuilder(jsonObject.toString()) which round-trips the whole JSON. This is a real divergence the moment any caller (plugin, integration, future internal change) passes a query with one of those keys. (dotCMS/src/main/java/com/dotcms/content/index/opensearch/OSSearchAPIImpl.java:317-358)

2. No timeout on OS search — legacy ES path sets INDEX_OPERATIONS_TIMEOUT_IN_MS on every search. OS path doesn't. A bad query on a busy cluster can hang indefinitely. (OSSearchAPIImpl.java:317-365 vs enterprise/.../ESSearchAPIImpl.java:333-335)

3. Internal OS client symbolSearchRequest._DESERIALIZER is the leading-underscore "internal use" path of opensearch-java. Locks the impl to a private contract that the OS client team can change without a semver bump. (OSSearchAPIImpl.java:324)

4. PhaseRouter.readChecked swallows non-OS errors as "OS unavailable" — in Phase 2 every Exception is caught and retried against ES with the log message "OS index may be stale or unavailable". A DotSecurityException from OSSearchAPIImpl.executeSearch (null user, frontend-roles=false) triggers a misleading error log and double-executes the security check on ES. Worth filtering by exception type so security/argument errors don't masquerade as OS health problems. (PhaseRouter.java:298-310)

API design

5. Overload collision on ContentletAPI.search — the new

default ContentSearchResults<Contentlet> search(String query, boolean live, User user, boolean respectFrontendRoles)

now sits next to the existing Lucene overloads:

List<Contentlet> search(String luceneQuery, int limit, int offset, String sortBy, User user, boolean respectFrontendRoles)

Different query language (JSON vs Lucene), different return type, same method name. The earlier searchJson/searchRawJson name (commit 3188eb5) avoided this — the rename back (320ccd8) doesn't have a rationale in the commit message and meaningfully raises the chance of caller confusion in IDE autocomplete. (Fix this →)

6. Default methods bypass the interceptorContentletAPI.search / searchRaw defaults call APILocator.getSearchAPI() directly. Any code path holding a non-interceptor ContentletAPI (a unit-test stub, a direct ESContentletAPIImpl reference) skips both the pre/post hooks and the community-license guard that the interceptor adds to searchRaw. Legacy esSearch/esSearchRaw were abstract, so subclasses had to opt in. Surface is wider now — worth a comment, or better, a guard inside the default itself.

7. SearchAPIImpl() constructor eagerly resolves CDI beansAPILocator.getSearchAPI() is lazy, but the first call constructs SearchAPIImpl which immediately does CDIUtils.getBeanThrows(ESSearchAPIImpl.class) and OSSearchAPIImpl.class. If anything in early startup (a BackgroundTask, a Quartz job, a CDI-eager bean) touches search before CDI is fully ready, you get a hard fail vs. the legacy lazy-resolved path. Verify the cold-start order or make the lookup lazy. (SearchAPIImpl.java:41-44)

Smaller items

8. ContentSearchResults declares serialVersionUID = 1L but does not implement Serializable. Pick one. (ContentSearchResults.java:24-26)

9. ESContentletAPIImpl.getRelatedChildren (and getRelatedParents) does if (response.hits().hits().isEmpty()) return result; and then iterates response.hits() anyway — the empty-check is dead weight, and the two response.hits() reads invite a future null mistake. Just iterate. (ESContentletAPIImpl.java:2402-2407)

10. OS searchRelated six-arg overload hard-codes live=false regardless of the caller-supplied live arg. Matches legacy ES (also looks like a bug there) but it's now contract on the new neutral API — at minimum add a Javadoc note. (OSSearchAPIImpl.java:162-174)

11. ESSearchAPIImpl wraps null returns from legacy esSearchRaw with "ES search returned null — unable to load index information". The real cause (a DotDataException from loadIndicies() that legacy logs as fatal and swallows) is gone by the time the adapter throws. Worth correlating these so an OS-cutover post-mortem isn't a forensic exercise. (ESSearchAPIImpl.java:71-77)

12. StringBuffer in OSSearchAPIImpl.executeSearch — copied from legacy; StringBuilder is fine for thread-local use. Nit.

Tests

OS integration tests (OSSearchAPIImplIntegrationTest) only exercise empty-index and one one-doc _source rewrite case. None cover: terms-aggregation buckets (the path ContentTypeAPIImpl.findRecentContent and getEntriesByContentTypes both rely on), the sort-by parameter, searchRelated with real related content, or the _source field-loss behavior flagged in #1. The aggregation path is the one most likely to regress silently and the one with the least coverage.


· branch: issue-34609/search-layer-neutral-api

@github-actions github-actions Bot added the Area : Backend PR changes Java/Maven backend code label May 7, 2026
…adeSuite

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… ESSearchAPIImpl adapter

Replace the reimplemented ContentletSearchAPIES with a proper adapter (ESSearchAPIImpl)
that delegates to the legacy com.dotcms.enterprise.priv.ESSearchAPIImpl and converts
ES-specific types to vendor-neutral DTOs via ContentSearchResponse.from().

The new class lives in com.dotcms.content.index.elasticsearch to mirror the symmetric
OSSearchAPIImpl in com.dotcms.content.index.opensearch, is annotated @ApplicationScoped
for CDI, and is resolved via CDIUtils in SearchAPIImpl.

Also fix OSSearchAPIImplIntegrationTest.setUp() to register cluster-prefixed index names
(via getNameWithClusterIDPrefix) in VersionedIndicesAPI so resolveIndex() targets the
correct OpenSearch index name and does not throw index_not_found_exception.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… migrate callers to neutral API

Add vendor-neutral default methods search() and searchRaw() to ContentletAPI, both delegating
to APILocator.getSearchAPI() (the phase-aware router). Mark esSearch() and esSearchRaw() as
@deprecated pointing to the new methods.

Migrate all call sites except ESContentResourcePortlet line 262 (deferred — that caller relies
on SearchResponse.toString() producing ES wire-format JSON; marked with FIXME(OS-cutover)):

- PageResource: esSearch → search, ESSearchResults → ContentSearchResults
- WorkflowHelper: replace ParsedStringTerms aggregations loop with AggregationBucket map iteration
- ESMappingAPITest: esSearch → search (3 callers); esSearchRaw → searchRaw with direct
  aggregations map access instead of raw.toString() JSON parsing
- ES6UpgradeTest: esSearch → search; getAggregations().asList() → getAggregations().isEmpty()
- ContentletAPITest: esSearchRaw → searchRaw; getHits().getHits()[i].getId() → hits().iterator().next().id()
- PageResourceTest: update mocks from ESSearchResults to ContentSearchResults

ESContentletAPIImpl lines 354/360 (getEsSearchAPI() calls) remain unchanged — they serve
the deprecated interface declarations and will be removed together with those declarations
once ESContentResourcePortlet is also migrated.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace ESSearchResults with ContentSearchResults and esSearchAPI().esSearch()
with searchAPI().search() to align the test with the vendor-neutral search layer.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@github-actions
Copy link
Copy Markdown
Contributor

…mark ES methods for removal

Add search() and searchRaw() overrides to ContentletAPIInterceptor so callers going through
the ContentletAPI interface (e.g. WorkflowHelper) still trigger pre/post hooks and the
community-license guard on searchRaw — addressing PR review point #1.

Add matching default hook methods to ContentletAPIPreHook and ContentletAPIPostHook so
existing hook implementations compile without changes.

Upgrade @deprecated@deprecated(forRemoval = true) on esSearch/esSearchRaw in
ContentletAPI and ESContentletAPIImpl, and mark the corresponding hook methods in
ContentletAPIPreHook/PostHook as @deprecated.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…val=true) with replacement refs

Add @deprecated Javadoc pointing to search()/searchRaw() replacements and upgrade
@deprecated@deprecated(forRemoval = true) on esSearch and esSearchRaw in both
ContentletAPIPreHook and ContentletAPIPostHook.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…n OSSearchAPIImplIntegrationTest

Index a real document (identifier + inode) to verify that OSSearchAPIImpl always rewrites
_source to [identifier, inode] regardless of what the caller provides, and that both fields
are present in hit sourceAsMap after the search.

Remove unused imports (assertFalse, Optional).

Remaining gaps (phase routing, ES fallback in Phase 2, permission filtering with non-admin
user) are tracked in issue #35669.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… and SearchHits

ES error responses can return null getHits() (e.g. shard failures). The OS branch
already guarded against this; apply the same pattern to the ES factory methods.

Fixes NPE regression in ESContentletAPIImpl.getRelatedChildren/getRelatedParents
when ES returns a response with null hits.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
fabrizzio-dotCMS and others added 2 commits May 12, 2026 17:46
…on ContentletAPI

Eliminates overload ambiguity with the existing Lucene-based search(String, int, int,
String, User, boolean) methods. The Json suffix makes the query contract explicit at
every call-site: these methods accept an ES/OS JSON query body, not a Lucene string.

Renames propagated to ContentletAPIInterceptor, ContentletAPIPreHook,
ContentletAPIPostHook, WorkflowHelper, and PageResource.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…nchecked casts

ContentSearchResults now carries its element type as a type parameter. SearchAPI,
ESSearchAPIImpl, OSSearchAPIImpl, ContentletAPI, and ContentletAPIInterceptor all
declare ContentSearchResults<Contentlet>. ESContentTool declares
ContentSearchResults<ContentMap> since it maps hits to view objects.

Removes the (Collection<Contentlet>)(Collection<?>) double-cast in PageResource and
the cast-per-element loop in ESContentTool, replacing both with direct typed iteration.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… ContentSearchResults<T>

Follows the rename of ContentletAPI.search/searchRaw → searchJson/searchRawJson and the
introduction of the ContentSearchResults<T> generic parameter.
Also removes stale (Contentlet) casts in loops that are now type-safe.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…on ContentletAPI

The Json suffix was semantically misleading (methods return typed Java DTOs, not JSON)
and unnecessary — the 4-param signature (String, boolean, User, boolean) has no overload
conflict with the existing Lucene-based search methods (6-7 params). Renamed across
ContentletAPI, ContentletAPIInterceptor, Pre/PostHook interfaces, call-sites, tests,
and migration guide.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@github-actions github-actions Bot added Area : Documentation PR changes documentation files and removed AI: Safe To Rollback labels May 13, 2026
…entTool

ESContentTool.search() and raw() were silently replaced with neutral return
types in the SearchAPI extraction commit, with no deprecation path for
Velocity templates that access ES-specific properties (hits, aggregations,
response, suggestions). Templates that only iterate results are unaffected,
but those accessing metadata break silently at render time.

Re-introduces esSearch() → ESSearchResults and esRaw() → SearchResponse as
@deprecated(forRemoval=true) bridges delegating to ContentletAPI's existing
deprecated methods. The neutral search() and raw() methods are untouched.
These bridges will be removed in v26.08.04 alongside ContentletAPI.esSearch*
and the other deprecated methods in the coordinated removal PR.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

AI: Safe To Rollback 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.

1 participant