Repository navigation
Description
Query Information
PPL Command/Query:
source=logs-*
| where `@timestamp` > DATE_SUB(NOW(), INTERVAL 5 MINUTE)
| where severityText = 'ERROR'
| eventstats count() as total
| head 10
| fields severityText, total
Expected Result:
The global eventstats count() should push down to OpenSearch as track_total_hits (the same way stats count() does), so the shard returns one number and the coordinator scales it across documents — instead of streaming every matching document to the coordinator just to count them.
Actual Result:
Pushdown does not fire. The scan runs with requestedTotalSize=2147483647 and _source includes — i.e., every matching document streams to the coordinator, where a COUNT() OVER () window function counts them in memory. On a 47B-document index this exceeds query timeouts (the reported "FAIL on PI") and on smaller indices it returns 0 rows when the streaming exceeds limits ("0 rows on CAPE/WI"). The query returns correct results on tiny test data but the plan is unviable at production scale.
Dataset Information
Dataset/Schema Type
- OpenTelemetry (OTEL)
Index Mapping
{
"index_patterns": ["logs-*"],
"template": {
"mappings": {
"properties": {
"@timestamp": { "type": "date" },
"body": { "type": "text", "norms": false },
"severityText": { "type": "keyword" }
}
}
}
}Sample Data
{ "@timestamp": "2026-05-28T17:44:20.000Z", "severityText": "ERROR", "body": "..." }
{ "@timestamp": "2026-05-28T17:44:21.000Z", "severityText": "INFO", "body": "..." }
{ "@timestamp": "2026-05-28T17:44:22.000Z", "severityText": "ERROR", "body": "..." }
{ "@timestamp": "2026-05-28T17:44:23.000Z", "severityText": "ERROR", "body": "..." }Bug Description
Issue Summary:
eventstats (no BY, no partition) — equivalent to a global stats count() for purposes of the count value — produces a COUNT() OVER () window function instead of a flat aggregation, and the existing pushdown rules deliberately skip it.
Explain output for both shapes (Calcite engine, pushdown enabled):
stats count() as total (works, pushed down):
CalciteEnumerableIndexScan(
PushDownContext=[[FILTER->..., AGGREGATION->...COUNT(), LIMIT->10000]],
sourceBuilder={"size":0, ..., "track_total_hits":2147483647})
DSL has "size":0 and track_total_hits — the shard returns one number.
eventstats count() as total (fails to push down):
EnumerableLimit(fetch=[10000])
EnumerableLimit(fetch=[10])
EnumerableWindow(window#0=[window(aggs [COUNT()])])
CalciteEnumerableIndexScan(
PushDownContext=[[PROJECT->[@timestamp, severityText],
FILTER->AND(>(@ts,...), =(severityText,'ERROR')),
PROJECT->[severityText]]],
sourceBuilder={"_source":{"includes":["severityText"]},
"query":{"bool":{"must":[{"range":...},{"term":{"severityText":"ERROR"}}]}}},
requestedTotalSize=2147483647)
DSL has _source includes and requestedTotalSize=MAX_INT — every matching document streams up to the coordinator. EnumerableWindow then counts them.
Root cause:
PPL eventstats is parsed in AstBuilder.visitEventstatsCommand (ppl/src/main/java/org/opensearch/sql/ppl/parser/AstBuilder.java:498) as a Window node, which Calcite lowers to a RexOver. The aggregation pushdown path in AggregateIndexScanRule (opensearch/.../planner/rules/AggregateIndexScanRule.java:122,171) explicitly excludes RexOver from pushdown — windows are treated as opaque coordinator-side computations.
This treatment is correct for windows that need partitioning or ordering. But eventstats <agg> without a BY clause produces an unpartitioned, unordered window — OVER () — which is semantically equivalent to: "compute a single global aggregate, broadcast it to every input row." The first half of that (the global aggregate) is exactly what OpenSearch can do natively in the shard.
The result is pathological at scale: instead of one round-trip returning a single number, the coordinator pulls every matching document.
Steps to Reproduce:
- Apply the index template above; index a few sample documents.
- Set
plugins.calcite.enabled: true,plugins.calcite.pushdown.enabled: true. - Run
EXPLAINon the failing query — observeEnumerableWindowand the absence of anaggregationsblock in the DSL. - Replace
eventstatswithstatsand re-runEXPLAIN— observe the correctaggregations+track_total_hitsshape.
Environment Information
OpenSearch Version: 3.7.0-SNAPSHOT (reproduced on main, commit ec433f4ff).
Additional Details:
- Calcite engine:
plugins.calcite.enabled = true - Pushdown:
plugins.calcite.pushdown.enabled = true - Code locations:
ppl/src/main/java/org/opensearch/sql/ppl/parser/AstBuilder.java:498—eventstatsbecomes aWindownodeopensearch/src/main/java/org/opensearch/sql/opensearch/planner/rules/AggregateIndexScanRule.java:122,171— pushdown rule excludesRexOver
Possible fix:
Add a rule that recognizes RexOver with empty partition/order/frame as a global aggregate and rewrites it to:
Project(input.*, broadcast(globalAgg))
Aggregate(group=[], aggs=[<agg>])
<input>
The aggregate is then pushable through the existing AggregateIndexScanRule. The broadcast step on the coordinator is one extra column join that costs nothing — far cheaper than streaming the entire matched set.
Same fix applies to eventstats count() by <field>: OVER (PARTITION BY <field>) → Aggregate(group=[<field>], aggs=[<agg>]) joined back on <field>. Both shapes are common in observability queries.
Activity
RyanL1997 commentedon May 29, 2026
@ryan-gh-bot can you do a rca and propose a fix? I would like to review it before you actually open the PR. You can just post it here.
ryan-gh-bot commentedon May 29, 2026
Working on
/@triage(invoked by @RyanL1997) — posting result when done.ryan-gh-bot commentedon May 29, 2026
[RCA]
eventstatslowers to aLogicalProjectcontainingRexOver, which the aggregation pushdown rule explicitly excludesThe reporter's diagnosis is correct. PPL
eventstatsalways emits aWindowAST node, which lowers to aLogicalProjectwhose expressions areRexOvers. EveryAggregateIndexScanRule.Configregistered today rejects projects that matchLogicalProject::containsOver, so the global-aggregate case (noBY, no order, full frame) — which is semantically a flat aggregate — never reaches the pushdown path.Environment
sql(RyanL1997/sqlfork; upstreamopensearch-project/sql)target/main @ d099d3effEXPLAINtrace frommain @ ec433f4ffshowing the exact symptom (EnumerableWindowoverrequestedTotalSize=2147483647), and the same shape is locked in by an existing unit test (see Evidence).Suspected location
ppl/src/main/java/org/opensearch/sql/ppl/parser/AstBuilder.javaL498-L526 —visitEventstatsCommandunconditionally returns aWindowAST node, including whengroupListis empty.core/src/main/java/org/opensearch/sql/calcite/CalciteRelNodeVisitor.javaL2105-L2134 —visitWindowcallsrelBuilder.projectPlus(overExpressions), producing theLogicalProject(..., RexOver)shape.core/src/main/java/org/opensearch/sql/calcite/CalciteRexNodeVisitor.javaL581-L654 —visitWindowFunctionbuilds theRexOverviaPlanUtils.makeOver, withpartitions/orderKeystaken straight from the AST. Foreventstats count()(noBY) both lists are empty.opensearch/src/main/java/org/opensearch/sql/opensearch/planner/rules/AggregateIndexScanRule.javaL121-L131 —Config.DEFAULTpredicatePredicate.not(LogicalProject::containsOver)excludes any project containing aRexOver.opensearch/src/main/java/org/opensearch/sql/opensearch/planner/rules/AggregateIndexScanRule.javaL171-L182 — same exclusion in theBUCKET_NON_NULL_AGGconfig; same exclusion repeats at L202 and L214 for the…WITH_UDFvariant.ppl/src/test/java/org/opensearch/sql/ppl/calcite/CalcitePPLEventstatsTest.javaL19-L31 — existing test asserts the broken shape:count()=[COUNT() OVER ()]over aLogicalTableScan.Analysis
The query path for
eventstats <agg>(noBY) is:AstBuilder.visitEventstatsCommandreturnsnew Window(windowFunctionList, groupList, bucketNullable). When theBYclause is absent,groupListis empty.CalciteRelNodeVisitor.visitWindowcallsrexVisitor.analyze(...)on each entry to produceRexOvers, thenrelBuilder.projectPlus(overExpressions). The result is a singleLogicalProjectwhose new columns areRexOvers with empty partition keys, empty order keys, and a default unbounded frame.AggregateIndexScanRule.ConfigrequiresPredicate.not(LogicalProject::containsOver)on the project under the aggregate. The eventstats project always carries aRexOver, so no rule fires. The window is preserved asEnumerableWindow, executed coordinator-side, and the underlyingCalciteEnumerableIndexScanretainsrequestedTotalSize=Integer.MAX_VALUEbecause no aggregate replaced it.The exclusion is correct for window functions that genuinely need partition/order semantics — those cannot be expressed as a single aggregation. But for an unpartitioned, unordered, fully unbounded
OVER ()the row-level value is, by definition, the global aggregate broadcast to every input row. That global aggregate is exactly the shape OpenSearch handles natively in a single shard request (size:0,track_total_hits).The same reasoning extends to
eventstats <agg> by <field>(aOVER (PARTITION BY <field>)window with no order and full frame): the row-level value is the per-group aggregate broadcast to each group's rows, equivalent to aterms-bucketed aggregation joined back on<field>. Both shapes can be expressed in pushable Calcite without losing semantics.Evidence
visitEventstatsCommandat AstBuilder.java L498-L526 constructs theWindowAST.visitWindowat CalciteRelNodeVisitor.java L2105 lowers toprojectPlus(overExpressions).containsOverexclusions in AggregateIndexScanRule.java appear at L125, L175, L202, L214.visitStatsCommandat AstBuilder.java L463-L495 returnsnew Aggregation(...), lowering toLogicalAggregateand matchingConfig.AGGREGATE_SCAN. This is the path that pushes down — exactly as the reporter shows in the workingstats count()EXPLAIN.AggregateIndexScanRulepredates theeventstatspush-down request, so this is a missing optimization, not a regression.EXPLAINoutput for both shapes against currentmain @ ec433f4ff, and the unit testtestEventstatsCountexplicitly asserts the broken plan shape, so the symptom is reproducible by inspection.Confidence: High
Two independent signals converge: (a) static reading shows a single, unambiguous code path where eventstats becomes a project-with-RexOver and four pushdown configs uniformly reject it, and (d) the existing unit test asserts the exact
LogicalProject(..., COUNT() OVER ())shape that the reporter quotes from the runtimeEXPLAIN.Next steps
Proposed fix (please review before I open a PR). Add a Calcite rule that runs before
AggregateIndexScanRuleand rewrites aLogicalProjectcontaining only "globally unbounded"RexOvers into anAggregate + Joinshape that the existing pushdown rule already handles. This matches the rule-level approach the issue suggests, and it generalizes to the SQLSELECT col, COUNT() OVER () FROM tshape too.Match criteria (intentionally strict, to avoid touching legitimate window semantics):
RexOvers.RexOver:partitionKeys.isEmpty(),orderKeys.isEmpty(), and the frame is unbounded on both sides (lowerBound.isUnbounded()andupperBound.isUnbounded()). This is the shapevisitEventstatsCommandproduces whengroupListis empty; it is alsoWindowFrame.rowsUnbounded()fromPlanUtils.makeOverL178-L191.RexInputRefs) so the input row is preserved unchanged. (Conservative; relax later if needed.)Rewrite (logical):
The new
LogicalAggregateis then matched by the existingAggregateIndexScanRule.Config.AGGREGATE_SCAN(no project under it), pushes intoCalciteEnumerableIndexScanas a flatsize:0+track_total_hitsrequest, and the cross-join with the resulting single-row relation runs coordinator-side as a constant broadcast.Files to change:
opensearch/src/main/java/org/opensearch/sql/opensearch/planner/rules/GlobalWindowToAggregateRule.java— the new rule. Precedent for inspectingRexOvershapes inside aLogicalProjectlives inRareTopPushdownRule.javaL34-L88 and the helperPlanUtils.getRexWindowFromProject.OpenSearchIndexRules.OPEN_SEARCH_PUSHDOWN_RULES— register the new rule beforeAGGREGATE_INDEX_SCAN.CalcitePPLEventstatsTest.testEventstatsCount— the asserted "expectedLogical" string must change once the rewrite runs in this test path; if the test runs at a stage before pushdown rules fire, leave it and add a new expectation at a later stage. (Will confirm during implementation.)LogicalAggregateand that the resulting OpenSearch DSL contains"size":0andtrack_total_hits(mirror thestats count()baseline from the issue'sEXPLAIN).Open questions worth deciding before I write the PR:
OVER ()(no partition) case in the first PR, or includeOVER (PARTITION BY <field>)as well? TheBYcase needs anAggregate(group=[<field>]) JOIN ON input.field = agg.fieldshape with explicit handling ofbucket_nullable=false(the existingwrapWindowFunctionsWithGroupNotNullpath invisitWindow), so it is meaningfully more work and review surface. My preference is to land the no-BYcase first since that's what the bug title and the reproducer call out, then a follow-up PR forBY.OPEN_SEARCH_PUSHDOWN_RULES(gated byplugins.calcite.pushdown.enabled). It's behavior-preserving so it could also live inOPEN_SEARCH_NON_PUSHDOWN_RULES, but there's no point rewriting if pushdown is off — keeping it gated avoids extra optimizer work.RyanL1997/sql. If the intent is to upstream this, file paths and tests are identical; only the PR target changes.Let me know which scope to take and I'll open the PR against
target/main.Automated response from @ryan-gh-bot (maintainer-triggered agent). About this agent.