Repository navigation
Description
Query Information
PPL Command/Query:
source=logs-*
| where `@timestamp` > DATE_SUB(NOW(), INTERVAL 5 MINUTE)
| dedup `resource.attributes.k8s.namespace.name`
| fields `resource.attributes.k8s.namespace.name`
Expected Result:
The dedup should push down to the shard as a composite aggregation + top_hits (the same pushdown that already works without a where clause), returning one row per distinct namespace value.
Actual Result:
With any where clause upstream, dedup pushdown is disabled. The scan returns every matching document to the coordinator and dedup runs in memory via a ROW_NUMBER window. On large indices (47B+ docs reported by users) this exceeds timeouts or triggers shard failures.
The same query without where pushes down correctly.
Dataset Information
Dataset/Schema Type
- OpenTelemetry (OTEL)
Index Mapping
{
"index_patterns": ["logs-*"],
"template": {
"mappings": {
"dynamic_templates": [
{ "resource_attributes": { "path_match": "resource.attributes.*",
"mapping": {"type": "keyword"},
"match_mapping_type": "string" } }
],
"properties": {
"@timestamp": { "type": "date" },
"body": { "type": "text", "norms": false },
"severityText": { "type": "keyword" }
}
}
}
}Sample Data
{ "@timestamp": "2026-05-28T16:18:40.000Z", "severityText": "INFO",
"resource": {"attributes": {"k8s.namespace.name": "kube-system"}}, "body": "..." }
{ "@timestamp": "2026-05-28T16:18:41.000Z", "severityText": "INFO",
"resource": {"attributes": {"k8s.namespace.name": "default"}}, "body": "..." }
{ "@timestamp": "2026-05-28T16:18:42.000Z", "severityText": "INFO",
"resource": {"attributes": {"k8s.namespace.name": "monitoring"}}, "body": "..." }
{ "@timestamp": "2026-05-28T16:18:43.000Z", "severityText": "INFO",
"resource": {"attributes": {"k8s.namespace.name": "kube-system"}}, "body": "..." }Bug Description
Issue Summary:
Explain output for both shapes (Calcite engine, pushdown enabled):
Without where (pushdown works):
CalciteEnumerableIndexScan(
PushDownContext=[[AGGREGATION->...LITERAL_AGG(1), LIMIT->10000]],
sourceBuilder={"size":0,"aggregations":{
"composite_buckets":{"composite":{...,"sources":[{"resource.attributes.k8s.namespace.name":{"terms":{"field":"..."}}}]},
"aggregations":{"$f1":{"top_hits":{"size":1,...}}}}}})
With where (pushdown fails):
EnumerableLimit(fetch=[10000])
EnumerableCalc(<= row_number 1)
EnumerableWindow(ROW_NUMBER() OVER (PARTITION BY $8))
CalciteEnumerableIndexScan(
PushDownContext=[[PROJECT, FILTER->AND(>(@ts,...), IS NOT NULL(...)), PROJECT]],
sourceBuilder={"_source":{"includes":[...]},
"query":{"bool":{"must":[{"range":...},{"exists":...}]}}})
Three operators of in-memory work appear above the scan; the scan emits _source (every matching doc) instead of an aggregation.
Root cause:
PPL dedup is initially compiled into a ROW_NUMBER() OVER (PARTITION BY field) window pattern, with a bucket-non-null IS_NOT_NULL(field) filter directly above the scan. A HEP rule, PPLSimplifyDedupRule (core/.../plan/rule/PPLSimplifyDedupRule.java), is supposed to fold that pattern into a LogicalDedup operator, which DedupPushdownRule (opensearch/.../planner/rules/DedupPushdownRule.java) then pushes to the scan as composite + top_hits.
The HEP program (CalciteToolsHelper.java:556) registers two rules in this order: FilterMergeRule.DEFAULT, PPLSimplifyDedupRule.DEDUP_SIMPLIFY_RULE. When a user where is present, the user's filter and the bucket-non-null filter are adjacent above the scan; FilterMergeRule runs first and merges them into one filter AND(IS_NOT_NULL(field), <user predicate>). PPLSimplifyDedupRule's operand requires the bottom filter to satisfy mayBeFilterFromBucketNonNull (PlanUtils.java:788), which only accepts pure IS_NOT_NULL or AND-of-IS_NOT_NULLs. The merged condition fails the predicate, the rule never fires, no LogicalDedup is produced, and DedupPushdownRule has nothing to match. Dedup falls through to the row-number window form executed on the coordinator.
Steps to Reproduce:
- Apply the index template above; index a few sample documents into
logs-000001. - Set
plugins.calcite.enabled: trueandplugins.calcite.pushdown.enabled: true. - Run
EXPLAINonsource=logs-* | dedup resource.attributes.k8s.namespace.name— observecomposite+top_hitsaggregation in the DSL (pushdown succeeds). - Run
EXPLAINon the same query with| where @timestamp > DATE_SUB(NOW(), INTERVAL 5 MINUTE)inserted beforededup— observe theEnumerableWindowform, no aggregation in DSL (pushdown fails).
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:
core/src/main/java/org/opensearch/sql/calcite/utils/CalciteToolsHelper.java:556— HEP rule ordercore/src/main/java/org/opensearch/sql/calcite/plan/rule/PPLSimplifyDedupRule.java— rule whose operand pattern fails after FilterMergecore/src/main/java/org/opensearch/sql/calcite/utils/PlanUtils.java:788—mayBeFilterFromBucketNonNullpredicateopensearch/src/main/java/org/opensearch/sql/opensearch/planner/rules/DedupPushdownRule.java— Volcano rule that depends onLogicalDedupbeing produced
Possible fixes:
- Loosen
mayBeFilterFromBucketNonNullto accept anyANDwhose operands contain anIS_NOT_NULLon the partition column (other conjuncts pass through as a separate filter that gets pushed normally). - Reorder the HEP program so
PPLSimplifyDedupRuleruns beforeFilterMergeRule— the dedup pattern matcher then sees the original adjacent-filter shape; FilterMerge can run after.
Activity
RyanL1997 commentedon May 28, 2026
@ryan-gh-bot, can you root cause this?
ryan-gh-bot commentedon May 28, 2026
Working on
@triage(invoked by @RyanL1997) — posting result when done.ryan-gh-bot commentedon May 28, 2026
[RCA]
FilterMergeRuleruns beforePPLSimplifyDedupRuleand merges the userWHEREinto the bucket-non-null filter, so the dedup-simplify pattern fails to match andLogicalDedupis never producedThe HEP program in
CalciteToolsHelperregistersFilterMergeRuleahead ofPPLSimplifyDedupRule. When a userWHEREsits below the syntheticIS NOT NULLfilter that PPL emits as part ofdedup(keepempty=false), the two adjacent filters are merged into one whose condition isAND(IS_NOT_NULL(field), <user predicate>). The simplify rule's bottom operand requires that filter to satisfymayBeFilterFromBucketNonNull, which only accepts pureIS_NOT_NULL(or AND-of-IS_NOT_NULLs); the merged condition fails the predicate, the rule never fires, noLogicalDedupis produced, and the Volcano-timeDedupPushdownRulehas nothing to match — dedup falls back to the row-number window form executed on the coordinator.Environment
sql(opensearch-project/sql)target/main @ d099d3effSuspected location
core/src/main/java/org/opensearch/sql/calcite/utils/CalciteToolsHelper.javaL555-L559 — HEP rule order:FilterMergeRulethenPPLSimplifyDedupRule.DEDUP_SIMPLIFY_RULE.core/src/main/java/org/opensearch/sql/calcite/utils/PlanUtils.javaL788-L799 —mayBeFilterFromBucketNonNull+isNotNullOnRef: only accepts a pureIS_NOT_NULLon aRexInputRef, or anANDwhose every operand is such anIS_NOT_NULL.core/src/main/java/org/opensearch/sql/calcite/plan/rule/PPLSimplifyDedupRule.javaL158-L184 — operand pattern: the bottomLogicalFilter(immediate child of the windowLogicalProject) must satisfyPlanUtils::mayBeFilterFromBucketNonNull.core/src/main/java/org/opensearch/sql/calcite/plan/rule/PPLDedupConvertRule.javaL151-L188 —buildDedupNotNullemits theIS NOT NULL(...)filter directly above the existing input; if the user added| where ..., that user filter is the input — the two filters are adjacent.opensearch/src/main/java/org/opensearch/sql/opensearch/planner/rules/DedupPushdownRule.java— Volcano substitution rule whose operand is rooted atLogicalDedup; it cannot fire unless the simplify rule has produced one.Analysis
PPL
| dedup field(defaultkeepempty=false) is compiled byPPLDedupConvertRule.buildDedupNotNullinto the following bottom-up shape (the comment in that method documents it explicitly):PPLSimplifyDedupRuleis a HEP rule whose 4-level operand matches that exact pattern and rewrites it into aLogicalDedup, whichDedupPushdownRulelater pushes to the OpenSearch index scan ascomposite+top_hits.The bug is in the interaction between the two rules registered in the HEP program:
| where, the bucket-non-null filter's input is the bare scan.PPLSimplifyDedupRulematches, producesLogicalDedup, andDedupPushdownRulepushes it. This is the "good" EXPLAIN the reporter showed (composite aggregation + top_hits).| where ts > X, the user predicate is already aLogicalFilterdirectly under the bucket-non-null filter whenbuildDedupNotNullruns. The two adjacentLogicalFilters are exactly the shapeFilterMergeRuleis designed to collapse. BecauseFilterMergeRuleis listed first inhepRuleList, the HEP planner fires it first and rewrites the subtree to a single filter whose condition isAND(IS_NOT_NULL(field), ts > X).PPLSimplifyDedupRule's operandb3then evaluatesmayBeFilterFromBucketNonNullagainst that merged filter. The predicate'sANDbranch requiresrexCall.getOperands().stream().allMatch(PlanUtils::isNotNullOnRef); the>operand is not anIS_NOT_NULL, so the predicate returnsfalse. The operand never matches, no transform is applied, noLogicalDedupis produced.ROW_NUMBER OVER (PARTITION BY ...)window survives,DedupPushdownRulefinds nothing matching itsLogicalDeduproot, and the scan emits_sourcerather than the composite aggregation. This is the "bad" EXPLAIN withEnumerableLimit -> EnumerableCalc -> EnumerableWindow -> CalciteEnumerableIndexScanthat the reporter observed.This is consistent with the symptom: dedup pushdown works on
source=logs-* | dedup fieldbut breaks the moment anyWHERElands between the dedup compilation output and the scan.Evidence
CalciteToolsHelper.java:555-556is literallyList.of(FilterMergeRule.Config.DEFAULT.toRule(), PPLSimplifyDedupRule.DEDUP_SIMPLIFY_RULE)—FilterMergeRuleis first.PlanUtils.mayBeFilterFromBucketNonNullat lines 788-794 only returnstruefor a pureIS_NOT_NULLon aRexInputRefor anANDwhose operands are all such; mixing in a non-IS_NOT_NULL(e.g., a range comparison from a userWHERE) makes it returnfalse. Confirmed by readingisNotNullOnRefat lines 796-799.PPLSimplifyDedupRule.Config.DEFAULTat lines 158-184 anchors the bottom of its operand on aLogicalFilterpredicated onPlanUtils::mayBeFilterFromBucketNonNull. There is no fallback path that accepts a wider filter.PPLDedupConvertRule.buildDedupNotNullat lines 151-188 emits theIS NOT NULLfilter as the immediate child of whatever input was on the rel-builder stack — guaranteeing adjacency to a userWHEREif one is present.DedupPushdownRule.Config.DEFAULToperand is rooted atLogicalDedup(b0.operand(LogicalDedup.class)...), so it depends on the simplify rule having succeeded.composite+top_hitswhen the simplify rule fires,EnumerableWindowform when it doesn't.LogicalDedup) yield high confidence without needing runtime confirmation.Confidence: High
Four independent signals from reading the actual files at the checked-out sha (HEP rule order,
mayBeFilterFromBucketNonNullshape,PPLSimplifyDedupRuleoperand definition,DedupPushdownRuleoperand depending onLogicalDedup) all converge on the same explanation, and the EXPLAIN evidence quoted in the issue body matches both paths exactly.Next steps
PPLSimplifyDedupRule.DEDUP_SIMPLIFY_RULEis registered beforeFilterMergeRule.Config.DEFAULT.toRule()inCalciteToolsHelper.javaL555-L556. The dedup pattern matcher then sees the original adjacent-filter shape; FilterMerge can run after and the user filter remains pushable normally. Lowest risk because it doesn't change the simplify rule's invariants — it only fixes the rule-order race.mayBeFilterFromBucketNonNullinPlanUtils.javaL788-L794 to accept anyANDcontaining anIS_NOT_NULLon the partition column(s), and split the matched filter so the non-IS_NOT_NULLconjuncts are re-attached as a separate filter that can be pushed normally. More invasive —PPLSimplifyDedupRule.applywould also need to thread the residual predicate above the dedup it produces.core/src/test/java/.../calcite/plan/rule/that constructs aLogicalProject(window) -> LogicalFilter(IS_NOT_NULL) -> LogicalFilter(user predicate) -> Scanplan, runs the currentHEP_PROGRAM, and asserts that aLogicalDedupwas produced would prevent regressions on either fix.@ryan-gh-bot /reproduceso the reporter's EXPLAIN can be replayed against the local server.Automated response from @ryan-gh-bot (maintainer-triggered agent). About this agent.
RyanL1997 commentedon May 28, 2026
@ryan-gh-bot, fix it based on your rca
ryan-gh-bot commentedon May 28, 2026
Working on
@triage(invoked by @RyanL1997) — posting result when done.ryan-gh-bot commentedon May 28, 2026
Opened pull request #8
This was generated by
/fixinvoked by @RyanL1997. The PR body includes Before/After verification output. Please review.