Skip to content

Report a PPL result that covers only some of its shards - #5807

Open
ahkcs wants to merge 5 commits into
opensearch-project:mainfrom
ahkcs:fix/ppl-shard-failure-warning
Open

ahkcs wants to merge 5 commits into
opensearch-project:mainfrom
ahkcs:fix/ppl-shard-failure-warning

Conversation

@ahkcs

@ahkcs ahkcs commented Sep 24, 2026 •

Copy link
Copy Markdown
Collaborator

Description

Two defects, both of which have to be fixed for a partial PPL result to be visible at all:

  1. The engine discarded the shard outcome. OpenSearchResponse read only getHits()/getAggregations() and threw away _shards, so a result assembled from the shards that answered was returned as if it were whole ([BUG] PPL returns HTTP 200 with silently partial results when shards fail (no _shards failure surfaced) #5687).
  2. Index pruning removed the evidence before the search ran. An index whose shards are unavailable cannot be probed by _field_caps, which reports no failure for it, so pruning dropped it from the query entirely — leaving nothing for fix 1 (or for allow_partial_search_results=false) to detect ([BUG] Index pruning silently drops an index it cannot reach, hiding missing shards #5808, found while verifying fix 1).

Fix 1 is the first commit, fix 2 the third. Without fix 2, fix 1 is inert on exactly the path that matters: every PPL query Dashboards issues carries request-level time bounds, which is what activates pruning.


OpenSearch answers a search with HTTP 200 as long as one shard responded — search.default_allow_partial_results defaults to true, and the plugin never sets allowPartialSearchResults on the request, so every PPL search inherits it. OpenSearchResponse then read only getHits()/getAggregations() and discarded the _shards block, so PPL presented the surviving shards' rows as the whole answer: no error, no warning, no way for the caller to tell.

During a load spike (workload-management rejection, circuit breaker) or a node drop, that silently undercounts. Reproduced locally on a two-node cluster over a 4-shard pattern holding 990 documents:

# both nodes up
source=osdp-* | stats count() as n        ->  990
# one data node stopped; DSL reports _shards {total: 4, successful: 3, failed: 0}
source=osdp-* | stats count() as n        ->  750     <- no warning, HTTP 200

DSL clients surface this from _shards (OpenSearch Dashboards toasts "N of M shards failed" on the DQL path). PPL responses had nowhere to carry it.

This captures the shard outcome and attaches a warning through the Warning{type,message,detail} → QueryResponse.warnings → SimpleJsonResponseFormatter channel added in #5657, so the existing Explore warning banner renders it:

"warnings": [{
  "type": "PARTIAL_RESULT_SHARD_FAILURE",
  "message": "Results are partial: 1 of 2 shards did not return data.",
  "detail": "Rows and aggregate values from the shards that did not respond are missing, so counts may be undercounted. No copy of those shards was available -- a node may be down or a shard unassigned. Retry the query, or set search.default_allow_partial_results to false so such searches fail instead of returning a subset."
}]

Two details worth reviewer attention:

The shard counters are not disjoint, which the completeness test has to account for. A shard skipped by the can-match phase is reported in both skipped and successful, so the invariant is total = successful + failed + missing. Subtracting skipped a second time hides a missing shard behind every skipped one — verified on a live cluster, where one unassigned shard plus one skipped and one searched reports total: 3, successful: 2, skipped: 1. That matters because can-match skipping is the norm for a time-filtered query once a pattern exceeds pre_filter_shard_size (default 128), i.e. exactly the dashboard workload this is for. Below that threshold the phase does not run, so neither a local cluster nor an IT can reproduce it; ShardStatsTest pins the arithmetic to the measured numbers instead.

All three partial shapes are detected, not only failures. A missing shard — no copy available, the node-drop case — reports failed: 0 and simply does not add up (successful + skipped < total). Checking failedShards > 0 alone, as the issue originally proposed, misses the most common production shape; OpenSearch Dashboards' own DQL shard-failure toast has the same blind spot. A timed_out response is the third shape.

The warning is raised on the query thread, not in the response constructor. In the Calcite path client.search runs on the sql-background pool via BackgroundSearchScanner, whose thread the warning thread-local cannot reach. fetchNextBatch runs on the query thread — the same one that later drains warnings in buildResultSet — so the stats travel on the response object and are recorded there. Identical stats repeated across pages collapse to one warning via the channel's existing de-duplication.

Testing

The integration test fixture is an index whose shard can never be allocated (an allocation filter naming a node that does not exist), alongside a healthy index in the same pattern. That yields total: 2, successful: 1, failed: 0 deterministically, with no node manipulation, and exercises the harder of the two detection paths. The unassignable index is dropped between tests so no other class inherits a red cluster.

Verified locally: 9 new ShardStatsTest cases, 3 new BackgroundSearchScannerTest cases, 4 new ITs, 35 IndexPrunerTest cases, plus :opensearch:test (1747), :core:test (5286), CalcitePartialResultOnMappingConflictIT (10) and CalciteTimeBoundsPruningIT (10) green. A full :integ-test:integTest run is in progress; I will post the result rather than assume it. Also confirmed end to end against a deployed build: the same query returns 990 with both nodes up and 750 with one stopped, now carrying the warning instead of presenting 750 as the answer.

Defect 2 in detail (#5808): pruning silently dropped the evidence

Verifying the warning through the Dashboards path exposed a worse bug in index pruning (#5766, mine), so the second commit fixes it here rather than leaving the first commit ineffective where it matters most.

Pruning probes each index with field caps plus an index_filter and keeps whichever come back. An index whose primary is unassigned cannot be probed, and field caps reports no failure for it — it is simply absent from the response, indistinguishable from an index proven to hold nothing in range:

GET osdp-*/_field_caps?fields=@timestamp  {"index_filter": {...}}
  indices:        ['osdp-main', 'osdp-textconflict']   # osdp-broken silently missing
  failed_indices: None
  failures:       null

So the unreachable index was pruned out of the expression. The search that then ran covered every shard it was given, which means:

  • its shard counts were complete, so the new warning had nothing to raise;
  • search.default_allow_partial_results=false saw a whole search and allowed it.

Measured on a two-node cluster over a 4-shard pattern of 990 documents, one node stopped, with the request-level time bounds Dashboards sends:

result
PPL, pruning on 750, no warning, no error
PPL, pruning on, allow_partial_search_results=false 750, still no error
equivalent DSL search, same setting HTTP 503, rejected

The fix keeps any index the probe could not have ruled out. The mechanism turns on a distinction one probe cannot make but two can — the second being the same _field_caps call with no index filter:

  • filtered returns indices that could be read and can match the range
  • unfiltered returns indices that could be read, range irrelevant

The unfiltered probe reaches nothing the filtered one could not; both miss an unreachable index identically. What it adds is a control that says what an absence means. Measured on a fixture carrying all three cases at once — one index in range, one healthy but out of range, one whose shard is unassigned:

index filtered probe unfiltered probe conclusion
shard_sec_recent (in range) present present matches → keep
shard_sec_old (healthy, out of range) absent present was read, nothing in range → prune
shard_sec_unreadable (shard unassigned) absent absent never read → keep

The two absences in the filtered column look identical; the unfiltered column separates them. shard_sec_old proves it was reachable by appearing without the filter, so its absence with the filter is real evidence of no matching data. shard_sec_unreadable never appears at all, so its absence is evidence of nothing — and that silence is what pruning was previously treating as proof of emptiness.

In code: unsearchable = resolved − unfiltered, then keep = candidates ∪ unsearchable. On the fixture above that prunes only shard_sec_old, which is exactly right.

Two consequences, both intentional. An index that is readable but does not map the time field is also absent from the unfiltered probe, so it is kept — one extra shard in the search, never a wrong answer. And "unreadable" is inferred from absence rather than from a failure marker because _field_caps offers none: failed_indices: null, failures: null, which is the underlying API gap.

Judged by a probe rather than by reading the routing table deliberately — see Security coverage below. Both probes run only when pruning would actually narrow the expression, so the healthy path keeps its single probe. And because pruning already declines when nothing matched the filter, keeping an unreachable index can never narrow a query down to only unreachable indices and turn a partial answer into an all-shards-failed error.

After the fix the three rows above read: 750 plus the warning; rejected under allow_partial_search_results=false; and the DSL 503 unchanged.

Security coverage

integTestWithSecurity runs only org.opensearch.sql.security.*, so a path whose IT lives elsewhere is never exercised with the security plugin installed. ShardFailureWarningSecurityIT closes that for this change, and it is not hypothetical — the first version of the pruning fix read the routing table, which needs cluster:monitor/state. An index-scoped role does not carry that, so the denied request made pruning decline for the whole query: TimeBoundsPruningSecurityIT went red, and every such user would have had pruning silently disabled plus a MISSING_PRIVILEGES audit event per query. Readability is now judged by a second field caps probe, an API those roles already allow, and a unit test asserts no cluster state request is ever issued.

The fixture holds three indices in one pattern — one in range, one out of range carrying a field of its own, and one whose shard can never be allocated — so a bounded query must prune the out-of-range index while keeping the unreadable one:

as an index-scoped user expected
source=shard_sec_* | stats count() 3 + PARTIAL_RESULT_SHARD_FAILURE (warning survives the transport handoff)
same, with request-level bounds 2 + PARTIAL_RESULT_SHARD_FAILURE (warning survives pruning)
source=shard_sec_* | fields legacy_only, with bounds HTTP 400 Field [legacy_only] not found. (pruning still narrows)
source=shard_sec_recent | stats count() 2, no warning

All four verified against a live cluster; the security dimension itself is verified by CI, since the security suite cannot run on my machine (the opensearch-security bundle trips jar hell against this distro snapshot's lib/ jackson).

Scope

Calcite path only, matching where the warnings channel is wired today (plugins.calcite.enabled defaults to true, so this is the primary path). Deliberately left for follow-ups:

  • the V2 fallback path, which builds its QueryResponse without warnings;
  • the error surface when a partial result is rejected: allow_partial_search_results=false now correctly fails the query, but the failure arrives as an HTTP 500 java.sql.SQLException ... the background task failed or interrupted rather than core's HTTP 503 "Search rejected due to missing shards". Worth mapping properly, and orthogonal to this change;
  • a setting to reject partial results instead of warning, mirroring the DSL allow_partial_search_results — it would be one allowPartialSearchResults(false) call on the request, letting core produce its own error rather than inventing one.

The warning type is deliberately distinct from PARTIAL_RESULT: that type means "the engine chose to narrow to a subset of indices", and OpenSearch Dashboards gates a "Rerun without partial results" action on it, which reruns with partial_result: false and would do nothing for a shard failure. Happy to fold it into PARTIAL_RESULT (a one-constant change) if we would rather widen that flag's meaning.

Related Issues

Resolves #5687
Resolves #5808

Check List

  • New functionality includes testing.
  • New functionality has been documented (docs/user/ppl/interfaces/protocol.md gains a Warnings section, which also fills in the PARTIAL_RESULT type Add an opt-in partial-result mode for aggregations on text/keyword mapping conflicts #5657 never documented).
  • API changes companion pull request created, if applicable — n/a, the warnings array already exists in the response contract.
  • Commits are signed per the DCO using --signoff.
  • Public documentation issue/PR created, if applicable — happy to open one if we want this on the website.

By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.
For more information on following Developer Certificate of Origin and signing off your commits, please check here.

@github-actions

github-actions Bot commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

PR Reviewer Guide 🔍

(Review updated until commit 9f3945c)

Here are some key observations to aid the review process:

🧪 PR contains tests
🔒 No security concerns identified
✅ No TODO sections
🔀 No multiple PR themes
⚡ Recommended focus areas for review

Possible Issue

The logic in prune() attempts to keep unsearchable indices by adding them to the candidate set, but if keep.size() equals the original resolved index count, it returns the original indexName instead of the pruned set. This means unsearchable indices are not actually included in the returned result when no pruning occurs. The condition indexExpr.isPrunedBy(keep.size()) will be false when keep.size() equals the total, causing the method to return the original indexName which may not include the unsearchable indices if they were filtered out earlier.

if (0 < candidates.length && indexExpr.isPrunedBy(candidates.length)) {
  // Only once indices would be dropped: an unreachable index is absent from the probe for
  // the same reason an empty one is, so keep it and let the search report the missing shard.
  Set<String> unsearchable = indexExpr.indicesNotProvenEmpty(timeField);
  if (unsearchable.isEmpty()) {
    return new IndexName(String.join(",", candidates));
  }
  Set<String> keep = new LinkedHashSet<>(Arrays.asList(candidates));
  keep.addAll(unsearchable);
  log.info("Index pruning kept unsearchable indices {}", unsearchable);
  if (indexExpr.isPrunedBy(keep.size())) {
    return new IndexName(String.join(",", keep));
  }
  return indexName;
}
Possible Issue

The indicesNotProvenEmpty() method calls probe(null, timeField) to determine which indices are unreadable. However, this unfiltered probe is called even when the filtered probe already succeeded. If the unfiltered probe fails (throws an exception), the entire pruning operation fails and falls back to no pruning. This creates a scenario where a successful filtered probe followed by a failed unfiltered probe causes pruning to be skipped entirely, even though the filtered probe provided valid candidates. The failure handling is inconsistent with the intent to preserve correctness.

Set<String> indicesNotProvenEmpty(String timeField) {
  Set<String> readable = new HashSet<>(Arrays.asList(probe(null, timeField).getIndices()));
  Set<String> missing = new LinkedHashSet<>();
  for (ResolveIndexAction.ResolvedIndex index : resolved().getIndices()) {
    if (!readable.contains(index.getName())) {
      missing.add(index.getName());
    }
  }
  return missing;
}

@github-actions

github-actions Bot commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

PR Code Suggestions ✨

Latest suggestions up to d0bd7e9

Explore these optional code suggestions:

CategorySuggestion                                                                                                                                    Impact
General
Avoid unnecessary probe when not pruning

The indicesNotProvenEmpty method is called unconditionally even when no pruning
would occur. This probe should only run when candidates would actually reduce the
index set, to avoid unnecessary field capabilities requests. Move the unsearchable
check inside the existing isPrunedBy condition.

opensearch/src/main/java/org/opensearch/sql/opensearch/request/IndexPruner.java [75-93]

-Set<String> unsearchable = indexExpr.indicesNotProvenEmpty(timeField);
-if (unsearchable.isEmpty()) {
-  return new IndexName(String.join(",", candidates));
-}
-Set<String> keep = new LinkedHashSet<>(Arrays.asList(candidates));
-keep.addAll(unsearchable);
-log.info("Index pruning kept unsearchable indices {}", unsearchable);
-if (indexExpr.isPrunedBy(keep.size())) {
-  return new IndexName(String.join(",", keep));
+if (indexExpr.isPrunedBy(candidates.length)) {
+  Set<String> unsearchable = indexExpr.indicesNotProvenEmpty(timeField);
+  if (unsearchable.isEmpty()) {
+    return new IndexName(String.join(",", candidates));
+  }
+  Set<String> keep = new LinkedHashSet<>(Arrays.asList(candidates));
+  keep.addAll(unsearchable);
+  log.info("Index pruning kept unsearchable indices {}", unsearchable);
+  if (indexExpr.isPrunedBy(keep.size())) {
+    return new IndexName(String.join(",", keep));
+  }
 }
 return indexName;
Suggestion importance[1-10]: 7

__

Why: The suggestion correctly identifies that indicesNotProvenEmpty is called before checking if pruning would occur. Moving this check inside the isPrunedBy condition would avoid an unnecessary field capabilities request when candidates.length doesn't reduce the index set, improving performance.

Medium
Handle zero total shards explicitly

The missing() calculation can produce incorrect results when total is zero (e.g.,
UNKNOWN stats). This would return 0 even though the stats are invalid. Add an
explicit check for total == 0 to return 0 immediately, making the intent clearer and
avoiding potential arithmetic issues.

opensearch/src/main/java/org/opensearch/sql/opensearch/response/ShardStats.java [62-64]

 public int missing() {
+  if (total == 0) {
+    return 0;
+  }
   return Math.max(0, total - successful - failed);
 }
Suggestion importance[1-10]: 3

__

Why: While the suggestion adds clarity, the Math.max(0, ...) already handles the case correctly when total is 0. The explicit check is redundant since Math.max(0, 0 - 0 - 0) returns 0. This is a minor code style improvement with minimal impact.

Low

Previous suggestions

Suggestions up to commit 38dfa20
CategorySuggestion                                                                                                                                    Impact
Possible issue
Include failed shards in calculation

The missing() calculation doesn't account for failed shards, which could lead to
incorrect counts when shards both fail and go missing. The formula should subtract
all accounted-for shards including failures: total - successful - skipped - failed.

opensearch/src/main/java/org/opensearch/sql/opensearch/response/ShardStats.java [51-53]

 public int missing() {
-  return Math.max(0, total - successful - skipped);
+  return Math.max(0, total - successful - skipped - failed);
 }
Suggestion importance[1-10]: 9

__

Why: This is a critical correctness issue. The missing() method should account for failed shards in its calculation. Currently, if shards fail, they would be incorrectly counted as missing, leading to inflated missing counts and incorrect warning messages. The formula total - successful - skipped - failed correctly identifies shards that neither succeeded, were skipped, nor failed.

High
Validate unsearchable indices subset

The unsearchable set may contain indices not in candidates, causing keep to grow
beyond the original resolved indices. This could lead to querying indices that
weren't part of the initial resolution. Verify that unsearchable indices are a
subset of the resolved indices before adding them to keep.

opensearch/src/main/java/org/opensearch/sql/opensearch/request/IndexPruner.java [82-92]

 Set<String> unsearchable = indexExpr.indicesNotProvenEmpty(timeField);
 if (unsearchable.isEmpty()) {
   return new IndexName(String.join(",", candidates));
 }
+Set<String> resolvedNames = indexExpr.resolved().getIndices().stream()
+    .map(ResolveIndexAction.ResolvedIndex::getName)
+    .collect(Collectors.toSet());
 Set<String> keep = new LinkedHashSet<>(Arrays.asList(candidates));
-keep.addAll(unsearchable);
+unsearchable.stream().filter(resolvedNames::contains).forEach(keep::add);
 log.info("Index pruning kept unsearchable indices {}", unsearchable);
 if (indexExpr.isPrunedBy(keep.size())) {
   return new IndexName(String.join(",", keep));
 }
 return indexName;
Suggestion importance[1-10]: 7

__

Why: The suggestion correctly identifies a potential issue where unsearchable indices might not be validated against the resolved indices before being added to keep. However, examining the code flow shows that indicesNotProvenEmpty only returns indices from resolved().getIndices(), so this scenario is unlikely. The suggestion adds defensive programming but may not be strictly necessary.

Medium
General
Handle probe failure gracefully

The unfiltered probe with null filter may throw an exception if the field caps
request fails, leaving indicesNotProvenEmpty without error handling. Wrap the probe
call in a try-catch to gracefully handle failures and return an empty set, allowing
pruning to fall back safely.

opensearch/src/main/java/org/opensearch/sql/opensearch/request/IndexPruner.java [180-189]

 Set<String> indicesNotProvenEmpty(String timeField) {
-  Set<String> readable = Set.of(probe(null, timeField).getIndices());
-  Set<String> missing = new LinkedHashSet<>();
-  for (ResolveIndexAction.ResolvedIndex index : resolved().getIndices()) {
-    if (!readable.contains(index.getName())) {
-      missing.add(index.getName());
+  try {
+    Set<String> readable = Set.of(probe(null, timeField).getIndices());
+    Set<String> missing = new LinkedHashSet<>();
+    for (ResolveIndexAction.ResolvedIndex index : resolved().getIndices()) {
+      if (!readable.contains(index.getName())) {
+        missing.add(index.getName());
+      }
     }
+    return missing;
+  } catch (Exception e) {
+    log.warn("Failed to probe index readability: {}", e.getMessage());
+    return Set.of();
   }
-  return missing;
 }
Suggestion importance[1-10]: 6

__

Why: While error handling is generally good practice, the test at line 312-318 shows that probe failures are already handled by the caller (pruning falls back to the full expression). Adding try-catch here would duplicate error handling logic and potentially hide issues that should be surfaced. The current design appears intentional.

Low
Suggestions up to commit d553f91
CategorySuggestion                                                                                                                                    Impact
Possible issue
Fix missing shard count calculation

The missing() calculation doesn't account for failed shards, which could lead to
incorrect counts when shards both fail and go missing. The formula should subtract
all accounted shards: total - successful - skipped - failed.

opensearch/src/main/java/org/opensearch/sql/opensearch/response/ShardStats.java [51-53]

 public int missing() {
-  return Math.max(0, total - successful - skipped);
+  return Math.max(0, total - successful - skipped - failed);
 }
Suggestion importance[1-10]: 10

__

Why: This is a critical correctness issue. The missing() calculation should account for failed shards. Without subtracting failed, the count includes shards that are already accounted for in the failed counter, leading to incorrect reporting of missing shards and potentially misleading warnings.

High
General
Avoid unnecessary unsearchable index check

The indicesNotProvenEmpty method is called unconditionally even when no pruning
would occur. This adds unnecessary overhead for every query. Move the unsearchable
check inside the pruning condition to only execute when indices would actually be
dropped.

opensearch/src/main/java/org/opensearch/sql/opensearch/request/IndexPruner.java [74-92]

-Set<String> unsearchable = indexExpr.indicesNotProvenEmpty(timeField);
-if (unsearchable.isEmpty()) {
-  return new IndexName(String.join(",", candidates));
-}
-Set<String> keep = new LinkedHashSet<>(Arrays.asList(candidates));
-keep.addAll(unsearchable);
-log.info("Index pruning kept unsearchable indices {}", unsearchable);
-if (indexExpr.isPrunedBy(keep.size())) {
-  return new IndexName(String.join(",", keep));
+if (indexExpr.isPrunedBy(candidates.length)) {
+  Set<String> unsearchable = indexExpr.indicesNotProvenEmpty(timeField);
+  if (unsearchable.isEmpty()) {
+    return new IndexName(String.join(",", candidates));
+  }
+  Set<String> keep = new LinkedHashSet<>(Arrays.asList(candidates));
+  keep.addAll(unsearchable);
+  log.info("Index pruning kept unsearchable indices {}", unsearchable);
+  if (indexExpr.isPrunedBy(keep.size())) {
+    return new IndexName(String.join(",", keep));
+  }
 }
 return indexName;
Suggestion importance[1-10]: 7

__

Why: The suggestion correctly identifies that indicesNotProvenEmpty is called before checking if pruning would occur. Moving this check inside the pruning condition would avoid unnecessary overhead when candidates.length doesn't meet the pruning threshold, improving performance for queries where pruning is declined.

Medium
Handle probe failures in readability check

If the unfiltered probe throws an exception, the method will propagate it and cause
pruning to fail entirely. Wrap the probe call in a try-catch to handle failures
gracefully and return all resolved indices as potentially unsearchable when the
probe cannot be performed.

opensearch/src/main/java/org/opensearch/sql/opensearch/request/IndexPruner.java [180-189]

-Set<String> readable = Set.of(probe(null, timeField).getIndices());
+Set<String> readable;
+try {
+  readable = Set.of(probe(null, timeField).getIndices());
+} catch (Exception e) {
+  return resolved().getIndices().stream()
+      .map(ResolveIndexAction.ResolvedIndex::getName)
+      .collect(Collectors.toCollection(LinkedHashSet::new));
+}
 Set<String> missing = new LinkedHashSet<>();
 for (ResolveIndexAction.ResolvedIndex index : resolved().getIndices()) {
   if (!readable.contains(index.getName())) {
     missing.add(index.getName());
   }
 }
 return missing;
Suggestion importance[1-10]: 6

__

Why: The suggestion addresses a valid concern about probe failures. However, the test shouldNotPruneWhenTheReadabilityProbeFails at line 313 shows this case is already handled by the caller, which falls back to not pruning. Adding explicit error handling here would improve robustness but is not critical since the failure already prevents incorrect pruning.

Low
Suggestions up to commit 4c2d3a6
CategorySuggestion                                                                                                                                    Impact
General
Handle probe failures gracefully

The unfiltered probe with null filter could fail or timeout, but there's no error
handling. If probe(null, timeField) throws an exception, the entire pruning
operation fails. Consider wrapping this in a try-catch to gracefully handle probe
failures and fall back to not pruning.

opensearch/src/main/java/org/opensearch/sql/opensearch/request/IndexPruner.java [218-227]

 Set<String> indicesMissingFromProbe(String timeField) {
-  Set<String> readable = Set.of(probe(null, timeField).getIndices());
-  Set<String> missing = new LinkedHashSet<>();
-  for (ResolveIndexAction.ResolvedIndex index : resolved().getIndices()) {
-    if (!readable.contains(index.getName())) {
-      missing.add(index.getName());
+  try {
+    Set<String> readable = Set.of(probe(null, timeField).getIndices());
+    Set<String> missing = new LinkedHashSet<>();
+    for (ResolveIndexAction.ResolvedIndex index : resolved().getIndices()) {
+      if (!readable.contains(index.getName())) {
+        missing.add(index.getName());
+      }
     }
+    return missing;
+  } catch (Exception e) {
+    log.warn("Readability probe failed, treating all indices as potentially missing", e);
+    return resolved().getIndices().stream()
+        .map(ResolveIndexAction.ResolvedIndex::getName)
+        .collect(Collectors.toCollection(LinkedHashSet::new));
   }
-  return missing;
 }
Suggestion importance[1-10]: 5

__

Why: This is a valid concern about error handling. However, the test at lines 330-336 shows that probe failures are already handled by falling back to not pruning. The suggestion would add defensive error handling at a lower level, which could improve robustness, though the current approach of letting exceptions propagate to trigger the fallback is also reasonable.

Low
Ensure deterministic index ordering

The order of indices in keep may not be deterministic when combining candidates and
unsearchable. Since candidates is an array and unsearchable is a set, their relative
ordering could vary. Consider maintaining a consistent ordering to ensure
predictable query behavior across executions.

opensearch/src/main/java/org/opensearch/sql/opensearch/request/IndexPruner.java [90-96]

 Set<String> keep = new LinkedHashSet<>(Arrays.asList(candidates));
 keep.addAll(unsearchable);
 log.info("Index pruning kept unsearchable indices {}", unsearchable);
 if (indexExpr.isPrunedBy(keep.size())) {
-  return new IndexName(String.join(",", keep));
+  // Sort to ensure deterministic ordering
+  return new IndexName(String.join(",", keep.stream().sorted().toArray(String[]::new)));
 }
 return indexName;
Suggestion importance[1-10]: 3

__

Why: While sorting could provide deterministic ordering, the suggestion doesn't demonstrate that non-deterministic ordering causes any actual problem. The LinkedHashSet already maintains insertion order, and the order of indices in a search request typically doesn't affect correctness.

Low
Suggestions up to commit af1da74
CategorySuggestion                                                                                                                                    Impact
General
Preserve deterministic index ordering

The order of indices in keep may not be deterministic when combining candidates and
unsearchable indices. Since LinkedHashSet preserves insertion order, but
Arrays.asList(candidates) followed by addAll(unsearchable) could produce different
orderings than the original index pattern, this might cause unexpected behavior in
downstream components that depend on index ordering.

opensearch/src/main/java/org/opensearch/sql/opensearch/request/IndexPruner.java [90-96]

 Set<String> keep = new LinkedHashSet<>(Arrays.asList(candidates));
 keep.addAll(unsearchable);
 log.info("Index pruning kept unsearchable indices {}", unsearchable);
 if (indexExpr.isPrunedBy(keep.size())) {
-  return new IndexName(String.join(",", keep));
+  // Preserve original order from candidates array
+  String[] orderedKeep = Arrays.stream(candidates)
+      .filter(keep::contains)
+      .toArray(String[]::new);
+  return new IndexName(String.join(",", orderedKeep) + "," + String.join(",", unsearchable));
 }
 return indexName;
Suggestion importance[1-10]: 3

__

Why: The suggestion raises a valid concern about potential ordering issues when combining candidates and unsearchable indices. However, the proposed solution is flawed: it filters candidates by keep (which already contains all candidates), making the filter redundant, and then appends unsearchable separately, which could duplicate indices if they're already in candidates. The current code using LinkedHashSet actually maintains insertion order correctly (candidates first, then unsearchable). The concern about "unexpected behavior in downstream components" is speculative without evidence that ordering matters here.

Low
Suggestions up to commit 58fc3f9
CategorySuggestion                                                                                                                                    Impact
Possible issue
Fix missing shard count calculation

The calculation does not account for failed shards. A shard that failed is not
successful but should not be counted as missing. The correct formula should subtract
failed as well to avoid double-counting failed shards as both failed and missing.

opensearch/src/main/java/org/opensearch/sql/opensearch/response/ShardStats.java [51-53]

 public int missing() {
-  return Math.max(0, total - successful - skipped);
+  return Math.max(0, total - successful - skipped - failed);
 }
Suggestion importance[1-10]: 10

__

Why: The missing() calculation incorrectly counts failed shards as missing, leading to double-counting. The formula should be total - successful - skipped - failed to accurately represent shards that neither answered nor failed. This is a critical bug affecting the correctness of shard statistics.

High
Handle availability probe failures gracefully

The indicesMissingPrimaries() call may throw an exception if the cluster state probe
fails, but this is not caught. If the probe fails, the method should fall back to
the original indexName to preserve correctness, as shown in the test
shouldNotPruneWhenTheAvailabilityProbeFails.

opensearch/src/main/java/org/opensearch/sql/opensearch/request/IndexPruner.java [85-96]

-Set<String> unsearchable = indexExpr.indicesMissingPrimaries();
+Set<String> unsearchable;
+try {
+  unsearchable = indexExpr.indicesMissingPrimaries();
+} catch (Exception e) {
+  log.warn("Index pruning availability probe failed, keeping all indices", e);
+  return indexName;
+}
 if (unsearchable.isEmpty()) {
   return new IndexName(String.join(",", candidates));
 }
 Set<String> keep = new LinkedHashSet<>(Arrays.asList(candidates));
 keep.addAll(unsearchable);
 log.info("Index pruning kept unsearchable indices {}", unsearchable);
 if (indexExpr.isPrunedBy(keep.size())) {
   return new IndexName(String.join(",", keep));
 }
 return indexName;
Suggestion importance[1-10]: 9

__

Why: The test shouldNotPruneWhenTheAvailabilityProbeFails explicitly verifies that probe failures should fall back to the original indexName, but the production code lacks this error handling. This is a critical correctness issue that could cause queries to fail unexpectedly.

High

@ahkcs ahkcs added the enhancement New feature or request label Sep 24, 2026
@ahkcs
ahkcs force-pushed the fix/ppl-shard-failure-warning branch from 22a208d to 70fb145 Compare September 24, 2026 21:11
@github-actions

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit 70fb145

@github-actions

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit 58fc3f9

@github-actions

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit af1da74

@ahkcs
ahkcs force-pushed the fix/ppl-shard-failure-warning branch from af1da74 to 4c2d3a6 Compare September 24, 2026 22:25
@github-actions

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit 4c2d3a6

@codecov

codecov Bot commented Sep 24, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 89.02439% with 9 lines in your changes missing coverage. Please review.
✅ Project coverage is 63.28%. Comparing base (5953571) to head (9f3945c).
⚠️ Report is 3 commits behind head on main.

Files with missing lines Patch % Lines
...opensearch/sql/opensearch/response/ShardStats.java 87.50% 3 Missing and 4 partials ⚠️
...opensearch/executor/OpenSearchExecutionEngine.java 0.00% 2 Missing ⚠️

❌ Your project check has failed because the head coverage (63.28%) is below the target coverage (99.00%). You can increase the head coverage or adjust the target coverage.

Additional details and impacted files
@@             Coverage Diff              @@
##               main    #5807      +/-   ##
============================================
+ Coverage     63.23%   63.28%   +0.05%     
- Complexity     8823     8852      +29     
============================================
  Files           938      939       +1     
  Lines         40236    40313      +77     
  Branches       4537     4550      +13     
============================================
+ Hits          25445    25514      +69     
- Misses        13966    13970       +4     
- Partials        825      829       +4     
Flag Coverage Δ
sql-engine 63.28% <89.02%> (+0.05%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@ahkcs
ahkcs force-pushed the fix/ppl-shard-failure-warning branch from 4c2d3a6 to d553f91 Compare September 24, 2026 23:04
@github-actions

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit d553f91

@github-actions

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit 38dfa20

OpenSearch answers a search with HTTP 200 as long as one shard responded
(search.default_allow_partial_results defaults to true), so a result can omit
whole shards. OpenSearchResponse read only getHits()/getAggregations() and
discarded the _shards block, so PPL presented the surviving shards' rows as the
whole answer: no error, no warning. During a load spike, a WLM rejection or a
node drop silently undercounts, e.g. a count of 750 where the answer is 990.
DSL clients surface this from _shards; PPL had nowhere to put it.

Capture the shard outcome and attach a PARTIAL_RESULT_SHARD_FAILURE warning
through the existing warnings channel when the search did not cover every shard.

- ShardStats records total/successful/skipped/failed/timed_out plus the distinct
  per-shard failure reasons, and builds the warning so both message and detail
  read identically wherever a scan raises it.
- Detect all three partial shapes, not just failures: a failed shard, a shard
  with no available copy (successful + skipped < total, where failed is 0 -- the
  node-drop shape a failed-only check misses), and a timeout.
- Record from BackgroundSearchScanner on the query thread. The search itself may
  run on the background pool, whose thread the warning sink cannot see.

Scoped to the Calcite path, matching where the warnings channel is wired today;
the V2 fallback and a setting to reject partial results instead of warning are
left for follow-ups.

Signed-off-by: Kai Huang <ahkcs@amazon.com>
The warnings array has been part of the PPL JSON response since opensearch-project#5657 but was
never described in the protocol reference. Document both types, when the field
is present, and which formats carry it.

Signed-off-by: Kai Huang <ahkcs@amazon.com>
Pruning probes each index with field caps and an index filter, keeping the ones
that come back. An index whose shards cannot be read does not come back, and
field caps reports no failure for it -- it is simply absent from the response,
indistinguishable from an index proven to hold nothing in the time range. Pruning
therefore dropped it from the index expression.

That loses data with nothing left to report. The search that runs afterwards
covers every shard it was given, so its shard counts are complete, the
PARTIAL_RESULT_SHARD_FAILURE warning has nothing to raise, and even
search.default_allow_partial_results=false sees a whole search and permits it.
Measured on a two-node cluster over a 4-shard pattern of 990 documents with one
node stopped: stats count() returned 750 with no warning and no error, while the
equivalent DSL search over the same pattern was rejected outright.

Keep any index the probe cannot have ruled out: those absent from a second,
unfiltered probe. A readable index reports its field caps whatever the time
range, so absence there means the index could not be read at all. That holds
regardless of what the cluster state believes, which also covers the window after
a node stops but before the cluster manager marks its shards unassigned --
seconds, and precisely when a dashboard refresh would otherwise lose the index.

Judged by a field caps probe rather than by reading the routing table on purpose:
a cluster state request needs cluster:monitor/state, which an index-scoped role
does not carry, so it would both disable pruning for those users and log a
missing-privileges audit event on every query. The cost is that an index readable
through its other shards while one shard is unassigned still looks prunable; its
in-range documents would have to live only on the missing shard, which takes
custom routing to arrange.

Both probes run only when pruning would actually narrow the expression, so the
healthy path is unchanged. When nothing matched the filter, pruning already
declines, which keeps a partial answer from becoming an all-shards-failed error.

Signed-off-by: Kai Huang <ahkcs@amazon.com>
The integTestWithSecurity suite runs only org.opensearch.sql.security.*, so a
path whose IT lives elsewhere is never exercised with security installed. Two
things about this change can only break there, and both did during review:

- The warning travels the response channel that opensearch-project#5739 showed the security
  transport interceptor can drop on the transport-to-worker handoff.
- Deciding whether an index was readable has to use an API the caller's role
  already allows. Reading the routing table needs cluster:monitor/state, which an
  index-scoped role does not carry, so it disabled pruning for exactly these
  users and logged a missing-privileges audit event on every query.

The fixture holds three indices in one pattern -- one in range, one out of range
carrying a field of its own, and one whose shard can never be allocated -- so a
bounded query must prune the out-of-range index while keeping the unreadable one:
legacy_only stops resolving, and the response still carries the warning. The
unallocatable index is dropped between tests so no other class in the suite
inherits a red cluster.

Signed-off-by: Kai Huang <ahkcs@amazon.com>
@ahkcs
ahkcs force-pushed the fix/ppl-shard-failure-warning branch from 38dfa20 to d0bd7e9 Compare September 25, 2026 01:57
@github-actions

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit d0bd7e9

Three holes from self-review, each a way the warning was still lost:

- A PIT created over only some of its shards pins that subset, so every search
  against it looks complete and the gap is visible only at creation. Report it
  there. Creation itself now allows partial, matching the search path: with it
  forbidden, one unassigned shard turned any cursor or >max_result_window query
  into a 500, which keeping unreadable indices in the expression would have
  widened from unbounded queries to bounded ones.
- buildResultSet drained warnings after the timewrap block, whose finally clears
  the same thread-local, so every timewrap query dropped them. Drain first.
- describeFailures keyed dedup on "[index][shard] reason", so one cause spanning
  four shards printed four times and truncated to three. Key on the reason and
  label it with the first shard that hit it. The test only passed because it
  reused one shard id.

Also: missing() no longer subtracts skipped twice, comments cut to what the code
does not already say, the duplicated bounds helper lifted into SecurityTestBase,
and the @after index drops guarded so a failed init reports its own cause.

Signed-off-by: Kai Huang <ahkcs@amazon.com>
@github-actions

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit 9f3945c

This branch has not been deployed

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

Labels

enhancement New feature or request

Projects

None yet

1 participant