Skip to content

Push down aggregation on text field without .keyword sub-field#5646

Open
penghuo wants to merge 6 commits into
opensearch-project:mainfrom
penghuo:bugFix/5631
Open

Push down aggregation on text field without .keyword sub-field#5646
penghuo wants to merge 6 commits into
opensearch-project:mainfrom
penghuo:bugFix/5631

Conversation

@penghuo

@penghuo penghuo commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator

Description

When a text field has no .keyword sub-field, PPL queries that group by or aggregate over that field silently fall back to a full _source scan with client-side aggregation, even when Calcite pushdown is enabled.

Fix. When getReferenceForTermQuery() returns null for a bare RexInputRef, route through ScriptQueryExpression(rexNode, ...) to build a Calcite script that reads the field from _source. This reuses the exact SOURCE path already established by TermQuery/LikeQuery/RexStandardizer.visitInputRef and consumed at runtime by CalciteScriptEngine.ScriptDataContext.getFromSourceSourceLookup.get(...). The change is in AggregateAnalyzer.AggregateBuilderHelper.build — one branch added.

Related Issues

Resolves #5634

Check List

  • New functionality includes testing.
    • Unit tests in AggregateAnalyzerTest: analyze_aggCall_TextWithoutKeyword_countPushesDownAsScript, analyze_groupBy_TextWithoutKeyword (asserts scripted value_count / terms DSL and SOURCES/DIGESTS script params).
    • YAML rest tests in text_agg_pushdown.yml (10 cases) covering top, stats … by, count(field) — including result correctness and pushed-down DSL assertions — plus baselines for text+keyword and multi-index queries.
  • New functionality has been documented.
  • New functionality has javadoc added.
  • New functionality has a user manual doc added.
  • New PPL command checklist all confirmed.
  • 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.
For more information on following Developer Certificate of Origin and signing off your commits, please check here.

For a text-typed group key or metric argument with no .keyword sub-field,
NamedFieldExpression.getReferenceForTermQuery() returned null and
CompositeValuesSourceBuilder/ValueCountAggregationBuilder rejected the null
field, so pushDownAggregate silently fell back to a full _source scan and
client-side aggregation.

Route those bare RexInputRefs through a Calcite script that reads the value
from _source, matching TermQuery/LikeQuery/RexStandardizer for filter and
script fields. Composite terms buckets and metric aggregations that accept
a script (notably count(FIELD)) now push down.

Signed-off-by: Peng Huo <penghuo@amazon.com>
Signed-off-by: Peng Huo <penghuo@gmail.com>
@github-actions

github-actions Bot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

PR Reviewer Guide 🔍

(Review updated until commit 99d01c3)

Here are some key observations to aid the review process:

🧪 PR contains tests
🔒 No security concerns identified
✅ No TODO sections
🔀 No multiple PR themes
⚡ No major issues detected

@github-actions

github-actions Bot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

PR Code Suggestions ✨

Latest suggestions up to 99d01c3

Explore these optional code suggestions:

CategorySuggestion                                                                                                                                    Impact
General
Document RexInputRef script handling

Adding RexInputRef to the script inference path is a significant behavioral change.
Ensure that ScriptQueryExpression correctly handles RexInputRef nodes, as they
represent direct field references and may require special handling compared to
expressions (RexCall) or literals (RexLiteral).

opensearch/src/main/java/org/opensearch/sql/opensearch/request/AggregateAnalyzer.java [184-187]

 ScriptQueryExpression inferScript(RexNode node) {
   if (node instanceof RexCall || node instanceof RexLiteral || node instanceof RexInputRef) {
+    // RexInputRef added to support text fields without .keyword via _source script
     return new ScriptQueryExpression(
         node, rowType, fieldTypes, cluster, Collections.emptyMap());
   }
Suggestion importance[1-10]: 3

__

Why: Adding a comment to document why RexInputRef was added to the script inference path would improve code maintainability. However, this is a minor documentation improvement that doesn't affect functionality.

Low

Previous suggestions

Suggestions up to commit c409ddf
CategorySuggestion                                                                                                                                    Impact
General
Handle empty string from field reference

The null check on fieldRef assumes getReferenceForTermQuery() returns null for text
fields without a keyword sub-field. However, if this method throws an exception or
returns an empty string instead, the fallback logic will fail. Add explicit
validation or exception handling to ensure robustness.

opensearch/src/main/java/org/opensearch/sql/opensearch/request/AggregateAnalyzer.java [161-167]

 String fieldRef = inferNamedField(node).getReferenceForTermQuery();
 // Text field with no .keyword sub-field is not aggregatable directly. Fall back to a
 // Calcite script that reads the value from _source.
-if (fieldRef == null) {
+if (fieldRef == null || fieldRef.isEmpty()) {
   return scriptBuilder.apply(inferScript(node).getScript());
 }
 return fieldBuilder.apply(fieldRef);
Suggestion importance[1-10]: 5

__

Why: The suggestion to check for empty strings in addition to null is a defensive programming practice that could prevent edge cases. However, the PR context shows this is specifically handling text fields without .keyword sub-fields where getReferenceForTermQuery() returns null. Without evidence that empty strings are returned in practice, this is a minor defensive improvement rather than a critical fix.

Low
Suggestions up to commit 91204e9
CategorySuggestion                                                                                                                                    Impact
General
Handle empty string from field reference

The null check on fieldRef assumes getReferenceForTermQuery() returns null for text
fields without a keyword sub-field. However, if this method throws an exception or
returns an empty string instead, the fallback logic will not trigger correctly. Add
explicit validation or document the expected behavior of getReferenceForTermQuery()
to ensure robustness.

opensearch/src/main/java/org/opensearch/sql/opensearch/request/AggregateAnalyzer.java [161-167]

 String fieldRef = inferNamedField(node).getReferenceForTermQuery();
 // Text field with no .keyword sub-field is not aggregatable directly. Fall back to a
 // Calcite script that reads the value from _source.
-if (fieldRef == null) {
+if (fieldRef == null || fieldRef.isEmpty()) {
   return scriptBuilder.apply(inferScript(node).getScript());
 }
 return fieldBuilder.apply(fieldRef);
Suggestion importance[1-10]: 5

__

Why: The suggestion to check for empty strings in addition to null is a defensive programming practice that could prevent potential issues. However, without evidence that getReferenceForTermQuery() can return empty strings, this is a minor improvement. The PR's implementation already handles the null case correctly for the stated purpose of handling text fields without .keyword sub-fields.

Low
Suggestions up to commit 70c263d
CategorySuggestion                                                                                                                                    Impact
General
Add logging for script fallback

The null check for fieldRef may silently mask unexpected null values from fields
that should be aggregatable. Consider logging a debug or trace message when falling
back to script-based aggregation to aid troubleshooting and ensure the fallback is
intentional for text-only fields.

opensearch/src/main/java/org/opensearch/sql/opensearch/request/AggregateAnalyzer.java [161-167]

 String fieldRef = inferNamedField(node).getReferenceForTermQuery();
 // Text field with no .keyword sub-field is not aggregatable directly. Fall back to a
 // Calcite script that reads the value from _source.
 if (fieldRef == null) {
+  log.debug("Field reference is null for node {}, falling back to script-based aggregation", node);
   return scriptBuilder.apply(inferScript(node).getScript());
 }
 return fieldBuilder.apply(fieldRef);
Suggestion importance[1-10]: 4

__

Why: Adding debug logging when falling back to script-based aggregation could help with troubleshooting, but this is a minor enhancement. The null check is intentional for text fields without .keyword sub-fields, as documented in the comment and validated by the extensive test coverage in the PR.

Low
Suggestions up to commit 1760521
CategorySuggestion                                                                                                                                    Impact
General
Add null safety for field reference

The null check for fieldRef may not be sufficient to detect all non-aggregatable
text fields. Consider verifying that inferNamedField(node) itself doesn't return
null before calling getReferenceForTermQuery() to prevent potential
NullPointerException. Add defensive null handling for the NamedFieldExpression
object.

opensearch/src/main/java/org/opensearch/sql/opensearch/request/AggregateAnalyzer.java [161-167]

-String fieldRef = inferNamedField(node).getReferenceForTermQuery();
+NamedFieldExpression namedField = inferNamedField(node);
+String fieldRef = namedField.getReferenceForTermQuery();
 // Text field with no .keyword sub-field is not aggregatable directly. Fall back to a
 // Calcite script that reads the value from _source.
 if (fieldRef == null) {
   return scriptBuilder.apply(inferScript(node).getScript());
 }
 return fieldBuilder.apply(fieldRef);
Suggestion importance[1-10]: 3

__

Why: The suggestion adds an intermediate variable to store the result of inferNamedField(node), but this doesn't add meaningful null safety since inferNamedField throws an IllegalStateException rather than returning null (as seen in lines 179-180). The refactoring provides minimal value and the concern about NPE is unfounded given the implementation.

Low
Suggestions up to commit bcd0f07
CategorySuggestion                                                                                                                                    Impact
General
Handle empty string from field reference

The null check for fieldRef assumes getReferenceForTermQuery() returns null for
non-aggregatable text fields. However, if this method throws an exception or returns
an empty string instead, the fallback logic won't trigger. Add explicit validation
or document the contract that getReferenceForTermQuery() must return null for
text-only fields.

opensearch/src/main/java/org/opensearch/sql/opensearch/request/AggregateAnalyzer.java [161-167]

 String fieldRef = inferNamedField(node).getReferenceForTermQuery();
 // Text field with no .keyword sub-field is not aggregatable directly. Fall back to a
 // Calcite script that reads the value from _source.
-if (fieldRef == null) {
+if (fieldRef == null || fieldRef.isEmpty()) {
   return scriptBuilder.apply(inferScript(node).getScript());
 }
 return fieldBuilder.apply(fieldRef);
Suggestion importance[1-10]: 3

__

Why: The suggestion to check for empty strings is a defensive programming practice, but there's no evidence in the PR that getReferenceForTermQuery() returns empty strings. The extensive test coverage in the PR validates the null-check behavior works correctly for text-only fields. This is a minor robustness improvement without clear necessity.

Low

@penghuo penghuo added bugFix PPL Piped processing language labels Jul 22, 2026
Signed-off-by: Peng Huo <penghuo@amazon.com>
Signed-off-by: Peng Huo <penghuo@gmail.com>
@github-actions

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit 1760521

Four CalciteExplainIT plans encoded the pre-fix behavior where dedup / chart /
timechart on a text field with no .keyword sub-field silently fell back to a
client-side aggregation over an unbounded scan. With the AggregateAnalyzer fix
those queries now push down as composite terms (script over _source), so the
pinned physical plans are stale.

- Rename testDedupTextTypeNotPushdown -> testDedupTextTypePushdown and update
  explain_dedup_text_type_push.yaml to the composite terms + top_hits DSL.
- Refresh chart_null_str.yaml (chart limit=10 ... over gender by age span=10)
  to the composite terms(script) + histogram plan.
- Refresh explain_timechart.yaml and explain_timechart_count.yaml (timechart
  span=1m ... by host) to the composite terms(script) + date_histogram plan.

Add DedupCommandIT.testDedupOnTextField to verify behavioral equivalence: the
result set for `source=bank | dedup email` matches the fixture's set of
distinct emails, running both under the V2 path (base class) and Calcite
pushdown path (CalciteDedupCommandIT).

Signed-off-by: Peng Huo <penghuo@amazon.com>
Signed-off-by: Peng Huo <penghuo@gmail.com>
@github-actions

Copy link
Copy Markdown
Contributor

PR Code Analyzer ❗

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

PathLineSeverityDescription
integ-test/src/test/resources/expectedOutput/calcite/explain_dedup_text_type_push.yaml10mediumBase64-encoded Java-serialized payloads (rO0ABX… prefix = Java serialization magic bytes) appear in OpenSearch script sources throughout the new and updated YAML test fixtures. While these payloads decode to benign Calcite type metadata ({"dynamicParam":0,"type":{"type":"VARCHAR",...}}) and the pattern is consistent with the existing plugin's opensearch_compounded_script mechanism, Java deserialization of attacker-controlled data is a historically exploited attack vector. Maintainers should confirm the serialized content cannot be substituted with a gadget-chain payload at runtime and that deserialization is constrained to known-safe types.

The table above displays the top 10 most important findings.

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


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 70c263d

Widen the assertion beyond the dedup key to also verify the associated
projected columns per row (firstname, balance), so the top_hits round-trip
in the pushed-down dedup DSL is checked end-to-end.

Signed-off-by: Peng Huo <penghuo@amazon.com>
Signed-off-by: Peng Huo <penghuo@gmail.com>
@github-actions

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit 91204e9

Assert row-level results for `source=events | timechart span=1m avg(cpu_usage)
by host` on the events fixture, where `host` is a text field with no .keyword
sub-field. Golden values were collected on upstream/main (unpushed) before
applying the fix, so the assertion pins behavioral equivalence between the
V2 client-side plan and the pushed composite terms(script)+date_histogram
plan.

Signed-off-by: Peng Huo <penghuo@amazon.com>
Signed-off-by: Peng Huo <penghuo@gmail.com>
@github-actions

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit c409ddf

Signed-off-by: Peng Huo <penghuo@amazon.com>
Signed-off-by: Peng Huo <penghuo@gmail.com>
EnumerableCalc(expr#0..2=[{inputs}], expr#3=[10], expr#4=[null:NULL], expr#5=[SPAN($t2, $t3, $t4)], gender=[$t1], balance=[$t0], age0=[$t5])
CalciteEnumerableIndexScan(table=[[OpenSearch, opensearch-sql_test_index_bank_with_null_values]], PushDownContext=[[PROJECT->[balance, gender, age], FILTER->AND(IS NOT NULL($1), IS NOT NULL($0))], OpenSearchRequestBuilder(sourceBuilder={"from":0,"timeout":"1m","query":{"bool":{"must":[{"exists":{"field":"gender","boost":1.0}},{"exists":{"field":"balance","boost":1.0}}],"adjust_pure_negative":true,"boost":1.0}},"_source":{"includes":["balance","gender","age"]}}, requestedTotalSize=2147483647, pageSize=null, startFrom=0)])
EnumerableCalc(expr#0..2=[{inputs}], expr#3=[SAFE_CAST($t1)], gender=[$t0], age=[$t3], avg(balance)=[$t2])
CalciteEnumerableIndexScan(table=[[OpenSearch, opensearch-sql_test_index_bank_with_null_values]], PushDownContext=[[FILTER->AND(IS NOT NULL($1), IS NOT NULL($0)), AGGREGATION->rel#:LogicalAggregate.NONE.[](input=RelSubset#,group={0, 2},avg(balance)=AVG($1))], OpenSearchRequestBuilder(sourceBuilder={"from":0,"size":0,"timeout":"1m","query":{"bool":{"must":[{"exists":{"field":"gender","boost":1.0}},{"exists":{"field":"balance","boost":1.0}}],"adjust_pure_negative":true,"boost":1.0}},"aggregations":{"composite_buckets":{"composite":{"size":1000,"sources":[{"gender":{"terms":{"script":{"source":"{\"langType\":\"calcite\",\"script\":\"rO0ABXQAaXsKICAiZHluYW1pY1BhcmFtIjogMCwKICAidHlwZSI6IHsKICAgICJ0eXBlIjogIlZBUkNIQVIiLAogICAgIm51bGxhYmxlIjogdHJ1ZSwKICAgICJwcmVjaXNpb24iOiAtMQogIH0KfQ==\"}","lang":"opensearch_compounded_script","params":{"utcTimestamp": 0,"SOURCES":[1],"DIGESTS":["gender"]}},"missing_bucket":true,"missing_order":"first","order":"asc"}}},{"age0":{"histogram":{"field":"age","missing_bucket":true,"missing_order":"first","order":"asc","interval":10.0}}}]},"aggregations":{"avg(balance)":{"avg":{"field":"balance"}}}}}}, requestedTotalSize=2147483647, pageSize=null, startFrom=0)])

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

EnumerableAggregate is pushed down as script.

@github-actions

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit 99d01c3

@penghuo
penghuo marked this pull request as ready for review July 23, 2026 22:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bugFix PPL Piped processing language

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG] Non-pushdownable stats/eventstats silently forces a full-scan PIT and fails with an opaque error

1 participant