Skip to content

Add an opt-in partial-result mode for aggregations on text/keyword mapping conflicts - #5657

Merged
ahkcs merged 32 commits into
opensearch-project:mainfrom
ahkcs:feature/ppl-partial-result-warning-channel
Sep 1, 2026
Merged

Add an opt-in partial-result mode for aggregations on text/keyword mapping conflicts#5657
ahkcs merged 32 commits into
opensearch-project:mainfrom
ahkcs:feature/ppl-partial-result-warning-channel

Conversation

@ahkcs

@ahkcs ahkcs commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator

Description

On the Calcite PPL path, an aggregation grouped on a field that is mapped keyword in some indices of a wildcard pattern and text in others cannot use native pushdown. The multi-index type merge collapses the field to text-without-.keyword, which has no doc values, so the aggregation runs as a per-document _source script over every document — correct, but a full-index scan that is orders of magnitude slower on a wide pattern.

This PR adds an opt-in mode that returns a fast, partial answer instead: it aggregates over only the subset of indices where the field is natively aggregatable (keyword) and attaches a warning naming the ones it excluded.

So the choice becomes complete-but-slow (default) vs fast-but-partial (opt-in) — both correct, differing in coverage and speed.

How it works

  1. Warning channel. Successful PPL JSON responses gain an optional warnings: [{type, message, detail}] array, emitted only when non-empty (existing responses are byte-for-byte unchanged):
    "warnings": [{
      "type": "PARTIAL_RESULT",
      "message": "Results exclude 1 of 2 indices due to a text/keyword mapping conflict on [applicationid].",
      "detail": "[applicationid] is not mapped as keyword in every queried index, so these indices were excluded from the aggregation: [logs-text]. Map [applicationid] as keyword across all indices to include them."
    }]
  2. Partial-result plan (PartialResultAggregatePushdown). When the mode is on and the group key is a text/keyword conflict, the scan is narrowed to the aggregatable index subset and the aggregation pushed down over just that subset (size = 0, no PIT). The partitioning logic is unit-tested in isolation.
  3. Per-request override. A partial_result boolean in the query body (mirroring profile) overrides the cluster setting for one query; absent → cluster setting decides.

Behavior

Cluster setting plugins.query.partial_result.on_mapping_conflict.enabled (default false):

Query Off (default) On
stats count() by <conflict field> complete result, slow (_source scan of all docs) fast result over the keyword subset + PARTIAL_RESULT warning
no-conflict / single-index aggregation complete, no warning complete, no warning (unchanged)
any of the above with format=csv as above falls through to the complete result (CSV has no warning channel)

Key points

  • Opt-in, default off. A partial result is knowingly incomplete, so it never happens silently; with the setting off the change is behavior-preserving.
  • Never degrades silently. Only the JSON shape carries warnings, so CSV/RAW/VIZ fall through to the complete result rather than dropping data unannounced.
  • Deterministic selection. Keep the keyword group whenever one exists; otherwise the text-with-.keyword group; always exclude bare text. The result never depends on how many indices of each type match.
  • Calcite PPL path only; V2/legacy untouched.

Not in scope: recovering an excluded but aggregatable group (text-with-.keyword alongside a keyword group) — that needs a per-group split-and-union, a larger separate change. This is why the warning recommends mapping the field as keyword everywhere.

Related Issues

Check List

  • New functionality includes testing (unit + integration).
  • New functionality has been documented (docs/user/admin/settings.rst).
  • New functionality has javadoc added.
  • API changes companion pull request created.
  • Commits are signed per the DCO using --signoff or -s.
  • Public documentation issue/PR created.

By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.

@github-actions

github-actions Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

PR Reviewer Guide 🔍

(Review updated until commit d647281)

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 stringOf helper catches all RuntimeException to handle non-string content, but this is too broad. If content.stringValue() throws an unexpected runtime exception (e.g., NullPointerException from a bug in the content implementation), the catch block silently converts it to a string representation of the underlying object, masking the real error. This could hide legitimate bugs in the content-handling code. The catch should be narrowed to the specific exception type that signals "not a string value" (e.g., ClassCastException or a domain-specific exception), or the method should document why swallowing all runtime exceptions is safe here.

private static String stringOf(Content content) {
  try {
    return content.stringValue();
  } catch (RuntimeException e) {
    // Not a string value (e.g. a numeric aggregation bucket key landing in a text column via a
    // partial-result narrowing) -- render its string form instead of failing the cast to null.
    return String.valueOf(content.objectValue());
  }
}
Possible Issue

resolvePartitionFields returns null when any group key is a pure constant (no field references). The caller tryPartialResultAggregate then skips partial-result mode entirely. However, a query like stats count() by 'literal', city has one constant key and one field key (city). The current logic returns null because the constant key contributes no refs, so the entire aggregation is left un-pushed even though city alone could be partitioned on. The method should either skip constant keys and return the non-empty field list, or the caller should handle mixed cases. As written, a single constant in a multi-field group key disables partial mode unnecessarily.

@Nullable
private List<String> resolvePartitionFields(Aggregate aggregate, @Nullable Project project) {
  List<String> scanFields = getRowType().getFieldNames();
  List<String> fields = new ArrayList<>();
  for (int group : aggregate.getGroupSet()) {
    Set<Integer> refs = new LinkedHashSet<>();
    if (project == null) {
      refs.add(group); // group key indexes directly into the scan
    } else {
      project
          .getProjects()
          .get(group)
          .accept(
              new RexVisitorImpl<Void>(true) {
                @Override
                public Void visitInputRef(RexInputRef ref) {
                  refs.add(ref.getIndex());
                  return null;
                }
              });
    }
    if (refs.isEmpty()) {
      return null; // constant group key -> nothing to partition on
    }
    for (int ref : refs) {
      String name = scanFields.get(ref);
      if (!fields.contains(name)) {
        fields.add(name);
      }
    }
  }
  return fields;
}
Possible Issue

resolveBucketSignature returns null if any field in bucketNames is absent from the index mapping. This means an index where one group field is missing (but others are present and aggregatable) is excluded entirely. For a multi-field group key like stats count() by city, region, if an index has city as keyword but no region field at all, that index is excluded even though city alone is aggregatable. The logic conflates "field is non-aggregatable" with "field is absent," but absence is a schema mismatch that might warrant a different treatment (e.g., a separate warning or allowing the index if the present fields are aggregatable). As written, a missing field silently excludes the index without distinguishing the reason.

static String resolveBucketSignature(
    Map<String, OpenSearchDataType> flatMapping, List<String> bucketNames) {
  List<String> tokens = new ArrayList<>();
  for (String field : bucketNames) {
    OpenSearchDataType type = flatMapping.get(field);
    if (type == null) {
      return null; // field absent here -> not aggregatable
    }
    MappingType mappingType = type.getMappingType();
    if (mappingType == MappingType.Text || mappingType == MappingType.MatchOnlyText) {
      return null; // text family (incl. text-with-.keyword) collapses to bare text on merge
    }
    tokens.add("t:" + mappingType); // aggregatable type (keyword, numeric, date, boolean, ip)
  }
  return String.join("|", tokens);
}

@github-actions

github-actions Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

PR Code Suggestions ✨

Latest suggestions up to d647281

Explore these optional code suggestions:

CategorySuggestion                                                                                                                                    Impact
General
Validate kept indices before use

Verify that plan.keptIndices() is not empty before constructing the narrowed index.
An empty kept-indices list would create an invalid index pattern and should be
treated as a planning failure rather than proceeding to construct an index.

opensearch/src/main/java/org/opensearch/sql/opensearch/storage/scan/CalciteLogicalIndexScan.java [511-554]

 private AbstractRelNode tryPartialResultAggregate(
     Aggregate aggregate, @Nullable Project project, List<String> partitionFields) {
   if (!QueryContext.isPartialResultEnabled(osIndex.getSettings())) {
     return null;
   }
   // A format with no warnings channel (CSV/RAW/VIZ) must not silently drop indices.
   if (!QueryContext.isWarningsSupported()) {
     return null;
   }
   try {
     Map<String, IndexMapping> mappings = osIndex.getIndexMappings();
     PartialResultAggregatePushdown.Plan plan =
         PartialResultAggregatePushdown.plan(partitionFields, mappings);
-    if (plan == null) {
+    if (plan == null || plan.keptIndices().isEmpty()) {
       return null;
     }
 
     OpenSearchIndex narrowedIndex =
         new OpenSearchIndex(
             osIndex.getClient(), osIndex.getSettings(), String.join(",", plan.keptIndices()));
-    CalciteLogicalIndexScan narrowedScan =
-        new CalciteLogicalIndexScan(
-            getCluster(),
-            traitSet,
-            hints,
-            table,
-            narrowedIndex,
-            getRowType(),
-            pushDownContext.cloneWithOsIndex(narrowedIndex));
-    // allowPartialFallback=false: the subset is already narrowed, so keep this one-shot.
-    AbstractRelNode pushed = narrowedScan.pushDownAggregate(aggregate, project, false);
-    if (pushed == null) {
-      return null; // narrowed subset still can't push down -> leave un-pushed
-    }
-
-    CalcitePlanContext.addWarning(plan.warning());
-    return pushed;
+    ...
   } catch (Exception e) {
     if (LOG.isDebugEnabled()) {
       LOG.debug("Cannot apply partial-result aggregate pushdown for {}", aggregate, e);
     }
     return null;
   }
 }
Suggestion importance[1-10]: 7

__

Why: The suggestion to verify plan.keptIndices() is not empty before constructing the narrowed index is a valid defensive check. However, the plan() method already returns null when there's no valid partition (including when kept indices would be empty), so this check is somewhat redundant but still adds clarity and safety.

Medium
Narrow exception catch scope

Catching all RuntimeException is too broad and may hide unexpected errors. Consider
catching only the specific exception type that content.stringValue() throws when the
value is not a string, or add logging to track when this fallback path is taken.

opensearch/src/main/java/org/opensearch/sql/opensearch/data/value/OpenSearchExprValueFactory.java [248-256]

 private static String stringOf(Content content) {
   try {
     return content.stringValue();
-  } catch (RuntimeException e) {
+  } catch (ClassCastException e) {
     // Not a string value (e.g. a numeric aggregation bucket key landing in a text column via a
     // partial-result narrowing) -- render its string form instead of failing the cast to null.
     return String.valueOf(content.objectValue());
   }
 }
Suggestion importance[1-10]: 6

__

Why: Catching RuntimeException is broad, but the suggestion to narrow to ClassCastException may not be accurate without knowing the exact exception thrown by content.stringValue(). The suggestion is reasonable for improving error handling specificity, though it assumes the exception type without verification.

Low

Previous suggestions

Suggestions up to commit 97aa640
CategorySuggestion                                                                                                                                    Impact
General
Clear all request context state

The clearRequestScopedState method does not clear the request ID set by
QueryContext.addRequestId(). This could cause request IDs to leak across pooled
threads, leading to incorrect request tracking.

plugin/src/main/java/org/opensearch/sql/plugin/transport/TransportPPLQueryAction.java [452-456]

 private static void clearRequestScopedState() {
   QueryProfiling.clear();
   QueryContext.setPartialResultOverride(null);
   QueryContext.setWarningsSupported(false);
+  QueryContext.clear();
 }
Suggestion importance[1-10]: 8

__

Why: This identifies a potential resource leak. The method clears partial result and warnings state but does not clear the request ID set by QueryContext.addRequestId(). On pooled threads, this could cause request IDs to leak to subsequent queries, leading to incorrect tracking and potential confusion in logs or monitoring.

Medium
Catch specific exception type

Catching RuntimeException is too broad and may hide unexpected errors. Consider
catching a more specific exception type that content.stringValue() throws when the
value is not a string, or add logging to track when this fallback is triggered.

opensearch/src/main/java/org/opensearch/sql/opensearch/data/value/OpenSearchExprValueFactory.java [248-256]

 private static String stringOf(Content content) {
   try {
     return content.stringValue();
-  } catch (RuntimeException e) {
+  } catch (ClassCastException e) {
     // Not a string value (e.g. a numeric aggregation bucket key landing in a text column via a
     // partial-result narrowing) -- render its string form instead of failing the cast to null.
     return String.valueOf(content.objectValue());
   }
 }
Suggestion importance[1-10]: 6

__

Why: Catching RuntimeException is overly broad and could mask unexpected errors. However, the suggestion to catch ClassCastException may not be the correct specific exception type without knowing what content.stringValue() actually throws. The suggestion is reasonable but may need verification of the actual exception type.

Low
Possible issue
Validate kept indices not empty

Verify that plan.keptIndices() is not empty before constructing the narrowed index.
An empty kept-indices list would create an invalid index pattern and could cause
downstream failures.

opensearch/src/main/java/org/opensearch/sql/opensearch/storage/scan/CalciteLogicalIndexScan.java [511-543]

 private AbstractRelNode tryPartialResultAggregate(
     Aggregate aggregate, @Nullable Project project, List<String> partitionFields) {
   if (!QueryContext.isPartialResultEnabled(osIndex.getSettings())) {
     return null;
   }
-  // A format with no warnings channel (CSV/RAW/VIZ) must not silently drop indices.
   if (!QueryContext.isWarningsSupported()) {
     return null;
   }
   try {
     Map<String, IndexMapping> mappings = osIndex.getIndexMappings();
     PartialResultAggregatePushdown.Plan plan =
         PartialResultAggregatePushdown.plan(partitionFields, mappings);
-    if (plan == null) {
+    if (plan == null || plan.keptIndices().isEmpty()) {
       return null;
     }
 
     OpenSearchIndex narrowedIndex =
         new OpenSearchIndex(
             osIndex.getClient(), osIndex.getSettings(), String.join(",", plan.keptIndices()));
     ...
   } catch (Exception e) {
     ...
   }
 }
Suggestion importance[1-10]: 7

__

Why: This is a valid defensive check. While PartialResultAggregatePushdown.plan likely returns null when no indices can be kept, explicitly checking for an empty keptIndices() list prevents potential issues with constructing an invalid index pattern (empty string from String.join).

Medium
Suggestions up to commit 3ffd741
CategorySuggestion                                                                                                                                    Impact
General
Reset profiling flag in cleanup

Ensure that QueryContext.setProfile(false) is also called to reset the profiling
flag, since QueryContext.setProfile(transformedRequest.profile()) is set at the
start of the request. This prevents the profiling state from leaking to subsequent
requests on the same thread.

plugin/src/main/java/org/opensearch/sql/plugin/transport/TransportPPLQueryAction.java [452-456]

 private static void clearRequestScopedState() {
   QueryProfiling.clear();
+  QueryContext.setProfile(false);
   QueryContext.setPartialResultOverride(null);
   QueryContext.setWarningsSupported(false);
 }
Suggestion importance[1-10]: 8

__

Why: This is a valid concern about thread-local state leakage. Since QueryContext.setProfile() is set at the start of the request, it should be reset in cleanup to prevent the profiling state from affecting subsequent requests on pooled threads. This is an important correctness issue for thread safety.

Medium
Avoid catching broad RuntimeException

Catching RuntimeException is too broad and may mask unexpected errors. Catch the
specific exception type that content.stringValue() throws when the value is not a
string, or verify the content type before attempting the cast.

opensearch/src/main/java/org/opensearch/sql/opensearch/data/value/OpenSearchExprValueFactory.java [248-256]

 private static String stringOf(Content content) {
-  try {
+  if (content.isString()) {
     return content.stringValue();
-  } catch (RuntimeException e) {
-    // Not a string value (e.g. a numeric aggregation bucket key landing in a text column via a
-    // partial-result narrowing) -- render its string form instead of failing the cast to null.
-    return String.valueOf(content.objectValue());
   }
+  // Not a string value (e.g. a numeric aggregation bucket key landing in a text column via a
+  // partial-result narrowing) -- render its string form instead of failing the cast to null.
+  return String.valueOf(content.objectValue());
 }
Suggestion importance[1-10]: 7

__

Why: The suggestion correctly identifies that catching RuntimeException is overly broad. Using a type check (content.isString()) before calling stringValue() is a cleaner approach that avoids masking unexpected errors. However, the impact is moderate since the current code does handle the case correctly, just less elegantly.

Medium
Validate mappings before use

Verify that osIndex.getIndexMappings() returns a non-null and non-empty map before
passing it to PartialResultAggregatePushdown.plan(). If the mappings are empty or
null, the method should return early to avoid unnecessary processing.

opensearch/src/main/java/org/opensearch/sql/opensearch/storage/scan/CalciteLogicalIndexScan.java [511-526]

 private AbstractRelNode tryPartialResultAggregate(
     Aggregate aggregate, @Nullable Project project, List<String> partitionFields) {
   if (!QueryContext.isPartialResultEnabled(osIndex.getSettings())) {
     return null;
   }
   // A format with no warnings channel (CSV/RAW/VIZ) must not silently drop indices.
   if (!QueryContext.isWarningsSupported()) {
     return null;
   }
   try {
     Map<String, IndexMapping> mappings = osIndex.getIndexMappings();
+    if (mappings == null || mappings.isEmpty()) {
+      return null;
+    }
     PartialResultAggregatePushdown.Plan plan =
         PartialResultAggregatePushdown.plan(partitionFields, mappings);
     if (plan == null) {
       return null;
     }
Suggestion importance[1-10]: 6

__

Why: Adding a null/empty check for mappings before passing to plan() is defensive programming. However, PartialResultAggregatePushdown.plan() already handles the case where mappings.size() < 2 returns null, so this check is somewhat redundant. The suggestion improves clarity but has limited practical impact.

Low
Suggestions up to commit 77fa72d
CategorySuggestion                                                                                                                                    Impact
Possible issue
Validate non-empty kept indices list

The narrowedIndex is created with a comma-joined list of index names without
validation. If plan.keptIndices() is empty, this will create an index with an empty
string name, which could cause unexpected behavior. Verify that keptIndices() is
non-empty before creating the narrowed index.

opensearch/src/main/java/org/opensearch/sql/opensearch/storage/scan/CalciteLogicalIndexScan.java [520-530]

-private AbstractRelNode tryPartialResultAggregate(
-    Aggregate aggregate, @Nullable Project project, List<String> partitionFields) {
-  if (!QueryContext.isPartialResultEnabled(osIndex.getSettings())) {
-    return null;
-  }
-  // A format with no warnings channel (CSV/RAW/VIZ) must not silently drop indices.
-  if (!QueryContext.isWarningsSupported()) {
-    return null;
-  }
-  try {
-    Map<String, IndexMapping> mappings = osIndex.getIndexMappings();
-    PartialResultAggregatePushdown.Plan plan =
-        PartialResultAggregatePushdown.plan(partitionFields, mappings);
-    if (plan == null) {
-      return null;
-    }
-
-    OpenSearchIndex narrowedIndex =
-        new OpenSearchIndex(
-            osIndex.getClient(), osIndex.getSettings(), String.join(",", plan.keptIndices()));
-    ...
-  }
+if (plan == null) {
+  return null;
+}
+if (plan.keptIndices().isEmpty()) {
+  return null;
 }
 
+OpenSearchIndex narrowedIndex =
+    new OpenSearchIndex(
+        osIndex.getClient(), osIndex.getSettings(), String.join(",", plan.keptIndices()));
+
Suggestion importance[1-10]: 7

__

Why: Valid concern about potential empty keptIndices() list leading to an invalid index name. However, the suggestion asks to verify/ensure a condition rather than fixing a definite bug, so it should not score above 7.

Medium
Add null check for mapping type

The method assumes type.getMappingType() never returns null, but if it does, the
subsequent checks will fail with a NullPointerException. Add a null check for
mappingType before using it in comparisons to prevent potential crashes.

opensearch/src/main/java/org/opensearch/sql/opensearch/storage/scan/PartialResultAggregatePushdown.java [110-125]

-static String resolveBucketSignature(
-    Map<String, OpenSearchDataType> flatMapping, List<String> bucketNames) {
-  List<String> tokens = new ArrayList<>();
-  for (String field : bucketNames) {
-    OpenSearchDataType type = flatMapping.get(field);
-    if (type == null) {
-      return null; // field absent here -> not aggregatable
-    }
-    MappingType mappingType = type.getMappingType();
-    if (mappingType == MappingType.Keyword) {
-      tokens.add("kw");
-    } else if (mappingType == MappingType.Text || mappingType == MappingType.MatchOnlyText) {
-      return null; // text family (incl. text-with-.keyword) collapses to bare text on merge
-    } else {
-      tokens.add("t:" + mappingType); // other aggregatable type (numeric, date, boolean, ip)
-    }
-  }
-  return String.join("|", tokens);
+MappingType mappingType = type.getMappingType();
+if (mappingType == null) {
+  return null; // mapping type unavailable -> not aggregatable
+}
+if (mappingType == MappingType.Keyword) {
+  tokens.add("kw");
+} else if (mappingType == MappingType.Text || mappingType == MappingType.MatchOnlyText) {
+  return null;
+} else {
+  tokens.add("t:" + mappingType);
 }
Suggestion importance[1-10]: 7

__

Why: The suggestion addresses a potential NullPointerException if getMappingType() returns null. This is a reasonable defensive check, though it's unclear if this scenario can actually occur in practice. As error handling, it should not score above 8, and since it's asking to verify a condition, it caps at 7.

Medium
Suggestions up to commit 0db5aa7
CategorySuggestion                                                                                                                                    Impact
General
Pre-flatten mappings to avoid redundant traversals

The traverseAndFlatten call inside the loop is invoked once per index, which can be
expensive for large wildcard patterns. Consider pre-flattening all mappings once
before the loop, or caching the flattened result in IndexMapping itself to avoid
redundant traversals.

opensearch/src/main/java/org/opensearch/sql/opensearch/storage/scan/PartialResultAggregatePushdown.java [71-76]

 static Plan plan(List<String> bucketNames, Map<String, IndexMapping> mappings) {
   if (bucketNames.isEmpty() || mappings.size() < 2) {
     return null;
   }
+  // Pre-flatten all mappings once
+  Map<String, Map<String, OpenSearchDataType>> flatMappings = new LinkedHashMap<>();
+  for (Map.Entry<String, IndexMapping> entry : mappings.entrySet()) {
+    flatMappings.put(entry.getKey(), 
+        OpenSearchDataType.traverseAndFlatten(entry.getValue().getFieldMappings()));
+  }
   ...
-  for (Map.Entry<String, IndexMapping> entry : mappings.entrySet()) {
-    Map<String, OpenSearchDataType> flatMapping =
-        OpenSearchDataType.traverseAndFlatten(entry.getValue().getFieldMappings());
-    switch (resolveBucketMapping(flatMapping, bucketNames)) {
+  for (Map.Entry<String, Map<String, OpenSearchDataType>> entry : flatMappings.entrySet()) {
+    switch (resolveBucketMapping(entry.getValue(), bucketNames)) {
Suggestion importance[1-10]: 5

__

Why: Valid optimization to avoid repeated traverseAndFlatten calls in the loop. However, the impact is moderate since this only runs when partial mode applies (opt-in setting, multi-index conflict), and the flattening cost is typically small compared to the query execution itself.

Low
Guard against stale thread-local state outside request lifecycle

The method reads from ThreadContext without verifying that the context is properly
initialized for the current request. If called outside a request lifecycle (e.g.,
during initialization or in a background thread), it may return stale data from a
previous request on the same pooled thread. Add a guard or document the lifecycle
requirement.

common/src/main/java/org/opensearch/sql/common/utils/QueryContext.java [134-140]

 public static boolean isPartialResultEnabled(Settings settings) {
-  String override = ThreadContext.get(PARTIAL_RESULT_OVERRIDE_KEY);
-  if (override != null) {
-    return Boolean.parseBoolean(override);
+  // Only check override if a request ID is set (indicating active request context)
+  if (ThreadContext.get(REQUEST_ID_KEY) != null) {
+    String override = ThreadContext.get(PARTIAL_RESULT_OVERRIDE_KEY);
+    if (override != null) {
+      return Boolean.parseBoolean(override);
+    }
   }
   return settings.getSettingValue(Settings.Key.PARTIAL_RESULT_ON_MAPPING_CONFLICT);
 }
Suggestion importance[1-10]: 4

__

Why: The concern about stale state is valid, but the PR already addresses cleanup in TransportPPLQueryAction.clearRequestScopedState() (lines 406-408 in TransportPPLQueryAction.java), which clears the override after each request. The suggested guard adds defensive depth but may be unnecessary given the existing cleanup.

Low
Defer partial-result attempt until after normal pushdown fails

The partial-result path is attempted before the normal aggregation analysis, which
means it may fire even when the aggregation would have succeeded normally. Consider
moving this check after the initial AggregateAnalyzer attempt, so partial mode only
activates when a conflict actually prevents pushdown. This avoids unnecessary
partitioning overhead for queries that don't need it.

opensearch/src/main/java/org/opensearch/sql/opensearch/storage/scan/CalciteLogicalIndexScan.java [420-427]

-List<String> partitionFields = resolvePartitionFields(aggregate, project);
-if (partitionFields != null) {
-  AbstractRelNode partial = tryPartialResultAggregate(aggregate, project, partitionFields);
-  if (partial != null) {
-    return partial;
+try {
+  // ... existing analyze logic ...
+  AggregateAnalyzer.AggregateBuilderHelper helper = ...;
+  // If analysis succeeds, return the pushed aggregate
+} catch (Exception e) {
+  // Only try partial mode if normal pushdown failed
+  List<String> partitionFields = resolvePartitionFields(aggregate, project);
+  if (partitionFields != null && allowPartialFallback) {
+    AbstractRelNode partial = tryPartialResultAggregate(aggregate, project, partitionFields);
+    if (partial != null) {
+      return partial;
+    }
   }
+  throw e;
 }
Suggestion importance[1-10]: 3

__

Why: The suggestion misunderstands the PR's design. The comment at line 418-419 explicitly states partial mode must be tried before analyze because since #5646 a text/keyword conflict pushes down as a slow script instead of failing, so a post-failure fallback would never fire. Moving the check after analysis would break the feature.

Low
Suggestions up to commit cafac5f
CategorySuggestion                                                                                                                                    Impact
Possible issue
Clear all thread-local state consistently

The clearRequestScopedState method clears thread-local state but does not clear the
CalcitePlanContext warnings that are also thread-local. This could cause warnings
from one request to leak into the next request on a pooled thread. Ensure all
thread-local state is cleared consistently.

plugin/src/main/java/org/opensearch/sql/plugin/transport/TransportPPLQueryAction.java [404-408]

 private static void clearRequestScopedState() {
   QueryProfiling.clear();
   QueryContext.setPartialResultOverride(null);
   QueryContext.setWarningsSupported(false);
+  CalcitePlanContext.drainWarnings();
 }
Suggestion importance[1-10]: 8

__

Why: Valid concern: CalcitePlanContext.drainWarnings() is called in OpenSearchExecutionEngine.buildResultSet (line 495), but if an exception occurs before that point, warnings could leak onto the next pooled thread. Adding drainWarnings() to clearRequestScopedState ensures warnings are always cleared, preventing potential cross-request contamination.

Medium
General
Cache flattened mappings to avoid redundant operations

The traverseAndFlatten operation is called for every index mapping in the loop,
which could be expensive for large wildcard patterns with many indices. Consider
caching the flattened mappings or performing this operation once per unique mapping
structure to improve performance.

opensearch/src/main/java/org/opensearch/sql/opensearch/storage/scan/PartialResultAggregatePushdown.java [68-74]

 static Plan plan(List<String> bucketNames, Map<String, IndexMapping> mappings) {
   if (bucketNames.isEmpty() || mappings.size() < 2) {
     return null;
   }
-  ...
+  
+  Map<String, Map<String, OpenSearchDataType>> flattenedMappings = new LinkedHashMap<>();
   for (Map.Entry<String, IndexMapping> entry : mappings.entrySet()) {
-    Map<String, OpenSearchDataType> flatMapping =
-        OpenSearchDataType.traverseAndFlatten(entry.getValue().getFieldMappings());
-    String signature = resolveBucketSignature(flatMapping, bucketNames);
+    flattenedMappings.put(
+        entry.getKey(),
+        OpenSearchDataType.traverseAndFlatten(entry.getValue().getFieldMappings()));
+  }
+  
+  Map<String, List<String>> aggregatableGroups = new LinkedHashMap<>();
+  List<String> nonAggregatable = new ArrayList<>();
+  for (Map.Entry<String, Map<String, OpenSearchDataType>> entry : flattenedMappings.entrySet()) {
+    String signature = resolveBucketSignature(entry.getValue(), bucketNames);
Suggestion importance[1-10]: 4

__

Why: The suggestion correctly identifies that traverseAndFlatten is called in a loop, but the optimization is minor: the method is already efficient for typical wildcard patterns, and the added complexity of caching may not justify the marginal performance gain. The existing code is clear and correct.

Low
Move partial-result attempt after normal pushdown

The partial-result fallback is attempted before the main aggregation analysis, which
means it may fire even when the aggregation would have succeeded normally. Consider
moving the partial-result attempt into the catch block or after detecting a specific
failure condition to avoid unnecessary overhead when the aggregation can be pushed
down cleanly.

opensearch/src/main/java/org/opensearch/sql/opensearch/storage/scan/CalciteLogicalIndexScan.java [390-428]

 private AbstractRelNode pushDownAggregate(
     Aggregate aggregate, @Nullable Project project, boolean allowPartialFallback) {
   try {
     CalciteLogicalIndexScan newScan =
         new CalciteLogicalIndexScan(
             getCluster(),
             ...
+    // Attempt normal pushdown first
+    AggregateAnalyzer.AggregateBuilderHelper helper = ...
+    // Only try partial fallback if normal pushdown fails
+  } catch (Exception e) {
     if (allowPartialFallback) {
       List<String> partitionFields = resolvePartitionFields(aggregate, project);
       if (partitionFields != null) {
         AbstractRelNode partial = tryPartialResultAggregate(aggregate, project, partitionFields);
         if (partial != null) {
           return partial;
         }
       }
     }
+    if (LOG.isDebugEnabled()) {
+      LOG.debug("Cannot pushdown the aggregate {}", aggregate, e);
+    }
+  }
+  return null;
+}
Suggestion importance[1-10]: 3

__

Why: The suggestion misunderstands the design: the comment at line 418 explicitly states partial mode must be tried before analyze because since #5646 a text/keyword conflict pushes down as a slow script instead of failing. Moving it to the catch block would defeat the purpose, as the fallback would never fire. The current placement is intentional.

Low

@github-actions

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit dad3bb3

@ahkcs ahkcs added the enhancement New feature or request label Jul 27, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit 078c949

@github-actions

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit 83fd527

@github-actions

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit 2a3eab8

@github-actions

github-actions Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

PR Code Analyzer ❗

AI-powered 'Code-Diff-Analyzer' found issues on commit d647281.

PathLineSeverityDescription
integ-test/src/test/resources/expectedOutput/calcite/explain_partial_filter_script_push.json4lowA segment of the long base64-encoded serialized Java object string changed ('cJjvMoC+gU1' → 'qHvcFFLzXT') in the expected physical-plan fixture. The same substitution appears in the YAML fixture as well, consistent with a serialVersionUID shift caused by the new cloneDeep() method on OpenSearchDataType. The blobs are opaque without decoding; the change is plausibly explained by the class modification but cannot be fully verified from diff context alone.
opensearch/src/main/java/org/opensearch/sql/opensearch/data/value/OpenSearchExprValueFactory.java248lowThe new stringOf() helper silently catches all RuntimeException and falls back to String.valueOf(content.objectValue()), broadly suppressing unexpected type conversion failures. This is a defensive coding pattern rather than a deliberate threat, but the wide catch could mask unintended data coercions in edge cases not covered by partial-result narrowing.

The table above displays the top 10 most important findings.

Total: 2 | Critical: 0 | High: 0 | Medium: 0 | Low: 2


Pull Requests Author(s): Please update your Pull Request according to the report above.

Repository Maintainer(s): You can bypass diff analyzer by adding label skip-diff-analyzer after reviewing the changes carefully, then re-run failed actions. To re-enable the analyzer, remove the label, then re-run all actions.


⚠️ Note: The Code-Diff-Analyzer helps protect against potentially harmful code patterns. Please ensure you have thoroughly reviewed the changes beforehand.

Thanks.

@github-actions

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit 19b6187

@github-actions

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit 51220fe

@anasalkouz

anasalkouz commented Jul 28, 2026

Copy link
Copy Markdown
Member
  1. Is this only applicable for non-mustang?
  2. Is this only limited to text vs keyward use-case? can we extend the scope?
  3. Shall we have a role on the inspect query feature to suggest customer to enable this parital result flag to optimize performance if the query fails to push down?
  4. Can we have performance benchmark for the 3 cases? with no pushdown, with text pushdown, and with partial results?

@ahkcs

ahkcs commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator Author

Performance benchmark: partial results vs. today vs. scripted text pushdown (#5646)

Comparing three responses to the mapping-conflict PIT-exhaustion case (an aggregation groups on a field mapped keyword in some indices of a wildcard pattern and text in others):

  • A — today. The type merge collapses the field to text-without-doc-values, aggregate pushdown is lost, and the engine scans every document per shard — opening a Point-In-Time (PIT) context on every shard — and aggregates client-side. Complete answer; trips search.max_open_pit_context on wide patterns.
  • B — scripted text pushdown (Push down aggregation on text field without .keyword sub-field #5646). Routes the group key through a Calcite _source script pushed down with size=0. No PIT. Complete answer.
  • C — partial results (this PR). Narrows the scan to the aggregatable (keyword) subset, pushes down natively (size=0, no PIT), returns a partial answer plus a PARTIAL_RESULT warning naming the excluded indices.

These do not compute the same thing, so every latency figure is paired with a completeness column.

Test setup

Cluster Single node, OpenSearch 3.8.0-SNAPSHOT, 2 GB heap (raised from the 512 MB dev default so A fails on PIT, not the query memory circuit breaker)
Engine Calcite path (plugins.calcite.enabled=true) — the only path in scope for this PR
A & C Same build (this PR); A = partial_result:false, C = partial_result:true
B #5646 build, separate run on identical seeded data
Iterations 30 measured + 5 warmup per query, serial (clean per-query latency + exact PIT deltas)
Latency Client-side wall-clock of the _plugins/_ppl call
PIT/query Delta of the cumulative point_in_time_total node stat

Datasets (deterministic, seed = 42):

  • wide — 40 keyword + 4 bare-text indices, 2 shards each (88 shards), 5,000 docs/index (220,000 total). A wide wildcard pattern where 88 shards exceeds any realistic PIT limit.
  • small — 1 keyword + 1 text, 1 shard each, 20,000 docs/index. Control below the PIT limit.
  • flat — same as small but the conflict field is top-level (appid), not nested. (See the note on B.)

Conflict field for wide/small is a nested resource.attributes.applicationid; for flat it is top-level appid.

Latency p50 / p90 / p99 (ms)

"Today" (A) has two modes on the same query, decided by whether the shard count exceeds search.max_open_pit_context:

Query A: PIT opened, under limit (no 500) A: PIT limit exceeded B: #5646 (script) C: partial (this PR) Completeness of C PIT/query (A)
wide stats 441 / 463 / 494 FAIL — 500 112 / 150 / 271 11 / 12 / 13 91.2% (200k/219k) 88
wide top 439 / 455 / 486 FAIL — 500 123 / 149 / 293 16 / 18 / 21 91.2% 88
small stats 90 / 110 / 115 92 / 111 / 114 32 / 36 / 43 5 / 6 / 7 50% (20k/40k) 2
small top 96 / 116 / 122 94 / 102 / 113 37 / 44 / 53 11 / 14 / 15 50% 2
flat stats 58 / 78 / 82 56 / 63 / 75 20 / 23 / 42 4 / 5 / 5 50% (20k/40k) 4
flat top 60 / 67 / 86 57 / 62 / 80 27 / 30 / 31 10 / 12 / 14 50% 4

The "under limit" column used max_open_pit_context=500; "exceeded" used =10. A only fails where shard count crosses the limit (wide, 88 shards). Small/flat stay under and complete — but still open PITs and run 8–20× slower than C. Error rate in the exceeded regime: A = 100% on wide, C = 0% everywhere (never opens a PIT).

Completeness (sum of count() across all buckets)

Dataset A (complete) B C (partial) C completeness
wide, nested field 219,328 220,000 but 1 null bucket (grouping lost) 200,000 91.2%
small, nested field 40,000 40,000 but 1 null bucket 20,000 50%
flat field 40,000 40,000, 50 buckets (correct) 20,000 50%

Why the latencies differ (mechanism)

A leaves the aggregate above the scan (explain shows requestedTotalSize=2147483647): every matching document is streamed out of every shard over PIT cursors into the coordinator JVM and counted there — cost scales with document count. B and C fuse the aggregate into the scan (size=0), so the count runs inside each shard and only bucket results cross the wire — cost scales with bucket count, and no PIT is opened. B groups on a per-document _source script; C groups on native keyword doc values, which is why C stays ~2–5× ahead of B even where both push down.

Takeaways

  1. When it runs, C is fastest (~34× vs A, ~10× vs B on wide) — but that speed is the partial answer: it excludes the non-aggregatable indices. On wide that is an 8.8% undercount; where the text indices hold half the data, 50%. Always accompanied by the PARTIAL_RESULT warning.
  2. In the low-PIT-budget regime, A fails outright (100% errors on wide). B and C never open a PIT.
  3. B is complete and PIT-free on flat fields and is the natural default there. On the nested dotted field, B in its current state grouped all documents into a single null bucket (complete count, grouping lost) — worth verifying whether the scripted _source reader resolves nested dotted paths. This PR's producer resolves the nested path.

C is intended as an opt-in escape hatch (default off) for the widest patterns / lowest PIT budgets where a knowingly-partial, clearly-warned answer is preferable to a slow scan or a 500 — complementary to, not competing with, a complete-answer pushdown fix.

Single-node, laptop-scale absolutes; the ratios and the PIT / completeness / error-rate columns are the transferable results.

@ahkcs

ahkcs commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator Author
  1. Is this only applicable for non-mustang?
  2. Is this only limited to text vs keyward use-case? can we extend the scope?
  3. Shall we have a role on the inspect query feature to suggest customer to enable this parital result flag to optimize performance if the query fails to push down?
  4. Can we have performance benchmark for the 3 cases? with no pushdown, with text pushdown, and with partial results?
  1. Yes, currently it's only applicable for Calcite path.
  2. Today it's deliberately scoped to the text/keyword conflict, it can be extended, and the shape generalizes cleanly if we have more partial result use cases.
  3. We can add that recommendation/suggestion
  4. link for performance benchmarking: Add an opt-in partial-result mode for aggregations on text/keyword mapping conflicts #5657 (comment)

ahkcs added 21 commits September 1, 2026 10:24
The warnings-supported check called format() on every request, including
explain requests whose format is an explain-only value (json/yaml) that
Format.of() does not recognize -- so an _explain request failed with
'response in json format is not supported' before reaching the explain branch.
Skip the check for explain requests, which never carry query warnings anyway.

Fixes the doctest failures on docs/user/ppl/interfaces/endpoint.md.

Signed-off-by: Kai Huang <ahkcs@amazon.com>
…erage

The protocol module requires 100% branch coverage. QueryResult's warnings
constructor normalizes null to an empty list, but no test exercised the null
branch, dropping protocol branch coverage to 0.9 and failing
jacocoTestCoverageVerification. Add a QueryResultTest case covering the
no-warnings, provided-list, and null-list paths.

Signed-off-by: Kai Huang <ahkcs@amazon.com>
…ck into pushDownAggregate

The setting is user-facing behavior, not a Calcite internal, so move it from
plugins.calcite.* to plugins.query.partial_result.on_mapping_conflict.enabled
and drop the CALCITE_ prefix from the key.

Fold tryPartialResultAggregate into pushDownAggregate so the planner rule keeps
a single entry point. The fallback is now private and gated by an
allowPartialFallback flag, so re-entering on the narrowed scan attempts it at
most once.

Signed-off-by: Kai Huang <ahkcs@amazon.com>
The partial-result path needs per-index mappings to decide which indices are
aggregatable, but the merged field types cached on OpenSearchIndex discard that
detail, so it was re-requesting the mappings from the client.

Retain the per-index mappings on the describe request that already fetches them
and cache them alongside the merged types, so partitioning reuses that result
instead of issuing a second mapping request. Also collapses three copies of the
fetch-and-cache block into one helper.

Signed-off-by: Kai Huang <ahkcs@amazon.com>
The shorter plugins.query.* key fits on one line, so the wrapped form no longer
matches google-java-format.

Signed-off-by: Kai Huang <ahkcs@amazon.com>
…ion bug

The optimization had partial-result partitioning reuse the per-index mappings
cached on OpenSearchIndex. But getFieldTypes() merges those mappings with
MergeRuleHelper, and DeepMergeRule.mergeInto mutates the target's nested
'properties' map in place -- and that target aliases the first-iterated index's
OpenSearchDataType objects. Reusing the cached mappings therefore handed the
partitioner a mapping whose nested field had been merged into the sibling
index's type, so a text/keyword conflict on a nested field intermittently
classified as no-conflict, returned no partitioning plan, and fell through to
the PIT-exhausting scan. The outcome depended on map iteration order, hence the
flaky CalcitePartialResultOnMappingConflictIT.partialResultOnHandlesNestedDottedField.

Restore the direct getIndexMappings() fetch, which returns freshly-parsed
mappings immune to that mutation. This only runs on the opt-in partial path
after normal pushdown has already failed (a cold path), so the extra fetch is
acceptable. The underlying in-place-merge mutation is a separate latent issue.

Stress-verified: reverted code passes the full IT class 8/8; the optimized code
failed 4/5.

Signed-off-by: Kai Huang <ahkcs@amazon.com>
… them

Partial-result partitioning needs per-index mappings, which the merged field
types cached on OpenSearchIndex discard, so it was fetching them a second time.

Retain the per-index mappings on the describe request that already fetches them
and cache them alongside the merged types, so partitioning reuses that result.

The first attempt at this was reverted because MergeRuleHelper rewrites the
accumulated type's nested properties in place, mutating the very mappings being
retained: a nested text/keyword conflict then read back as no conflict, produced
no partitioning plan, and fell through to the PIT-exhausting scan. Merge deep
copies instead, via a new OpenSearchDataType.cloneDeep() that carries the nested
properties subtree (cloneEmpty drops it).

Covered by a regression test that fails without the copy. Stress-verified:
CalcitePartialResultOnMappingConflictIT passes 8/8 (it failed 4/5 before).

Signed-off-by: Kai Huang <ahkcs@amazon.com>
The partial-result override and the warnings-supported flag live in
QueryContext's log4j thread-locals, but only QueryProfiling was being cleared
when a request finished. Transport threads are pooled, so a query that expressed
no preference inherited the previous query's override from the same thread: with
the cluster setting off and no request flag, an aggregation over a text/keyword
conflict intermittently returned a partial result (with a warning) instead of
failing -- observed 7 of 12 runs after an earlier request had set the flag.

Clear both flags alongside QueryProfiling in the response listener. Verified:
flag-absent requests now fail 12/12 when interleaved with explicit true
requests, while explicit true still returns the partial result.

Signed-off-by: Kai Huang <ahkcs@amazon.com>
…VersionUID

OpenSearchDataType is Serializable without an explicit serialVersionUID, so the
JVM derives one from the class shape. Adding cloneDeep() changed it, and that
UID is embedded in the Java-serialized script blobs these two explain plans
assert on.

Both files now carry the same derived UID (7128bdc1452f35d3). The ppl/ one is
confirmed by ExplainIT passing; the calcite/ one is skipped in this environment
(enabledOnlyWhenPushdownIsEnabled) and verified by decoding both blobs and
comparing the UID bytes.

Signed-off-by: Kai Huang <ahkcs@amazon.com>
The partial-result path hooked the failure branch of pushDownAggregate: a group
key that collapsed to text-without-keyword used to throw (getReferenceForTermQuery
returned null and the composite builder rejected it), and the fallback caught
that. opensearch-project#5646 made that case succeed instead -- it pushes down as a per-document
_source script -- so the fallback lost its trigger and the setting became a no-op.
Verified by cherry-picking opensearch-project#5646 onto this branch: 7 of 10 ITs failed, the
partial-result ones because pushdown now succeeds and no warning is emitted.

Consult the partial-result plan before AggregateAnalyzer.analyze instead. The
choice is no longer failure-vs-fallback but between two working plans: a native
aggregation over the keyword subset (fast, incomplete, warned) and opensearch-project#5646's script
over every document (slow, complete). Only an up-front check can pick the fast
one. The post-failure call is kept so a key that genuinely cannot push down (e.g.
an array bucket) still gets the chance.

Two ITs asserted the old failure mode (PIT exhaustion raising a 4xx). That
failure no longer happens, which is the point of opensearch-project#5646, so they now assert the
behavior that matters: partial-result off returns the complete result with no
warning, and CSV -- which has no warnings channel -- still returns every index
rather than silently dropping one.

Signed-off-by: Kai Huang <ahkcs@amazon.com>
Add a settings.rst entry for plugins.query.partial_result.on_mapping_conflict.enabled:
what a text/keyword mapping conflict is, the complete-but-slow default vs the
fast-but-partial opt-in, the PARTIAL_RESULT warning, the JSON-only constraint, and
the per-request partial_result override.

Signed-off-by: Kai Huang <ahkcs@amazon.com>
Signed-off-by: Kai Huang <ahkcs@amazon.com>
- settings.rst: mark the setting [Experimental] with a note, and correct the
  version to 3.9.
- Consolidate the per-request-override + cluster-setting precedence into
  QueryContext.isPartialResultEnabled(Settings); drop the duplicate resolver in
  CalciteLogicalIndexScan and the getPartialResultOverride accessor.
- Remove a redundant inline comment.

Signed-off-by: Kai Huang <ahkcs@amazon.com>
The partial-result check runs before analyze (line ~418); the two post-failure
call sites could never add a case. The catch-path call re-invoked with identical
inputs the pre-analyze check already tried, so it always returned null. The
array/nested branch is issue opensearch-project#5006's scope, not a text/keyword conflict, so
partial mode does not apply. Both revert to returning null, and the now-unused
two-arg overload is removed.

Signed-off-by: Kai Huang <ahkcs@amazon.com>
Signed-off-by: Kai Huang <ahkcs@amazon.com>
A group field mapped keyword in some indices and a non-text type (e.g. int) in
others is a type conflict, not a text/keyword collapse. The int index is
aggregatable, so excluding it would silently drop valid data and mislabel it a
text/keyword conflict. Classify such a field as CONFLICTING_TYPE and return no
plan, leaving the query to the normal path (the type conflict itself is out of
scope here). Bare text and absent fields are still excludable as before.

Signed-off-by: Kai Huang <ahkcs@amazon.com>
Resolve each aggregation group key through the eval Project to the scan
fields it reads, so an expression key (e.g. eval g = lower(city) | stats
count() by g) gets partial results over the keyword subset just like a
bare 'by city'. Previously only a bare group field matched the per-index
mapping; a derived key looked up its output alias, found nothing, and
bailed to the complete (script) path. A constant group key resolves to no
field and cleanly bails.

Signed-off-by: Kai Huang <ahkcs@amazon.com>
Covers concat(city, region) over a text/keyword conflict: the key traces to
both fields, keeps only the index where both are aggregatable, and warns
naming both fields and the excluded index. Closes the end-to-end gap on
multi-field expression keys.

Signed-off-by: Kai Huang <ahkcs@amazon.com>
Generalize partitioning from a text/keyword-only enum to a per-index
compatibility signature. This also covers a single aggregatable non-text
type mixed with bare text (e.g. integer vs text): keep the aggregatable
index, exclude the text one, and warn -- rather than silently coercing to
one type and dropping the other index's docs.

A conflict between mutually-incompatible aggregatable types (keyword vs
integer, two numeric types) is left to the normal path: its merged type is
an arbitrary last-write-wins, so narrowing to any one subset could misread
the other's values under that type. That is a fundamental type conflict
tracked separately (opensearch-project#5610).

Signed-off-by: Kai Huang <ahkcs@amazon.com>
Live testing showed the non-text generalization is unsafe. The narrowed scan
reuses the conflict's merged output type, which for a non-text conflict is an
arbitrary last-write-wins. When int-vs-text merged to text, keeping the int
index produced a native numeric aggregation whose integer bucket keys did not
materialize under the text output column -- the group labels came back null
([[2, null], [1, null]]). And when the merge instead picks text, the normal
path already returns the complete result, so narrowing only loses data.

Only the text/keyword collapse narrows safely: its merged type is a
deterministic text, and a kept keyword / text-with-.keyword group's string
bucket keys match it. Reverting to that scope. keyword-vs-int and other
mutually-incompatible aggregatable-type conflicts remain on the normal path
(a fundamental type conflict, opensearch-project#5610). Expression-key tracing (#cbf50748) is
unaffected and retained.

This reverts commit cafac5f.

Signed-off-by: Kai Huang <ahkcs@amazon.com>
…nflict

Partition by aggregatability, not just text/keyword: an index whose group
field is non-aggregatable is dropped and the aggregatable indices are kept.
Non-aggregatable = the text family (text, text-with-.keyword, match_only_text
-- all collapse to bare text on merge) plus absent fields. Aggregatable =
keyword, numerics, date, boolean, ip. So e.g. integer-vs-text now keeps the
integer index and excludes the text one, warning about the exclusion, instead
of silently coercing to one type and dropping the other index's docs.

Kept indices must share one aggregatable type; a mix of incompatible
aggregatable types (keyword vs integer, two numeric types) has an arbitrary
last-write-wins merged type and is left to the normal path (opensearch-project#5610).

Also coerce a numeric/boolean aggregation bucket key to its string form when
it lands in a text-typed output column (OpenSearchExprValueFactory), rather
than failing the cast and nulling the label -- which happens when the kept
non-keyword index's native buckets flow through the conflict's text-merged
output type.

Signed-off-by: Kai Huang <ahkcs@amazon.com>
@ahkcs
ahkcs force-pushed the feature/ppl-partial-result-warning-channel branch from 3ffd741 to 97aa640 Compare September 1, 2026 17:25
@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit 97aa640

…ng the warning

- resolveBucketSignature: keyword uses the same t:TYPE token as other
  aggregatable types (no separate 'kw').
- plan(): stop sorting excludedIndices; sort a copy inside buildWarning,
  since ordering only matters for a readable message.

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

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit d647281

@ahkcs
ahkcs merged commit 18c39e3 into opensearch-project:main Sep 1, 2026
40 checks passed
@ahkcs
ahkcs deleted the feature/ppl-partial-result-warning-channel branch September 1, 2026 21:23
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

Development

Successfully merging this pull request may close these issues.

4 participants