Skip to content

Commit 52f45f9

Browse files
committed
[BugFix] Order PPLSimplifyDedupRule before FilterMergeRule in HEP program (#7)
When a user WHERE sits below dedup, the synthetic IS NOT NULL filter that PPL emits as part of dedup keepempty=false ends up adjacent to the user filter above the scan. The HEP program in CalciteToolsHelper registered both FilterMergeRule and PPLSimplifyDedupRule via addRuleCollection, which lets HepPlanner schedule them in either order. When FilterMergeRule fires first the two adjacent filters are merged into a single filter whose condition is AND(IS NOT NULL(field), <user predicate>); PPLSimplifyDedupRule's bottom operand calls mayBeFilterFromBucketNonNull, which only accepts a pure IS NOT NULL or AND-of-IS-NOT-NULLs, so the merged condition fails the predicate, no LogicalDedup is produced, and the OpenSearch DedupPushdownRule has nothing to match — dedup falls back to the row-number window form executed on the coordinator. Switch the HEP program to two sequential addRuleInstance calls (PPLSimplifyDedupRule first to fixpoint, then FilterMergeRule). Sequential addRuleInstance instructions guarantee deterministic ordering, so the simplify rule always sees the original adjacent-filter shape and dedup pushdown is preserved when a where clause is present. Signed-off-by: ryan-gh-bot <ryan-gh-bot@users.noreply.github.com>
1 parent d099d3e commit 52f45f9

3 files changed

Lines changed: 115 additions & 7 deletions

File tree

core/src/main/java/org/opensearch/sql/calcite/utils/CalciteToolsHelper.java

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,6 @@
3636
import java.sql.PreparedStatement;
3737
import java.sql.SQLException;
3838
import java.time.Instant;
39-
import java.util.List;
4039
import java.util.Properties;
4140
import java.util.function.Consumer;
4241
import org.apache.calcite.adapter.enumerable.EnumerableConvention;
@@ -59,7 +58,6 @@
5958
import org.apache.calcite.plan.Convention;
6059
import org.apache.calcite.plan.RelOptCluster;
6160
import org.apache.calcite.plan.RelOptPlanner;
62-
import org.apache.calcite.plan.RelOptRule;
6361
import org.apache.calcite.plan.RelOptSchema;
6462
import org.apache.calcite.plan.RelOptTable;
6563
import org.apache.calcite.plan.RelOptTable.ViewExpander;
@@ -551,12 +549,26 @@ public RelNode visit(TableScan scan) {
551549
}
552550
}
553551

554-
/** Try to optimize the plan by using HepPlanner */
555-
private static final List<RelOptRule> hepRuleList =
556-
List.of(FilterMergeRule.Config.DEFAULT.toRule(), PPLSimplifyDedupRule.DEDUP_SIMPLIFY_RULE);
557-
552+
/**
553+
* Try to optimize the plan by using HepPlanner.
554+
*
555+
* <p>Rule order matters: {@link PPLSimplifyDedupRule#DEDUP_SIMPLIFY_RULE} must run to fixpoint
556+
* before {@link FilterMergeRule}. The simplify rule's bottom operand only matches a pure {@code
557+
* IS NOT NULL} (or AND-of-{@code IS NOT NULL}) bucket-non-null filter; if {@code FilterMergeRule}
558+
* runs first when a user {@code WHERE} sits below the synthetic {@code IS NOT NULL} filter that
559+
* PPL emits as part of {@code dedup}, the two adjacent filters are merged into a single filter
560+
* whose condition includes the user predicate, the simplify rule's predicate fails, no {@link
561+
* org.opensearch.sql.calcite.plan.rel.LogicalDedup} is produced, and dedup pushdown to the
562+
* OpenSearch storage engine is silently disabled. Using separate {@code addRuleInstance} calls
563+
* (rather than {@code addRuleCollection}) enforces deterministic ordering: dedup simplification
564+
* fires first against the original adjacent-filter shape, then any remaining adjacent filters are
565+
* merged.
566+
*/
558567
private static final HepProgram HEP_PROGRAM =
559-
new HepProgramBuilder().addRuleCollection(hepRuleList).build();
568+
new HepProgramBuilder()
569+
.addRuleInstance(PPLSimplifyDedupRule.DEDUP_SIMPLIFY_RULE)
570+
.addRuleInstance(FilterMergeRule.Config.DEFAULT.toRule())
571+
.build();
560572

561573
public static RelNode optimize(RelNode plan, CalcitePlanContext context) {
562574
Util.discard(context);

ppl/src/test/java/org/opensearch/sql/ppl/calcite/CalcitePPLAbstractTest.java

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
import org.opensearch.sql.calcite.CalcitePlanContext;
4646
import org.opensearch.sql.calcite.CalciteRelNodeVisitor;
4747
import org.opensearch.sql.calcite.SysLimit;
48+
import org.opensearch.sql.calcite.utils.CalciteToolsHelper;
4849
import org.opensearch.sql.common.setting.Settings;
4950
import org.opensearch.sql.datasource.DataSourceService;
5051
import org.opensearch.sql.exception.ExpressionEvaluationException;
@@ -110,6 +111,20 @@ public RelNode getRelNode(String ppl) {
110111
return root;
111112
}
112113

114+
/**
115+
* Get the root RelNode of the given PPL query after running the production HEP program from
116+
* {@code CalciteToolsHelper}. Use this in regression tests that exercise rules registered in the
117+
* production HEP program (e.g. {@code PPLSimplifyDedupRule}) — those rules need to see the raw
118+
* planner output, not the post-FilterMerge form returned by {@link #getRelNode(String)}.
119+
*/
120+
public RelNode getRelNodeAfterCalciteHep(String ppl) {
121+
CalcitePlanContext context = createBuilderContext();
122+
Query query = (Query) plan(pplParser, ppl);
123+
planTransformer.analyze(query.getPlan(), context);
124+
RelNode root = context.relBuilder.build();
125+
return CalciteToolsHelper.optimize(root, context);
126+
}
127+
113128
private RelNode mergeAdjacentFilters(RelNode relNode) {
114129
HepProgram program =
115130
new HepProgramBuilder().addRuleInstance(FilterMergeRule.Config.DEFAULT.toRule()).build();

ppl/src/test/java/org/opensearch/sql/ppl/calcite/CalcitePPLDedupTest.java

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -353,4 +353,85 @@ public void testSortFieldProjectedAwayBeforeDedup() {
353353
+ " LogicalTableScan(table=[[scott, EMP]])\n";
354354
verifyLogical(root, expectedLogical);
355355
}
356+
357+
/**
358+
* Regression test for issue #7: when a user {@code where} sits below {@code dedup}, the HEP
359+
* program in {@code CalciteToolsHelper} must still produce a {@link
360+
* org.opensearch.sql.calcite.plan.rel.LogicalDedup}. Before the fix, {@code FilterMergeRule}
361+
* fired ahead of {@code PPLSimplifyDedupRule} and merged the user predicate into the
362+
* bucket-non-null filter; the simplify rule's bottom operand then rejected the merged condition
363+
* (it only accepts pure {@code IS NOT NULL}/AND-of-{@code IS NOT NULL}), no {@code LogicalDedup}
364+
* was produced, and dedup pushdown to the OpenSearch storage engine was silently disabled. The
365+
* fix is to enforce ordering: simplify-dedup runs to fixpoint first, then filter merging.
366+
*/
367+
/**
368+
* Regression test for issue #7: when a user {@code where} sits below {@code dedup}, the HEP
369+
* program in {@code CalciteToolsHelper} must still produce a {@link
370+
* org.opensearch.sql.calcite.plan.rel.LogicalDedup}. Before the fix, both rules were registered
371+
* via {@code addRuleCollection} so {@code FilterMergeRule} could fire ahead of {@code
372+
* PPLSimplifyDedupRule}, merge the user predicate into the bucket-non-null filter, and silently
373+
* disable dedup pushdown to the OpenSearch storage engine. The fix is to register the two rules
374+
* with separate {@code addRuleInstance} calls in the order: simplify-dedup first, then
375+
* filter-merge.
376+
*/
377+
@Test
378+
public void testWhereThenDedupProducesLogicalDedup() {
379+
// Use a where predicate on a DIFFERENT column from the dedup column. With the same column,
380+
// Calcite's RexSimplify can fold AND(IS_NOT_NULL(x), >(x, c)) down to >(x, c), masking the
381+
// bug. The issue's reproducer (where on @timestamp, dedup on namespace) hits this exact
382+
// shape.
383+
String ppl = "source=EMP | where SAL > 1000 | dedup 1 DEPTNO | fields DEPTNO";
384+
RelNode optimized = getRelNodeAfterCalciteHep(ppl);
385+
String optimizedPlan = optimized.explain();
386+
org.junit.Assert.assertTrue(
387+
"where + dedup must produce a LogicalDedup so OpenSearch DedupPushdownRule can match;"
388+
+ " actual plan was:\n"
389+
+ optimizedPlan,
390+
optimizedPlan.contains("LogicalDedup"));
391+
// The window-form leftover would indicate the simplify rule did not fire — assert it is gone.
392+
org.junit.Assert.assertFalse(
393+
"ROW_NUMBER window must be consumed by PPLSimplifyDedupRule when where + dedup are"
394+
+ " combined; actual plan was:\n"
395+
+ optimizedPlan,
396+
optimizedPlan.contains("ROW_NUMBER"));
397+
}
398+
399+
/**
400+
* Adversarial regression test: simulates the pathological order described in issue #7 by forcing
401+
* FilterMergeRule to run to fixpoint BEFORE PPLSimplifyDedupRule. This documents the failure mode
402+
* the fix in {@code CalciteToolsHelper} prevents — once the bucket-non-null filter has been
403+
* merged with the user {@code WHERE}, {@code mayBeFilterFromBucketNonNull} can never accept the
404+
* combined condition, so {@code PPLSimplifyDedupRule} is permanently unable to produce a {@code
405+
* LogicalDedup}. The production fix enforces order at the program level (sequential {@code
406+
* addRuleInstance} calls), making this hazard unreachable.
407+
*/
408+
@Test
409+
public void testFilterMergeBeforeSimplifyDedupBreaksPattern() {
410+
String ppl = "source=EMP | where SAL > 1000 | dedup 1 DEPTNO | fields DEPTNO";
411+
// getRelNode already runs FilterMergeRule on the raw plan, simulating the pathological
412+
// schedule where FilterMergeRule fires before PPLSimplifyDedupRule.
413+
RelNode mergedFirst = getRelNode(ppl);
414+
org.apache.calcite.plan.hep.HepProgram simplifyOnly =
415+
new org.apache.calcite.plan.hep.HepProgramBuilder()
416+
.addRuleInstance(
417+
org.opensearch.sql.calcite.plan.rule.PPLSimplifyDedupRule.DEDUP_SIMPLIFY_RULE)
418+
.build();
419+
org.apache.calcite.plan.hep.HepPlanner planner =
420+
new org.apache.calcite.plan.hep.HepPlanner(simplifyOnly);
421+
planner.setRoot(mergedFirst);
422+
RelNode result = planner.findBestExp();
423+
String plan = result.explain();
424+
org.junit.Assert.assertFalse(
425+
"If FilterMergeRule runs before PPLSimplifyDedupRule, the simplify rule must NOT recover"
426+
+ " — the merged AND(IS_NOT_NULL, user_predicate) filter fails the bucket-non-null"
427+
+ " predicate. This documents why the production HEP program enforces ordering via"
428+
+ " separate addRuleInstance calls (PPLSimplifyDedupRule first, then FilterMergeRule)."
429+
+ " Actual plan was:\n"
430+
+ plan,
431+
plan.contains("LogicalDedup"));
432+
org.junit.Assert.assertTrue(
433+
"Plan should still contain ROW_NUMBER window form when simplify fails. Actual plan was:\n"
434+
+ plan,
435+
plan.contains("ROW_NUMBER"));
436+
}
356437
}

0 commit comments

Comments
 (0)