Skip to content

Commit fb3daac

Browse files
[Enhancement] Support right-outer/semi/anti and full-outer range-colocate joins (#76040)
Signed-off-by: xiangguangyxg <xiangguangyxg@gmail.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent e434fe3 commit fb3daac

7 files changed

Lines changed: 283 additions & 40 deletions

File tree

fe/fe-core/src/main/java/com/starrocks/sql/optimizer/ChildOutputPropertyGuarantor.java

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -106,9 +106,15 @@ public boolean canColocateJoin(HashDistributionSpec leftLocalDistributionSpec,
106106
* {@code HINT_JOIN_SKEW}, or {@code HINT_JOIN_BUCKET} disables
107107
* colocate. Only {@code null} or {@code HINT_JOIN_COLOCATE} lets
108108
* the range fast path proceed.
109-
* <li>Join-type allowlist: INNER, LEFT OUTER, LEFT SEMI, LEFT ANTI.
110-
* Right-family and full-outer joins are rejected (NULL-key runtime
111-
* correctness validation deferred past P2).
109+
* <li>Join-type allowlist: INNER, LEFT/RIGHT OUTER, LEFT/RIGHT SEMI,
110+
* LEFT/RIGHT ANTI, FULL OUTER. All equi-join families are colocate-safe
111+
* because a colocate range join is bucket-local: rows sharing a colocate
112+
* key (NULL included — NULL sorts into the first ColocateRange on every
113+
* table in the group) always land in the same bucket, so the unmatched
114+
* right rows a right/full-outer join must emit are produced by the right
115+
* scan node in the same fragment. CROSS, NULL-aware anti, and ASOF joins
116+
* stay rejected (no covering equijoin key / cross-bucket NULL semantics /
117+
* inequality match).
112118
* <li>{@code disable_colocate_join} session kill switch (shared with
113119
* hash colocate).
114120
* <li>Structural {@link RangeDistributionSpec#canColocate}: same group,
@@ -139,10 +145,15 @@ private boolean canRangeColocateJoin(String hint,
139145
|| HintNode.HINT_JOIN_BUCKET.equals(hint)) {
140146
return false;
141147
}
142-
if (joinType != JoinOperator.INNER_JOIN
143-
&& joinType != JoinOperator.LEFT_OUTER_JOIN
144-
&& joinType != JoinOperator.LEFT_SEMI_JOIN
145-
&& joinType != JoinOperator.LEFT_ANTI_JOIN) {
148+
boolean supportedJoinType = joinType == JoinOperator.INNER_JOIN
149+
|| joinType == JoinOperator.LEFT_OUTER_JOIN
150+
|| joinType == JoinOperator.LEFT_SEMI_JOIN
151+
|| joinType == JoinOperator.LEFT_ANTI_JOIN
152+
|| joinType == JoinOperator.RIGHT_OUTER_JOIN
153+
|| joinType == JoinOperator.RIGHT_SEMI_JOIN
154+
|| joinType == JoinOperator.RIGHT_ANTI_JOIN
155+
|| joinType == JoinOperator.FULL_OUTER_JOIN;
156+
if (!supportedJoinType) {
146157
return false;
147158
}
148159
if (ConnectContext.get().getSessionVariable().isDisableColocateJoin()) {

fe/fe-core/src/main/java/com/starrocks/sql/optimizer/OutputPropertyDeriver.java

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -352,14 +352,27 @@ private PhysicalPropertySet visitPhysicalJoin(PhysicalJoinOperator node, Express
352352
List<DistributionCol> leftOnPredicateColumns = joinHelper.getLeftCols();
353353
List<DistributionCol> rightOnPredicateColumns = joinHelper.getRightCols();
354354

355-
// Range-colocate join: both children are RangeDistributionSpec.
356-
// ChildOutputPropertyGuarantor.canRangeColocateJoin already rejected
357-
// right-family / full-outer joins, so the join type here is safe.
355+
// Range-colocate join: both children are RangeDistributionSpec. The
356+
// dominated output side mirrors the hash-colocate mapping in
357+
// computeColocateJoinOutputProperty: right-family joins (right outer /
358+
// semi / anti) output the right-side range spec, full-outer outputs the
359+
// null-relaxed left-side spec (either side may be NULL-padded), and
360+
// everything else (inner / left outer / left semi / left anti) outputs
361+
// the left-side spec.
358362
DistributionSpec leftRawSpec = leftChildOutputProperty.getDistributionProperty().getSpec();
359363
DistributionSpec rightRawSpec = rightChildOutputProperty.getDistributionProperty().getSpec();
360364
if (leftRawSpec instanceof RangeDistributionSpec && rightRawSpec instanceof RangeDistributionSpec) {
361-
// Left-dominated output (inner / left-outer / left-semi / left-anti).
362-
PhysicalPropertySet outputProperty = createPropertySetByDistribution(leftRawSpec);
365+
JoinOperator joinType = node.getJoinType();
366+
DistributionSpec dominatedRangeSpec;
367+
if (joinType.isRightJoin()) {
368+
dominatedRangeSpec = rightRawSpec;
369+
} else if (joinType.isFullOuterJoin()) {
370+
RangeDistributionSpec leftRange = (RangeDistributionSpec) leftRawSpec;
371+
dominatedRangeSpec = leftRange.getNullRelaxSpec(leftRange.getEquivalentDescriptor());
372+
} else {
373+
dominatedRangeSpec = leftRawSpec;
374+
}
375+
PhysicalPropertySet outputProperty = createPropertySetByDistribution(dominatedRangeSpec);
363376
return updateEquivalentDescriptor(node, outputProperty,
364377
leftOnPredicateColumns, rightOnPredicateColumns);
365378
}

fe/fe-core/src/main/java/com/starrocks/sql/optimizer/base/DistributionProperty.java

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -104,8 +104,11 @@ public DistributionProperty getNullStrictProperty() {
104104
hashDistributionSpec.getEquivDesc()), isCTERequired);
105105
}
106106
}
107-
// RangeDistributionSpec always builds colocate columns with
108-
// nullStrict=true (see RangeDistributionSpec constructor); no conversion needed.
107+
// Non-hash specs (gather / broadcast / any / range) need no conversion. A
108+
// RangeDistributionSpec in particular is scan-local and never a required
109+
// distribution property (appendEnforcers rejects it, and this method only runs
110+
// on context.getRequiredProperty()), so a range spec never reaches here even
111+
// though a full-outer range-colocate join can produce a null-relaxed one.
109112
return this;
110113
}
111114

fe/fe-core/src/main/java/com/starrocks/sql/optimizer/base/RangeDistributionSpec.java

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -187,10 +187,10 @@ private boolean isSatisfyHashShuffle(HashDistributionSpec hashSpec) {
187187
}
188188

189189
/**
190-
* Null-relaxed variant for symmetry with {@code HashDistributionSpec}.
191-
* Not invoked by the P2 join planner because full-outer range colocate
192-
* is rejected by the join-type allowlist; retained for parallelism and
193-
* future phases.
190+
* Null-relaxed variant (colocate columns rebuilt with {@code nullStrict=false}),
191+
* mirroring {@code HashDistributionSpec.getNullRelaxSpec}. Used to derive the
192+
* output distribution of a full-outer range-colocate join, where either side
193+
* may be NULL-padded.
194194
*/
195195
public RangeDistributionSpec getNullRelaxSpec(EquivalentDescriptor descriptor) {
196196
List<DistributionCol> relaxed = colocateColumns.stream()

fe/fe-core/src/test/java/com/starrocks/planner/RangeColocateMixedJoinPlanTest.java

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,46 @@ public void t12_hashLocalRangeShuffleHint() throws Exception {
208208
"INNER JOIN (PARTITIONED)");
209209
}
210210

211+
// ---------- Range × range colocate: right-family / full-outer join types ----------
212+
213+
/** T13: range×range RIGHT OUTER on the colocate key must remain colocate. */
214+
@Test
215+
public void t13_rangeRightOuterRangeStillColocate() throws Exception {
216+
String fragmentPlan = plan("select count(*) from cd a right outer join cd2 b on a.k1 = b.k1");
217+
Assertions.assertTrue(fragmentPlan.contains("colocate: true"),
218+
() -> "range×range RIGHT OUTER on colocate key must remain colocate, plan was:\n" + fragmentPlan);
219+
}
220+
221+
/** T14: range×range FULL OUTER on the colocate key must remain colocate. */
222+
@Test
223+
public void t14_rangeFullOuterRangeStillColocate() throws Exception {
224+
String fragmentPlan = plan("select count(*) from cd a full outer join cd2 b on a.k1 = b.k1");
225+
Assertions.assertTrue(fragmentPlan.contains("colocate: true"),
226+
() -> "range×range FULL OUTER on colocate key must remain colocate, plan was:\n" + fragmentPlan);
227+
}
228+
229+
/** T15: [shuffle] hint defeats range×range RIGHT OUTER colocate. */
230+
@Test
231+
public void t15_rangeRightOuterRangeShuffleHint() throws Exception {
232+
String fragmentPlan = plan("select count(*) from cd a right outer join [shuffle] cd2 b on a.k1 = b.k1");
233+
Assertions.assertFalse(fragmentPlan.contains("colocate: true"),
234+
() -> "shuffle hint must defeat range×range RIGHT OUTER colocate, plan was:\n" + fragmentPlan);
235+
}
236+
237+
/** T16: disable_colocate_join defeats range×range FULL OUTER colocate. */
238+
@Test
239+
public void t16_rangeFullOuterRangeDisableColocate() throws Exception {
240+
Deencapsulation.setField(connectContext.getSessionVariable(), "disableColocateJoin", true);
241+
try {
242+
String fragmentPlan = plan("select count(*) from cd a full outer join cd2 b on a.k1 = b.k1");
243+
Assertions.assertFalse(fragmentPlan.contains("colocate: true"),
244+
() -> "disable_colocate_join must defeat range×range FULL OUTER colocate, plan was:\n"
245+
+ fragmentPlan);
246+
} finally {
247+
Deencapsulation.setField(connectContext.getSessionVariable(), "disableColocateJoin", false);
248+
}
249+
}
250+
211251
// ---------- Optimizer-level invariant ----------
212252

213253
/**

fe/fe-core/src/test/java/com/starrocks/sql/optimizer/ChildOutputPropertyGuarantorRangeTest.java

Lines changed: 102 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,9 @@
4040
*
4141
* <p>Covers:
4242
* <ul>
43-
* <li>Join-type allowlist (INNER, LEFT OUTER, LEFT SEMI, LEFT ANTI) —
44-
* RIGHT / FULL rejected.
43+
* <li>Join-type allowlist accepts all equi-join families (INNER, LEFT/RIGHT
44+
* OUTER, LEFT/RIGHT SEMI, LEFT/RIGHT ANTI, FULL OUTER); CROSS and NULL-aware
45+
* left anti stay rejected.
4546
* <li>Position-preserving pairing accepts identity join keys.
4647
* <li>Position-preserving pairing rejects swapped-column joins.
4748
* <li>Partial shuffle coverage rejected.
@@ -92,13 +93,81 @@ private static boolean invokeGate(ChildOutputPropertyGuarantor guarantor,
9293
hint, joinType, left, right, leftShuffle, rightShuffle);
9394
}
9495

96+
/**
97+
* Installs the "same group, stable, non-empty" expectations shared by every
98+
* accepts-* test: colocate is not session-disabled, both tables are in the
99+
* same stable colocate group. Group ids 100L/200L match {@link #buildSide}.
100+
*/
101+
private static void expectStableSameGroup(ConnectContext connectContext,
102+
SessionVariable sessionVariable,
103+
GlobalStateMgr globalStateMgr,
104+
ColocateTableIndex colocateTableIndex,
105+
ColocateTableIndex.GroupId groupId) {
106+
new Expectations() {
107+
{
108+
ConnectContext.get();
109+
result = connectContext;
110+
connectContext.getSessionVariable();
111+
result = sessionVariable;
112+
sessionVariable.isDisableColocateJoin();
113+
result = false;
114+
GlobalStateMgr.getCurrentState();
115+
result = globalStateMgr;
116+
globalStateMgr.getColocateTableIndex();
117+
result = colocateTableIndex;
118+
colocateTableIndex.isSameGroup(100L, 200L);
119+
result = true;
120+
colocateTableIndex.getGroup(100L);
121+
result = groupId;
122+
colocateTableIndex.getGroup(200L);
123+
result = groupId;
124+
colocateTableIndex.isGroupUnstable(groupId);
125+
result = false;
126+
}
127+
};
128+
}
129+
130+
/**
131+
* Asserts an identity-key join of {@code joinType} on a stable same-group
132+
* range-colocate pair is accepted (colocate = (1,2), shuffle = (1,2)).
133+
*/
134+
private void assertJoinTypeAccepted(TaskContext taskContext, ConnectContext connectContext,
135+
SessionVariable sessionVariable, GlobalStateMgr globalStateMgr,
136+
ColocateTableIndex colocateTableIndex, JoinOperator joinType) {
137+
ColocateTableIndex.GroupId groupId = new ColocateTableIndex.GroupId(1L, 1L);
138+
expectStableSameGroup(connectContext, sessionVariable, globalStateMgr, colocateTableIndex, groupId);
139+
assertTrue(invokeGate(newGuarantor(taskContext), joinType,
140+
buildSide(100L, 1, 2), buildSide(200L, 1, 2), cols(1, 2), cols(1, 2)));
141+
}
142+
95143
@Test
96-
void rejectsRightOuterJoin(@Mocked TaskContext taskContext) {
97-
// Join-type allowlist is the earliest gate; no ConnectContext access.
98-
RangeDistributionSpec left = buildSide(100L, 1, 2);
99-
RangeDistributionSpec right = buildSide(200L, 1, 2);
100-
assertFalse(invokeGate(newGuarantor(taskContext), JoinOperator.RIGHT_OUTER_JOIN,
101-
left, right, cols(1, 2), cols(1, 2)));
144+
void acceptsRightOuterJoin(@Mocked TaskContext taskContext,
145+
@Mocked ConnectContext connectContext,
146+
@Mocked SessionVariable sessionVariable,
147+
@Mocked GlobalStateMgr globalStateMgr,
148+
@Mocked ColocateTableIndex colocateTableIndex) {
149+
assertJoinTypeAccepted(taskContext, connectContext, sessionVariable, globalStateMgr,
150+
colocateTableIndex, JoinOperator.RIGHT_OUTER_JOIN);
151+
}
152+
153+
@Test
154+
void acceptsRightSemiJoin(@Mocked TaskContext taskContext,
155+
@Mocked ConnectContext connectContext,
156+
@Mocked SessionVariable sessionVariable,
157+
@Mocked GlobalStateMgr globalStateMgr,
158+
@Mocked ColocateTableIndex colocateTableIndex) {
159+
assertJoinTypeAccepted(taskContext, connectContext, sessionVariable, globalStateMgr,
160+
colocateTableIndex, JoinOperator.RIGHT_SEMI_JOIN);
161+
}
162+
163+
@Test
164+
void acceptsRightAntiJoin(@Mocked TaskContext taskContext,
165+
@Mocked ConnectContext connectContext,
166+
@Mocked SessionVariable sessionVariable,
167+
@Mocked GlobalStateMgr globalStateMgr,
168+
@Mocked ColocateTableIndex colocateTableIndex) {
169+
assertJoinTypeAccepted(taskContext, connectContext, sessionVariable, globalStateMgr,
170+
colocateTableIndex, JoinOperator.RIGHT_ANTI_JOIN);
102171
}
103172

104173
@Test
@@ -131,11 +200,33 @@ void rejectsBucketHint(@Mocked TaskContext taskContext) {
131200
}
132201

133202
@Test
134-
void rejectsFullOuterJoin(@Mocked TaskContext taskContext) {
135-
// Join-type allowlist is the earliest gate; no ConnectContext access.
203+
void acceptsFullOuterJoin(@Mocked TaskContext taskContext,
204+
@Mocked ConnectContext connectContext,
205+
@Mocked SessionVariable sessionVariable,
206+
@Mocked GlobalStateMgr globalStateMgr,
207+
@Mocked ColocateTableIndex colocateTableIndex) {
208+
assertJoinTypeAccepted(taskContext, connectContext, sessionVariable, globalStateMgr,
209+
colocateTableIndex, JoinOperator.FULL_OUTER_JOIN);
210+
}
211+
212+
@Test
213+
void rejectsCrossJoin(@Mocked TaskContext taskContext) {
214+
// CROSS join stays rejected by the allowlist (earliest gate after hint;
215+
// no ConnectContext access). It has no covering equijoin key anyway.
216+
RangeDistributionSpec left = buildSide(100L, 1, 2);
217+
RangeDistributionSpec right = buildSide(200L, 1, 2);
218+
assertFalse(invokeGate(newGuarantor(taskContext), JoinOperator.CROSS_JOIN,
219+
left, right, cols(1, 2), cols(1, 2)));
220+
}
221+
222+
@Test
223+
void rejectsNullAwareLeftAntiJoin(@Mocked TaskContext taskContext) {
224+
// NULL-aware left anti (NOT IN with nullable) needs cross-bucket NULL
225+
// knowledge, which bucket-local colocate execution cannot provide, so it
226+
// stays rejected by the allowlist.
136227
RangeDistributionSpec left = buildSide(100L, 1, 2);
137228
RangeDistributionSpec right = buildSide(200L, 1, 2);
138-
assertFalse(invokeGate(newGuarantor(taskContext), JoinOperator.FULL_OUTER_JOIN,
229+
assertFalse(invokeGate(newGuarantor(taskContext), JoinOperator.NULL_AWARE_LEFT_ANTI_JOIN,
139230
left, right, cols(1, 2), cols(1, 2)));
140231
}
141232

0 commit comments

Comments
 (0)