Skip to content

Commit 3b27e69

Browse files
RyanL1997bkhishor
authored andcommitted
[Analytics-Engine] Decorrelate RexSubQuery before marking in PlannerImpl (opensearch-project#21795)
* Decorrelate RexSubQuery before marking in PlannerImpl PPL queries that contain EXISTS / IN / SOME / ANY subqueries (and the `subsearch` shapes the frontend lowers to them) reach the analytics engine as a LogicalFilter / LogicalProject / LogicalJoin holding a RexSubQuery. The downstream marking rules — particularly OpenSearchFilterRule, which resolves leaf predicates through a ScalarFunction table that doesn't (and shouldn't) cover EXISTS — reject the operator at run time: IllegalStateException: Unrecognized filter operator [EXISTS / EXISTS] Add a 'subquery-remove' phase at the top of PlannerImpl.runAllOptimizations that runs Calcite's three SUB_QUERY_TO_CORRELATE rules to lower every RexSubQuery into a LogicalCorrelate, then decorrelates the result back to a straight join shape via RelDecorrelator.decorrelateQuery. Every later phase (reduce / pushdown / decompose / marking / CBO) sees a subquery-free tree. Placement: per the Component Responsibilities table in opensearch-project/sql#5246, logical-plan rewrites are an SQL-plugin concern long-term — but the unified path has no logical-optimizer stage yet, and AE already owns RelNode → physical-execution. Putting the rewrite here lets every AE client (PPL today, ANSI SQL / Spark / future frontends) get correct subquery handling without each frontend having to remember to run SubQueryRemoveRule + RelDecorrelator before handoff. When a UnifiedQueryOptimizer lands SQL-side, this phase can migrate upward. After: CalcitePPLExistsSubqueryIT goes from 19/19 fail (all IllegalStateException at the filter rule) to 6/19 pass + 13/19 AssertionError result mismatches. The remaining 13 failures are downstream analytics-engine semantics for bare-group-by Aggregate inside the decorrelated join — unrelated to subquery removal, tracked separately. Tests: * SubQueryPlanShapeTests — 3 new tests covering uncorrelated EXISTS, correlated EXISTS, and IN-subquery; each asserts no RexSubQuery survives the planner pipeline. * RuleProfilingListenerTests — EXPECTED_PHASES extended with 'subquery-remove' so the profile-listener phase-list assertion still matches the actual phase ordering. * Full :sandbox:plugins:analytics-engine:test — 311 tests, 0 failures. Signed-off-by: Jialiang Liang <jialianl@amazon.com> Signed-off-by: Jialiang Liang <jiallian@amazon.com> * spotlessApply Inline the RelDecorrelator.decorrelateQuery call and reorder the RelHomogeneousShuttle import — fixes the spotlessJavaCheck failure sandbox-check flagged on the prior commit. Signed-off-by: Jialiang Liang <jialianl@amazon.com> Signed-off-by: Jialiang Liang <jiallian@amazon.com> * Flip StreamstatsCommandIT.testWhereInWithStreamstatsSubquery to positive assertion The subquery-remove phase introduced in PlannerImpl makes the where-in-with-streamstats-subquery shape reachable on the analytics-engine route — RexSubQuery lowers to LogicalCorrelate, then decorrelates into a semi-join that the marking + capability rules already support. The test was previously pinned as assertErrorAny(...) because the query crashed at the filter rule before this phase existed. Flip it to executePpl(...) + assertRowCount(1) — `| head 1` over a non-empty matching set returns one row — and update the class-level 'known limitations' comment so testWhereInWithStreamstatsSubquery is no longer listed alongside testLeftJoinWithStreamstats. Signed-off-by: Jialiang Liang <jialianl@amazon.com> Signed-off-by: Jialiang Liang <jiallian@amazon.com> * Revert testWhereInWithStreamstatsSubquery flip — keep assertErrorAny The previous flip to executePpl + assertRowCount(1) was based on a single-node local run where the query succeeded. On the 4-node testcluster that sandbox-check uses, the decorrelated streamstats-inside-correlate shape errors with 'Stage 0 sink feed failed: partition stream receiver dropped before send' rather than producing rows. The subquery-remove phase moves the failure point from plan-time (IllegalStateException at OpenSearchFilterRule) to runtime (sink-feed failure during distributed execution) — same overall outcome from the test's perspective (HTTP 500), just a different message. Restore the original assertErrorAny(...) assertion: it catches any ResponseException so both the old and new error shapes satisfy it. Keep the test on the broad-failure pin until the downstream multi-node correctness gap is closed. Locally verified: integTest --tests testWhereInWithStreamstatsSubquery passes 1/1 against a 4-node testcluster on this commit. Signed-off-by: Jialiang Liang <jialianl@amazon.com> Signed-off-by: Jialiang Liang <jiallian@amazon.com> * Skip testWhereInWithStreamstatsSubquery with @AwaitsFix — nondeterministic on multi-node The subquery-remove phase makes this where-in-with-streamstats shape reachable on the analytics-engine route, but the streamstats-inside- decorrelated-correlate execution path races on multi-node clusters: sometimes the Stage 0 sender hits a 'partition stream receiver dropped before send' error, sometimes it returns one row successfully. Neither a positive assertion (executePpl + assertRowCount) nor the prior broad-failure assertion (assertErrorAny) is stable across CI runs. Pin with @AwaitsFix until the downstream multi-node race is closed — re-enable by removing the annotation. The annotation message names the specific failure signature so the next person picking this up knows which subsystem to investigate. Locally verified: integTest --tests testWhereInWithStreamstatsSubquery reports the test as skipped (tests=1 skipped=1 failures=0 errors=0). Signed-off-by: Jialiang Liang <jialianl@amazon.com> Signed-off-by: Jialiang Liang <jiallian@amazon.com> --------- Signed-off-by: Jialiang Liang <jialianl@amazon.com> Signed-off-by: Jialiang Liang <jiallian@amazon.com>
1 parent e1f3cda commit 3b27e69

4 files changed

Lines changed: 178 additions & 3 deletions

File tree

sandbox/plugins/analytics-engine/src/main/java/org/opensearch/analytics/planner/PlannerImpl.java

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
import org.apache.calcite.rel.metadata.RelMetadataQuery;
2525
import org.apache.calcite.rel.rules.CoreRules;
2626
import org.apache.calcite.rel.rules.ReduceExpressionsRule;
27+
import org.apache.calcite.sql2rel.RelDecorrelator;
2728
import org.apache.calcite.tools.RelBuilder;
2829
import org.apache.logging.log4j.LogManager;
2930
import org.apache.logging.log4j.Logger;
@@ -87,6 +88,7 @@ public static RelNode runAllOptimizations(RelNode rawRelNode, PlannerContext con
8788
RuleProfilingListener listener = context.isProfilingEnabled() ? new RuleProfilingListener() : null;
8889

8990
RelNode modifiedRelNode = rawRelNode;
91+
modifiedRelNode = removeSubQueries(modifiedRelNode, listener);
9092
modifiedRelNode = reduceExpressions(modifiedRelNode, listener);
9193
modifiedRelNode = pushdownRules(modifiedRelNode, listener);
9294
modifiedRelNode = decomposeAggregates(modifiedRelNode, listener);
@@ -112,6 +114,42 @@ public static RelNode runAllOptimizations(RelNode rawRelNode, PlannerContext con
112114
return modifiedRelNode;
113115
}
114116

117+
/**
118+
* Phase 0: lower {@link org.apache.calcite.rex.RexSubQuery}s (EXISTS / IN / SOME / ANY,
119+
* including the PPL {@code subsearch} shapes that the frontend lowers to them) into
120+
* {@code LogicalCorrelate} via the three {@code *_SUB_QUERY_TO_CORRELATE} rules, then
121+
* decorrelate back to a standard join shape. Without this phase, downstream rules see
122+
* a {@code RexSubQuery} inside a filter / project predicate and either reject the
123+
* operator outright (e.g. {@link org.opensearch.analytics.planner.rules.OpenSearchFilterRule}
124+
* resolves leaf predicates through a {@code ScalarFunction} table that doesn't and
125+
* shouldn't cover {@code EXISTS}) or carry the un-removed subquery into substrait
126+
* emission. Runs first so every later phase observes a subquery-free tree.
127+
*/
128+
private static RelNode removeSubQueries(RelNode input, RuleProfilingListener listener) {
129+
HepProgramBuilder builder = new HepProgramBuilder();
130+
builder.addRuleCollection(
131+
List.of(
132+
CoreRules.FILTER_SUB_QUERY_TO_CORRELATE,
133+
CoreRules.PROJECT_SUB_QUERY_TO_CORRELATE,
134+
CoreRules.JOIN_SUB_QUERY_TO_CORRELATE
135+
)
136+
);
137+
HepPlanner planner = new HepPlanner(builder.build());
138+
if (listener != null) {
139+
planner.addListener(listener);
140+
listener.beginPhase("subquery-remove");
141+
}
142+
try {
143+
planner.setRoot(input);
144+
RelNode withCorrelates = planner.findBestExp();
145+
// RexSubQuery removal introduces LogicalCorrelate; decorrelate back to a
146+
// straight join shape that the marking + capability rules already handle.
147+
return RelDecorrelator.decorrelateQuery(withCorrelates, RelBuilder.proto(Contexts.empty()).create(input.getCluster(), null));
148+
} finally {
149+
if (listener != null) listener.endPhase("subquery-remove");
150+
}
151+
}
152+
115153
/**
116154
* Phase 1a: constant-expression reduction on Filter and Project predicates. Kept in
117155
* its own phase so {@code ProjectReduceExpressionsRule} cannot use a downstream

sandbox/plugins/analytics-engine/src/test/java/org/opensearch/analytics/planner/RuleProfilingListenerTests.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ public class RuleProfilingListenerTests extends BasePlannerRulesTests {
3434
private static final Logger LOGGER = LogManager.getLogger(RuleProfilingListenerTests.class);
3535

3636
private static final List<String> EXPECTED_PHASES = List.of(
37+
"subquery-remove",
3738
"reduce-expressions",
3839
"pushdown-rules",
3940
"aggregate-decompose",
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
/*
2+
* SPDX-License-Identifier: Apache-2.0
3+
*
4+
* The OpenSearch Contributors require contributions made to
5+
* this file be licensed under the Apache-2.0 license or a
6+
* compatible open source license.
7+
*/
8+
9+
package org.opensearch.analytics.planner;
10+
11+
import org.apache.calcite.plan.RelOptUtil;
12+
import org.apache.calcite.rel.RelHomogeneousShuttle;
13+
import org.apache.calcite.rel.RelNode;
14+
import org.apache.calcite.rel.RelShuttle;
15+
import org.apache.calcite.rex.RexShuttle;
16+
import org.apache.calcite.rex.RexSubQuery;
17+
import org.opensearch.cluster.ClusterState;
18+
19+
/**
20+
* Plan-shape coverage for the {@code subquery-remove} phase introduced in
21+
* {@link PlannerImpl#runAllOptimizations}. The phase lowers {@link RexSubQuery}s
22+
* (EXISTS / IN / SOME / ANY) into {@code LogicalCorrelate} via Calcite's three
23+
* {@code *_SUB_QUERY_TO_CORRELATE} rules, then decorrelates the result into a
24+
* straight join. Without it, downstream rules (e.g. {@code OpenSearchFilterRule}
25+
* which resolves leaf predicates through a {@code ScalarFunction} table that
26+
* doesn't include {@code EXISTS}) reject the plan.
27+
*
28+
* <p>Tests parse real SQL through {@link SqlPlannerTestFixture}, run the full
29+
* planner pipeline, and assert that no {@code RexSubQuery} remains anywhere in
30+
* the optimized tree — the strongest invariant the phase guarantees.
31+
*/
32+
public class SubQueryPlanShapeTests extends PlanShapeTestBase {
33+
34+
/** Uncorrelated EXISTS: {@code SELECT * FROM test_index WHERE EXISTS (SELECT 1 FROM test_index)}. */
35+
public void testUncorrelatedExistsSubqueryIsLowered() {
36+
ClusterState parserState = SqlPlannerTestFixture.clusterStateWith("test_index", intFields());
37+
RelNode parsed = SqlPlannerTestFixture.parseSql("SELECT * FROM test_index WHERE EXISTS (SELECT 1 FROM test_index)", parserState);
38+
assertContainsSubQuery("Pre-condition: parsed plan must carry the EXISTS RexSubQuery", parsed);
39+
40+
RelNode result = runPlanner(parsed, singleShardContext());
41+
42+
assertNoSubQuery(
43+
"subquery-remove phase must lower every RexSubQuery (EXISTS / IN / SOME / ANY) before"
44+
+ " marking — otherwise OpenSearchFilterRule rejects the operator at runtime with"
45+
+ " 'Unrecognized filter operator [EXISTS / EXISTS]'.",
46+
result
47+
);
48+
}
49+
50+
/** Correlated EXISTS: subquery references an outer-row column. */
51+
public void testCorrelatedExistsSubqueryIsLowered() {
52+
ClusterState parserState = SqlPlannerTestFixture.clusterStateWith("test_index", intFields());
53+
RelNode parsed = SqlPlannerTestFixture.parseSql(
54+
"SELECT * FROM test_index t WHERE EXISTS" + " (SELECT 1 FROM test_index s WHERE s.status = t.status)",
55+
parserState
56+
);
57+
assertContainsSubQuery("Pre-condition: parsed plan must carry the EXISTS RexSubQuery", parsed);
58+
59+
RelNode result = runPlanner(parsed, singleShardContext());
60+
61+
assertNoSubQuery(
62+
"subquery-remove + decorrelate must convert a correlated EXISTS into a standard join"
63+
+ " (no leftover RexSubQuery, no leftover LogicalCorrelate).",
64+
result
65+
);
66+
}
67+
68+
/** Uncorrelated IN-list: lowered through {@code FILTER_SUB_QUERY_TO_CORRELATE} too. */
69+
public void testInSubqueryIsLowered() {
70+
ClusterState parserState = SqlPlannerTestFixture.clusterStateWith("test_index", intFields());
71+
RelNode parsed = SqlPlannerTestFixture.parseSql(
72+
"SELECT * FROM test_index WHERE status IN (SELECT status FROM test_index)",
73+
parserState
74+
);
75+
assertContainsSubQuery("Pre-condition: parsed plan must carry the IN RexSubQuery", parsed);
76+
77+
RelNode result = runPlanner(parsed, singleShardContext());
78+
79+
assertNoSubQuery(
80+
"subquery-remove must cover IN subqueries in addition to EXISTS — both flow through"
81+
+ " the same CoreRules.FILTER_SUB_QUERY_TO_CORRELATE rewrite.",
82+
result
83+
);
84+
}
85+
86+
// ---- Helpers ----
87+
88+
private static void assertContainsSubQuery(String message, RelNode plan) {
89+
if (!findSubQuery(plan)) {
90+
throw new AssertionError(message + "\nPlan:\n" + RelOptUtil.toString(plan));
91+
}
92+
}
93+
94+
private static void assertNoSubQuery(String message, RelNode plan) {
95+
if (findSubQuery(plan)) {
96+
throw new AssertionError(message + "\nPost-optimize plan:\n" + RelOptUtil.toString(plan));
97+
}
98+
}
99+
100+
private static boolean findSubQuery(RelNode plan) {
101+
boolean[] found = { false };
102+
RexShuttle rexFinder = new RexShuttle() {
103+
@Override
104+
public org.apache.calcite.rex.RexNode visitSubQuery(RexSubQuery sub) {
105+
found[0] = true;
106+
return sub;
107+
}
108+
};
109+
RelShuttle relWalker = new RelHomogeneousShuttle() {
110+
@Override
111+
public RelNode visit(RelNode node) {
112+
if (found[0]) return node;
113+
node.accept(rexFinder);
114+
return found[0] ? node : super.visit(node);
115+
}
116+
};
117+
plan.accept(relWalker);
118+
return found[0];
119+
}
120+
}

sandbox/qa/analytics-engine-rest/src/test/java/org/opensearch/analytics/qa/StreamstatsCommandIT.java

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
package org.opensearch.analytics.qa;
1010

11+
import org.apache.lucene.tests.util.LuceneTestCase.AwaitsFix;
1112
import org.opensearch.client.Request;
1213
import org.opensearch.client.Response;
1314
import org.opensearch.client.ResponseException;
@@ -52,8 +53,12 @@
5253
* before substrait emission. The {@code bySpan} cases assert exact per-bucket rows.</li>
5354
* <li>{@code streamstats dc / distinct_count / earliest / latest / percentile / median / mode}
5455
* — not in {@link org.opensearch.analytics.spi.WindowFunction} enum.</li>
55-
* <li>{@code testLeftJoinWithStreamstats} / {@code testWhereInWithStreamstatsSubquery} — test
56-
* streamstats embedded in joins/subqueries, not exercised on the analytics-engine route.</li>
56+
* <li>{@code testLeftJoinWithStreamstats} — streamstats inside a left-join, not exercised on
57+
* the analytics-engine route yet.</li>
58+
* <li>{@code testWhereInWithStreamstatsSubquery} — reachable on the analytics-engine route via
59+
* PlannerImpl's subquery-remove phase, but the streamstats-inside-decorrelated-correlate
60+
* shape has a nondeterministic multi-node execution race today. Marked {@code @AwaitsFix}
61+
* until the downstream race is closed.</li>
5762
* </ul>
5863
*
5964
* <h2>Note on row order determinism</h2>
@@ -919,7 +924,18 @@ public void testLeftJoinWithStreamstats() throws IOException {
919924
}
920925

921926
/** sql IT: testWhereInWithStreamstatsSubquery. WHERE-IN with streamstats subquery — uses
922-
* semi-join lowering inside the subquery. */
927+
* semi-join lowering inside the subquery. After PlannerImpl's subquery-remove phase the
928+
* RexSubQuery becomes a decorrelated correlate, but the streamstats-inside-correlate
929+
* shape is nondeterministic on multi-node execution: sometimes errors with
930+
* {@code Stage 0 sink feed failed: partition stream receiver dropped before send},
931+
* sometimes returns one row successfully. Neither a positive nor a broad-failure
932+
* assertion is stable across runs. Skipped until the downstream multi-node race is
933+
* fixed — re-enable by removing {@code @AwaitsFix}. */
934+
@AwaitsFix(
935+
bugUrl = "streamstats-inside-decorrelated-correlate has a nondeterministic multi-node"
936+
+ " execution race; needs a downstream analytics-engine fix before this test can"
937+
+ " assert a deterministic outcome"
938+
)
923939
public void testWhereInWithStreamstatsSubquery() throws IOException {
924940
ensureDataProvisioned();
925941
assertErrorAny(

0 commit comments

Comments
 (0)