Skip to content

Fixes #32255: Surface search shard failures instead of a silent empty result - #32401

Merged
mohityadav766 merged 8 commits into
mainfrom
check-issue-32255
Sep 9, 2026
Merged

Fixes #32255: Surface search shard failures instead of a silent empty result#32401
mohityadav766 merged 8 commits into
mainfrom
check-issue-32255

Conversation

@mohityadav766

Copy link
Copy Markdown
Member

Describe your changes:

Fixes #32255

I worked on surfacing search shard failures because both engines answer a search whose shards threw with HTTP 200 and whatever the surviving shards produced, and nothing in the service ever looked at _shards.failed — 14 client.search() sites per engine, zero shards() references, no WARN or ERROR logged. The shards that throw are the ones holding data (an empty index has nothing to score and always succeeds), so the hits that failed to come back are exactly the ones the user was looking for, and a broken search engine renders as an ordinary "no results" screen.

Type of change:

  • Bug fix

High-level design:

Where the guard lives. search(SearchRequest, Class) is non-final on both vendor clients, and each engine builds its client at exactly one site (OpenSearchClient:172, ElasticSearchClient:185). One subclass per engine therefore covers every client.search(...) in the service — search, aggregation, column and lineage managers alike — with two one-line swaps and no call-site edits. The builder-lambda overload is final, but it compiles to a call on the overridable one, so it is covered too; that is an assumption about someone else's bytecode, so it is asserted in a test rather than reasoned about. msearch/count/scroll are not used anywhere in the service, so search() is the whole read surface.

Rejected alternative: guarding each caller. That is 28 edits across two files, leaves the aggregation/lineage managers untouched, and leaves call site #29 unguarded.

Policy (SearchShardFailures, shared by both engines):

  • failed > 0always WARN, with the reason the engine gave and the failing index (table_search_index[0]: null_pointer_exception), not just a shard count. The issue reports this single line as what would have saved a day of debugging.
  • failed > 0 && hits == 0SearchException (500). A search that lost shards and found nothing cannot be shown to anyone as "no results".
  • failed > 0 && hits > 0passes through unchanged. Degraded is not wrong, and failing it would turn every rolling restart and shard relocation into a user-visible outage.

hits reads hits.total.value, falling back to the returned page when track_total_hits is off (it is caller-controlled via SearchResource) — otherwise an absent total reads as zero and would fail every degraded search that did return rows.

Backward compatibility: the only behaviour change is that one previously-silent case now returns 500 instead of a misleading 200. Healthy and degraded-but-productive searches are byte-identical.

Out of scope: the trigger on OpenSearch 3.3.2 is an upstream Lucene 10.3.1 bug, fixed in 3.4.0 / Lucene 10.3.2. This PR makes the failure loud; it does not make 3.3.2 work. The documented OpenSearch floor should move to 3.4.0 (this repo already standardizes on 3.4.0 in every compose file, TestSuiteBootstrap, and all ITs), but the docs live in openmetadata-docs and are not touched here.

Tests:

Use cases covered

  • A healthy search is returned unchanged.
  • A search that lost shards but still found rows is returned, with a WARN naming the failing index and the engine's error type.
  • A search that lost shards and found nothing returns 500 carrying the engine's own reason, instead of an empty result list with 200.
  • The same holds when the caller disabled track_total_hits, and via both search(...) overloads, on both OpenSearch and Elasticsearch.

Unit tests

  • I added unit tests for the new/changed logic.
  • Files added:
    • openmetadata-service/src/test/java/org/openmetadata/service/search/SearchShardFailuresTest.java
    • openmetadata-service/src/test/java/org/openmetadata/service/search/opensearch/ShardFailureAwareOpenSearchClientTest.java
    • openmetadata-service/src/test/java/org/openmetadata/service/search/elasticsearch/ShardFailureAwareElasticsearchClientTest.java
  • 14 tests, all passing. Coverage from mvn jacoco:prepare-agent test jacoco:report -pl openmetadata-service:
SearchShardFailures                   lines 14/14 = 100.0%
ShardFailureAwareElasticsearchClient  lines 24/26 =  92.3%
ShardFailureAwareOpenSearchClient     lines 18/20 =  90.0%
  • Regression: mvn test -pl openmetadata-service -Dtest='org.openmetadata.service.search.**'2250 tests, 0 failures. mvn spotless:check clean.

Backend integration tests

  • Not applicable — no new or changed REST endpoint. The behaviour is a search-client concern, and provoking a genuine shard failure requires an engine-level fault that cannot be injected reliably from an IT, so it was verified against a real broken cluster instead (below).

Ingestion integration tests

  • Not applicable (no ingestion changes).

Playwright (UI) tests

  • Not applicable (no UI changes).

Manual testing performed

Reproduced the reporter's deployment shape end to end on a real cluster, using OpenMetadata's own shipped default ranking query (4 constant_score + 4 script_score stages under a dis_max):

  1. docker run -d -p 9333:9200 -e discovery.type=single-node -e DISABLE_SECURITY_PLUGIN=true opensearchproject/opensearch:3.3.2
  2. Created one populated index (table_search_index, 1 doc) plus three empty ones, all behind a dataAsset alias.
  3. Issued the default ranking query against the alias:
HTTP 200   _shards: total=4 successful=3 failed=1
hits.total = 0
failure -> index=table_search_index shard=0 type=null_pointer_exception

Only the populated index failed — the exact signature in the issue. That response is what the new guard rejects, and the unit tests assert on the same shape.

While reproducing this I found the issue's stated mechanism is not quite right, and the correction matters for anyone triaging it: the NPE is not caused by having two or more script_score clauses. It fires when a single script_score clause matches zero documents while sitting in a disjunction next to any clause that does match. Two matching script_score clauses are fine; a lone non-matching one is fine. Verified on 3.3.2 (Lucene 10.3.1) against 3.4.0 (Lucene 10.3.2) as a control. This explains the three things the report could not: why only populated indices fail, why removing one ranking rule did not help but removing all four did, and why size: 0 gives a false negative. It also means no ranking-rule trimming is a reliable workaround, which is why this PR does not attempt a query-shape fix.

UI screen recording / screenshots:

Not applicable.

Checklist:

  • I have read the CONTRIBUTING document.
  • My PR title is Fixes <issue-number>: <short explanation>
  • My PR is linked to a GitHub issue via Fixes #<issue-number> above.
  • I have commented on my code, particularly in hard-to-understand areas.
  • For JSON Schema changes: not applicable — no schema changes.
  • For UI changes: not applicable — no UI changes.
  • I have added tests (unit / integration / Playwright as applicable) and listed them above.

Bug fix

  • I have added a test that covers the exact scenario we are fixing. #32255 is referenced in the test and in SearchShardFailures for future reference.

🤖 Generated with Claude Code

… result

Both engines answer a search whose shards threw with HTTP 200 and whatever
the surviving shards produced. Nothing in the service looked at
_shards.failed on any read path -- 14 client.search() sites per engine, zero
shards() references -- so a broken query engine and a genuinely empty catalog
were indistinguishable, with no WARN or ERROR logged either.

The shards that throw are the ones holding data (an empty index has nothing
to score and always succeeds), so the hits that failed to come back are
exactly the ones the user was looking for. On OpenSearch 3.3.2 that turns
every search into an ordinary "no results" screen.

Guard the client rather than each caller: search(SearchRequest, Class) is
non-final on both vendor clients and each engine builds its client at exactly
one site, so one subclass per engine covers every search in the service. The
final builder-lambda overload compiles to a call on the overridable one, so it
is covered too.

Shard failures are always logged with the reason the engine gave. Only a
response that lost shards and carries no hits is rejected -- one that lost
shards and still found something is degraded rather than wrong, and failing it
would turn every rolling restart into a user-visible outage.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings September 1, 2026 17:28
@mohityadav766
mohityadav766 requested a review from a team as a code owner September 1, 2026 17:28
@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

❌ PR checklist incomplete

This PR cannot be merged until the following are addressed on its linked issue:

The fields live on the linked issue in the Shipping project (open the issue → right sidebar → Projects). After you set them, re-run this check (or push a commit) — issue/project changes do not re-trigger it automatically.

Maintainers can bypass this check by adding the skip-pr-checks label.

@github-actions github-actions Bot added backend safe to test Add this label to run secure Github workflows on PRs labels Sep 1, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR hardens OpenMetadata’s search layer by detecting Elasticsearch/OpenSearch partial shard failures (_shards.failed > 0) and preventing those failures from being silently surfaced to callers as an ordinary empty result set (notably the #32255 failure mode).

Changes:

  • Add a shared SearchShardFailures policy that WARN-logs shard failures and throws SearchException when shard failures coincide with zero hits.
  • Wrap both vendor clients with shard-failure-aware subclasses so all client.search(...) call sites are covered without per-caller edits.
  • Add unit tests validating behavior for healthy, degraded-with-hits, and degraded-with-no-hits responses (including the builder-lambda overload).

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
openmetadata-service/src/main/java/org/openmetadata/service/search/SearchShardFailures.java Implements the shared policy for logging and rejecting untrustworthy empty results on shard failures.
openmetadata-service/src/main/java/org/openmetadata/service/search/opensearch/ShardFailureAwareOpenSearchClient.java Overrides OpenSearch search(...) to enforce SearchShardFailures on every search response.
openmetadata-service/src/main/java/org/openmetadata/service/search/opensearch/OpenSearchClient.java Switches OpenSearch client construction to the shard-failure-aware subclass.
openmetadata-service/src/main/java/org/openmetadata/service/search/elasticsearch/ShardFailureAwareElasticsearchClient.java Overrides Elasticsearch search(...) to enforce SearchShardFailures on every search response.
openmetadata-service/src/main/java/org/openmetadata/service/search/elasticsearch/ElasticSearchClient.java Switches Elasticsearch client construction to the shard-failure-aware subclass.
openmetadata-service/src/test/java/org/openmetadata/service/search/SearchShardFailuresTest.java Unit tests for the shared shard-failure policy.
openmetadata-service/src/test/java/org/openmetadata/service/search/opensearch/ShardFailureAwareOpenSearchClientTest.java Unit tests ensuring OpenSearch client wrapper guards both overloads and handles missing totals.
openmetadata-service/src/test/java/org/openmetadata/service/search/elasticsearch/ShardFailureAwareElasticsearchClientTest.java Unit tests ensuring Elasticsearch client wrapper guards both overloads and handles missing totals.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +40 to +54
String failures = String.join("; ", listOrEmpty(failureDetails));
LOG.warn(
"Search completed with {} of {} shards failing, hits={}. Shard failures: {}",
failedShards,
totalShards,
hits,
failures.isEmpty() ? "<none reported>" : failures);

if (hits == 0) {
throw new SearchException(
String.format(
"Search failed on %d of %d shards and returned no results, so an empty result cannot "
+ "be trusted. Shard failures: %s",
failedShards, totalShards, failures.isEmpty() ? "<none reported>" : failures));
}
@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

✅ Playwright Results — workflow succeeded

Validated commit 4942bfcb8620124bfed55951473524a0e806903d in Playwright run 34340716933, attempt 1.

✅ 4483 passed · ❌ 0 failed · 🟡 5 flaky · ⏭️ 1 skipped · 🧰 0 lifecycle flaky

Performance

Blocking targets: ✅ met · Optimization targets: 🟡 in progress

Shard-job maxima below are not the full workflow wall time; the linked run includes build, fixture, planning, and reporting.

🕒 Full workflow signal wall (to summary) 1h 0m 41s

⏱️ Max setup 4m 30s · max shard execution 21m 9s · max shard-job elapsed before upload 24m 38s · reporting 18s

🌐 217.81 requests/attempt · 2.31 app boots/UI scenario · 45.86% common-shard skew

Optimization targets still in progress:

  • Common shard skew was 45.86% (convergence target: at most 15%).
  • Browser traffic was 217.81 requests per attempt (convergence target: fewer than 200).
  • Application boot ratio was 2.31 per UI scenario (10921 boots / 4736 scenarios; convergence target: at most 1).
Shard Passed Failed Flaky Skipped Lifecycle failed Lifecycle flaky
✅ Shard advanced-search-01 130 0 0 0 0 0
🟡 Shard chromium-01 133 0 1 0 0 0
✅ Shard chromium-02 130 0 0 0 0 0
✅ Shard chromium-03 117 0 0 0 0 0
✅ Shard chromium-04 148 0 0 0 0 0
✅ Shard chromium-05 156 0 0 1 0 0
✅ Shard chromium-06 188 0 0 0 0 0
🟡 Shard chromium-07 183 0 1 0 0 0
✅ Shard chromium-08 193 0 0 0 0 0
✅ Shard chromium-09 160 0 0 0 0 0
✅ Shard chromium-10 140 0 0 0 0 0
✅ Shard chromium-11 145 0 0 0 0 0
✅ Shard chromium-12 149 0 0 0 0 0
✅ Shard chromium-13 152 0 0 0 0 0
✅ Shard chromium-14 160 0 0 0 0 0
🟡 Shard chromium-15 157 0 1 0 0 0
✅ Shard chromium-16 163 0 0 0 0 0
🟡 Shard chromium-17 195 0 1 0 0 0
🟡 Shard chromium-18 148 0 1 0 0 0
✅ Shard chromium-19 134 0 0 0 0 0
✅ Shard chromium-20 180 0 0 0 0 0
✅ Shard chromium-21 129 0 0 0 0 0
✅ Shard chromium-22 176 0 0 0 0 0
✅ Shard chromium-23 157 0 0 0 0 0
✅ Shard chromium-24 156 0 0 0 0 0
✅ Shard chromium-25 184 0 0 0 0 0
✅ Shard data-asset-rules-01 65 0 0 0 0 0
✅ Shard domain-isolation-01 16 0 0 0 0 0
✅ Shard global-state-01 34 0 0 0 0 0
✅ Shard import-export-01 66 0 0 0 0 0
✅ Shard import-export-02 84 0 0 0 0 0
✅ Shard ingestion-01 45 0 0 0 0 0
✅ Shard ingestion-02 41 0 0 0 0 0
✅ Shard reindex-01 28 0 0 0 0 0
✅ Shard search-01 12 0 0 0 0 0
✅ Shard search-rbac-01 29 0 0 0 0 0
🟡 5 flaky test(s) (passed on retry)
  • Features/ContextCenterArticles.spec.tsText formatting (shard chromium-01, 1 retry)
  • Pages/TasksUIFlow.spec.tsCreate and reject tag task for Dashboard via UI (shard chromium-07, 1 retry)
  • Flow/ObservabilityAlerts.spec.tsTable alert (shard chromium-15, 1 retry)
  • Pages/InputOutputPorts.spec.tsOutput ports section collapse/expand (shard chromium-17, 1 retry)
  • Flow/ConditionalPermissions.spec.tsUser with owner permission can only view owned Messaging Services (shard chromium-18, 1 retry)

📦 Download artifacts

How to debug locally
# Download playwright-test-results-<shard> artifact and unzip
npx playwright show-trace path/to/trace.zip    # view trace

sonika-shah
sonika-shah previously approved these changes Sep 7, 2026
# Conflicts:
#	openmetadata-service/src/main/java/org/openmetadata/service/search/elasticsearch/ElasticSearchClient.java
Copilot AI review requested due to automatic review settings September 7, 2026 14:09
@gitar-bot

gitar-bot Bot commented Sep 9, 2026

Copy link
Copy Markdown
Code Review ✅ Approved 2 resolved / 2 findings

Surfaces search shard failures instead of silent empty results by adding a guard at the client level that covers all search call sites. Resolved two edge cases: hits==0 guard misfiring on aggregation/size:0 searches, and deep-paging with track_total_hits off throwing false 500s. Policy: failed shards always log a WARN with the reason and failing index; searches losing shards with zero hits return 500; degraded searches that still found rows pass through. Comprehensive test coverage (14 tests, 100% on SearchShardFailures) and 2250 regression tests all passing.

✅ 2 resolved
Edge Case: hits==0 guard misfires on aggregation/size:0 searches

📄 openmetadata-service/src/main/java/org/openmetadata/service/search/SearchShardFailures.java:34-48 📄 openmetadata-service/src/main/java/org/openmetadata/service/search/opensearch/ShardFailureAwareOpenSearchClient.java:37-47
The guard is installed on the new Java API client's search(SearchRequest,Class), which the explorer confirmed is also the path for aggregation, data-insight and lineage/relationship queries (OpenSearchAggregationManager:229, OpenSearchDataInsightAggregatorManager:84, OSEntityRelationshipGraphBuilder:92, OsUtils:164). Those queries commonly run with size:0 and/or legitimately match zero documents while the real payload is in the aggregation buckets, so matchedHits(response)==0 is normal there and says nothing about trustworthiness. When any shard transiently fails (e.g. during the rolling restart / relocation the PR explicitly wants to keep non-fatal) an aggregation or data-insight call will now throw SearchException (HTTP 500). Consider scoping the fail-on-empty policy to genuine document searches, or basing it on total shards attempted vs. aggregation intent rather than hit count, so aggregation-only responses are not rejected.

Edge Case: Deep-paging with track_total_hits off can throw a false 500

📄 openmetadata-service/src/main/java/org/openmetadata/service/search/opensearch/ShardFailureAwareOpenSearchClient.java:54-61 📄 openmetadata-service/src/main/java/org/openmetadata/service/search/elasticsearch/ShardFailureAwareElasticsearchClient.java:56-63
When track_total_hits is disabled, hits.total() is null and matchedHits falls back to the current page size. On a page beyond the first that legitimately returns an empty page (deep offset past the result set) while a shard transiently failed, matchedHits reads 0 and the guard throws SearchException even though earlier pages returned rows. This turns a benign paginated request into a 500. Consider only applying the empty-result rejection when the request targets the first page / offset 0, or otherwise distinguishing an empty page from an empty result set.

Options

Display: compact → Showing less information.

Comment with these commands to change the behavior for this request:

Compact
gitar display:verbose         

Was this helpful? React with 👍 / 👎 | Powered by Gitar — free for open source

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

backend safe to test Add this label to run secure Github workflows on PRs

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Search silently returns 0 results on OpenSearch 3.3.2: shard failures are not surfaced

3 participants