Skip to content

Commit 3d360bb

Browse files
committed
[analytics-engine] Insert shard-side Sort+Limit above PARTIAL aggregate
Post-CBO HEP rule that recognizes the OpenSearchSort(coll, fetch=N) ← Aggregate(FINAL) ← ER ← Aggregate(PARTIAL) shape and inserts a shard-side Sort+Limit above the PARTIAL aggregate with fetch = ceil(max(N, 10) * factor) + 10. Mirrors native OpenSearch's terms-aggregation shard_size pattern. The factor is read from index.analytics.shard_bucket_oversampling_factor per involved index; min across multiple tables. Factor 0 (or any single involved index at 0) disables the rewrite for that query β€” gather-all behavior preserved. Tests cover default factor, factor=0 disabled, factor=1.0 pure top-K, factor=3.0 aggressive oversampling, single-shard no-op, no-LIMIT no-op. Test fixture buildContextWithExplicitStorage and buildContextPerIndex gain a Settings.EMPTY default + per-index settings overrides hook so tests can vary the factor. Signed-off-by: Sandesh Kumar <sandeshkr419@gmail.com>
1 parent bcb2938 commit 3d360bb

5 files changed

Lines changed: 368 additions & 7 deletions

File tree

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

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
import org.opensearch.analytics.planner.rel.OpenSearchDistributionTraitDef;
3131
import org.opensearch.analytics.planner.rules.OpenSearchAggregateReduceRule;
3232
import org.opensearch.analytics.planner.rules.OpenSearchAggregateRule;
33+
import org.opensearch.analytics.planner.rules.OpenSearchAggregateShardBucketRule;
3334
import org.opensearch.analytics.planner.rules.OpenSearchAggregateSplitRule;
3435
import org.opensearch.analytics.planner.rules.OpenSearchDistributionDeriveRule;
3536
import org.opensearch.analytics.planner.rules.OpenSearchFilterRule;
@@ -103,6 +104,8 @@ public static RelNode runAllOptimizations(RelNode rawRelNode, PlannerContext con
103104
// AnnotatedPredicates under OR/NOT (Lucene call buys nothing in those positions).
104105
modifiedRelNode = cbo(modifiedRelNode, rawRelNode, context, listener);
105106
LOGGER.info("After CBO:\n{}", RelOptUtil.toString(modifiedRelNode));
107+
modifiedRelNode = shardBucketOversample(modifiedRelNode, context, listener);
108+
LOGGER.info("After shard-bucket oversampling:\n{}", RelOptUtil.toString(modifiedRelNode));
106109

107110
if (listener != null) {
108111
RuleProfilingListener.PlannerProfile profile = listener.snapshot();
@@ -269,4 +272,25 @@ private static RelNode cbo(RelNode marked, RelNode rawRelNode, PlannerContext co
269272
if (listener != null) listener.endPhase("cbo");
270273
}
271274
}
275+
276+
/**
277+
* Phase 3: shard-bucket oversampling β€” see
278+
* {@link OpenSearchAggregateShardBucketRule}.
279+
*/
280+
private static RelNode shardBucketOversample(RelNode input, PlannerContext context, RuleProfilingListener listener) {
281+
HepProgramBuilder builder = new HepProgramBuilder();
282+
builder.addMatchOrder(HepMatchOrder.TOP_DOWN);
283+
builder.addRuleInstance(new OpenSearchAggregateShardBucketRule(context));
284+
HepPlanner planner = new HepPlanner(builder.build());
285+
if (listener != null) {
286+
planner.addListener(listener);
287+
listener.beginPhase("shard-bucket-oversample");
288+
}
289+
try {
290+
planner.setRoot(input);
291+
return planner.findBestExp();
292+
} finally {
293+
if (listener != null) listener.endPhase("shard-bucket-oversample");
294+
}
295+
}
272296
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
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.rules;
10+
11+
import org.apache.calcite.plan.RelOptRule;
12+
import org.apache.calcite.plan.RelOptRuleCall;
13+
import org.apache.calcite.plan.RelTraitSet;
14+
import org.apache.calcite.rel.RelNode;
15+
import org.apache.calcite.rex.RexBuilder;
16+
import org.apache.calcite.rex.RexLiteral;
17+
import org.apache.calcite.rex.RexNode;
18+
import org.opensearch.analytics.AnalyticsPlugin;
19+
import org.opensearch.analytics.planner.PlannerContext;
20+
import org.opensearch.analytics.planner.RelNodeUtils;
21+
import org.opensearch.analytics.planner.rel.AggregateMode;
22+
import org.opensearch.analytics.planner.rel.OpenSearchAggregate;
23+
import org.opensearch.analytics.planner.rel.OpenSearchExchangeReducer;
24+
import org.opensearch.analytics.planner.rel.OpenSearchSort;
25+
import org.opensearch.analytics.planner.rel.OpenSearchTableScan;
26+
import org.opensearch.cluster.metadata.IndexMetadata;
27+
28+
import java.math.BigDecimal;
29+
30+
/**
31+
* Inserts a shard-side {@link OpenSearchSort} with bounded fetch above the PARTIAL
32+
* aggregate, mirroring native OpenSearch's terms-aggregation {@code shard_size}
33+
* pattern. Each shard ships {@code ceil(max(LIMIT, 10) * factor) + 10} buckets
34+
* instead of every group it saw.
35+
*
36+
* <p>Matches:
37+
* <pre>{@code
38+
* OpenSearchSort(coll, fetch=N) ← Aggregate(FINAL) ← ER ← Aggregate(PARTIAL)
39+
* }</pre>
40+
*
41+
* <p>Rewrites the PARTIAL side to:
42+
* <pre>{@code
43+
* ER ← OpenSearchSort(coll, fetch=shardSize) ← Aggregate(PARTIAL)
44+
* }</pre>
45+
*
46+
* <p>{@code factor} is the minimum of
47+
* {@code index.analytics.shard_bucket_oversampling_factor} across all tables
48+
* scanned under the PARTIAL aggregate. Any index with {@code factor == 0}
49+
* disables the rewrite for the whole query.
50+
*
51+
* <p>Approximate: a group missing from every shard's top-{@code shardSize}
52+
* cannot reach the coordinator.
53+
*
54+
* @opensearch.internal
55+
*/
56+
public class OpenSearchAggregateShardBucketRule extends RelOptRule {
57+
58+
private final PlannerContext context;
59+
60+
public OpenSearchAggregateShardBucketRule(PlannerContext context) {
61+
super(operand(OpenSearchSort.class, any()), "OpenSearchAggregateShardBucketRule");
62+
this.context = context;
63+
}
64+
65+
@Override
66+
public boolean matches(RelOptRuleCall call) {
67+
OpenSearchSort sort = call.rel(0);
68+
if (!(sort.fetch instanceof RexLiteral)) return false;
69+
if (sort.getCollation().getFieldCollations().isEmpty()) return false;
70+
return findShape(sort.getInput()) != null;
71+
}
72+
73+
@Override
74+
public void onMatch(RelOptRuleCall call) {
75+
OpenSearchSort outerSort = call.rel(0);
76+
Shape shape = findShape(outerSort.getInput());
77+
if (shape == null) return;
78+
79+
double factor = resolveFactor(shape.partial);
80+
if (factor == 0.0) return;
81+
82+
long coordLimit = RexLiteral.intValue((RexLiteral) outerSort.fetch);
83+
long shardSize = (long) Math.ceil(Math.max(coordLimit, 10L) * factor) + 10L;
84+
if (shardSize > Integer.MAX_VALUE) return;
85+
if (shardSize <= coordLimit) return; // defensive: would not bound below the user's request
86+
87+
RexBuilder rexBuilder = outerSort.getCluster().getRexBuilder();
88+
RexNode shardFetch = rexBuilder.makeExactLiteral(BigDecimal.valueOf(shardSize), outerSort.fetch.getType());
89+
90+
OpenSearchAggregate partial = shape.partial;
91+
RelTraitSet shardSortTraits = partial.getTraitSet().plus(outerSort.getCollation());
92+
OpenSearchSort shardSort = new OpenSearchSort(
93+
outerSort.getCluster(),
94+
shardSortTraits,
95+
partial,
96+
outerSort.getCollation(),
97+
null,
98+
shardFetch,
99+
partial.getViableBackends()
100+
);
101+
102+
RelNode newEr = shape.er.copy(shape.er.getTraitSet(), java.util.List.of(shardSort));
103+
RelNode newFinal = shape.finalAgg.copy(shape.finalAgg.getTraitSet(), java.util.List.of(newEr));
104+
RelNode newOuter = outerSort.copy(outerSort.getTraitSet(), newFinal, outerSort.getCollation(), outerSort.offset, outerSort.fetch);
105+
call.transformTo(newOuter);
106+
}
107+
108+
/** Pattern-match the FINAL ← ER ← PARTIAL chain under a Sort. */
109+
private static Shape findShape(RelNode input) {
110+
RelNode finalAggNode = RelNodeUtils.unwrapHep(input);
111+
if (!(finalAggNode instanceof OpenSearchAggregate finalAgg) || finalAgg.getMode() != AggregateMode.FINAL) {
112+
return null;
113+
}
114+
RelNode erNode = RelNodeUtils.unwrapHep(finalAgg.getInput());
115+
if (!(erNode instanceof OpenSearchExchangeReducer er)) {
116+
return null;
117+
}
118+
RelNode partialNode = RelNodeUtils.unwrapHep(er.getInputs().get(0));
119+
if (!(partialNode instanceof OpenSearchAggregate partial) || partial.getMode() != AggregateMode.PARTIAL) {
120+
return null;
121+
}
122+
return new Shape(finalAgg, er, partial);
123+
}
124+
125+
/**
126+
* Walks the PARTIAL aggregate's subtree, finds every {@link OpenSearchTableScan},
127+
* returns {@code min} of their {@code index.analytics.shard_bucket_oversampling_factor}
128+
* settings. Returns {@code 0.0} if any involved index has the setting disabled.
129+
*/
130+
private double resolveFactor(RelNode subtree) {
131+
double[] state = { Double.MAX_VALUE };
132+
boolean[] any = { false };
133+
collectFactor(subtree, state, any);
134+
return any[0] ? state[0] : 0.0;
135+
}
136+
137+
private void collectFactor(RelNode node, double[] state, boolean[] any) {
138+
RelNode current = RelNodeUtils.unwrapHep(node);
139+
if (current instanceof OpenSearchTableScan scan) {
140+
double factor = readFactor(scan);
141+
if (factor < state[0]) state[0] = factor;
142+
any[0] = true;
143+
return;
144+
}
145+
for (RelNode input : current.getInputs()) {
146+
collectFactor(input, state, any);
147+
}
148+
}
149+
150+
private double readFactor(OpenSearchTableScan scan) {
151+
String tableName = scan.getTable().getQualifiedName().getLast();
152+
IndexMetadata indexMetadata = context.getClusterState().metadata().index(tableName);
153+
// OpenSearchTableScanRule rejects unknown indices before this rule fires.
154+
assert indexMetadata != null : "IndexMetadata missing for [" + tableName + "]";
155+
assert indexMetadata.getSettings() != null : "IndexMetadata.getSettings() null for [" + tableName + "]";
156+
return AnalyticsPlugin.INDEX_ANALYTICS_SHARD_BUCKET_OVERSAMPLING_FACTOR.get(indexMetadata.getSettings());
157+
}
158+
159+
private record Shape(OpenSearchAggregate finalAgg, OpenSearchExchangeReducer er, OpenSearchAggregate partial) {
160+
}
161+
}
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
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.rel.RelCollations;
12+
import org.apache.calcite.rel.RelFieldCollation;
13+
import org.apache.calcite.rel.RelNode;
14+
import org.apache.calcite.sql.type.SqlTypeName;
15+
import org.opensearch.common.settings.Settings;
16+
17+
import java.util.List;
18+
import java.util.Map;
19+
20+
/**
21+
* Plan-shape tests for {@link org.opensearch.analytics.planner.rules.OpenSearchAggregateShardBucketRule}.
22+
*
23+
* <p>Verifies the post-CBO insertion of a shard-side Sort+Limit above the PARTIAL
24+
* aggregate when {@code index.analytics.shard_bucket_oversampling_factor > 0}
25+
* for all involved indices. {@code shardSize = ceil(max(LIMIT, 10) * factor) + 10}.
26+
*/
27+
public class AggregateShardBucketTests extends PlanShapeTestBase {
28+
29+
/**
30+
* Default factor 1.5 with multi-shard {@code GROUP BY k ORDER BY COUNT(*) LIMIT 100}.
31+
* Inserts a shard-side Sort with {@code fetch = ceil(100 * 1.5) + 10 = 160}.
32+
*/
33+
public void testDefaultFactor_multiShard() {
34+
RelNode plan = makeSortLimitOverGroupByCount(/* limit */ 100);
35+
RelNode result = runPlanner(plan, multiShardContext());
36+
assertPlanShape(
37+
"""
38+
OpenSearchSort(sort0=[$1], dir0=[ASC], fetch=[100], viableBackends=[[mock-parquet]])
39+
OpenSearchAggregate(group=[{0}], cnt=[COUNT()], mode=[FINAL], viableBackends=[[mock-parquet]])
40+
OpenSearchExchangeReducer(viableBackends=[[mock-parquet]], exchange=[ExchangeInfo[distributionType=SINGLETON, partitionKeyIndices=[]]])
41+
OpenSearchSort(sort0=[$1], dir0=[ASC], fetch=[160], viableBackends=[[mock-parquet]])
42+
OpenSearchAggregate(group=[{0}], cnt=[COUNT()], mode=[PARTIAL], viableBackends=[[mock-parquet]])
43+
OpenSearchTableScan(table=[[test_index]], viableBackends=[[mock-parquet]])
44+
""",
45+
result
46+
);
47+
}
48+
49+
/** Factor 0 disables the optimization β€” pre-existing FINAL+ER+PARTIAL shape, no shard Sort. */
50+
public void testFactorZero_disabled() {
51+
RelNode plan = makeSortLimitOverGroupByCount(100);
52+
RelNode result = runPlanner(plan, multiShardContextWithFactor(0.0));
53+
assertPlanShape(
54+
"""
55+
OpenSearchSort(sort0=[$1], dir0=[ASC], fetch=[100], viableBackends=[[mock-parquet]])
56+
OpenSearchAggregate(group=[{0}], cnt=[COUNT()], mode=[FINAL], viableBackends=[[mock-parquet]])
57+
OpenSearchExchangeReducer(viableBackends=[[mock-parquet]], exchange=[ExchangeInfo[distributionType=SINGLETON, partitionKeyIndices=[]]])
58+
OpenSearchAggregate(group=[{0}], cnt=[COUNT()], mode=[PARTIAL], viableBackends=[[mock-parquet]])
59+
OpenSearchTableScan(table=[[test_index]], viableBackends=[[mock-parquet]])
60+
""",
61+
result
62+
);
63+
}
64+
65+
/** Factor 1.0 β€” no oversampling buffer; {@code shardSize = max(LIMIT, 10) * 1.0 + 10 = 110}. */
66+
public void testFactorOne_pureTopK() {
67+
RelNode plan = makeSortLimitOverGroupByCount(100);
68+
RelNode result = runPlanner(plan, multiShardContextWithFactor(1.0));
69+
assertPlanShape(
70+
"""
71+
OpenSearchSort(sort0=[$1], dir0=[ASC], fetch=[100], viableBackends=[[mock-parquet]])
72+
OpenSearchAggregate(group=[{0}], cnt=[COUNT()], mode=[FINAL], viableBackends=[[mock-parquet]])
73+
OpenSearchExchangeReducer(viableBackends=[[mock-parquet]], exchange=[ExchangeInfo[distributionType=SINGLETON, partitionKeyIndices=[]]])
74+
OpenSearchSort(sort0=[$1], dir0=[ASC], fetch=[110], viableBackends=[[mock-parquet]])
75+
OpenSearchAggregate(group=[{0}], cnt=[COUNT()], mode=[PARTIAL], viableBackends=[[mock-parquet]])
76+
OpenSearchTableScan(table=[[test_index]], viableBackends=[[mock-parquet]])
77+
""",
78+
result
79+
);
80+
}
81+
82+
/** Factor 3.0 β€” aggressive oversampling; {@code shardSize = max(100, 10) * 3.0 + 10 = 310}. */
83+
public void testFactorThree() {
84+
RelNode plan = makeSortLimitOverGroupByCount(100);
85+
RelNode result = runPlanner(plan, multiShardContextWithFactor(3.0));
86+
assertPlanShape(
87+
"""
88+
OpenSearchSort(sort0=[$1], dir0=[ASC], fetch=[100], viableBackends=[[mock-parquet]])
89+
OpenSearchAggregate(group=[{0}], cnt=[COUNT()], mode=[FINAL], viableBackends=[[mock-parquet]])
90+
OpenSearchExchangeReducer(viableBackends=[[mock-parquet]], exchange=[ExchangeInfo[distributionType=SINGLETON, partitionKeyIndices=[]]])
91+
OpenSearchSort(sort0=[$1], dir0=[ASC], fetch=[310], viableBackends=[[mock-parquet]])
92+
OpenSearchAggregate(group=[{0}], cnt=[COUNT()], mode=[PARTIAL], viableBackends=[[mock-parquet]])
93+
OpenSearchTableScan(table=[[test_index]], viableBackends=[[mock-parquet]])
94+
""",
95+
result
96+
);
97+
}
98+
99+
/** Single-shard: aggregate is SINGLE (no FINAL/PARTIAL split), rule pattern doesn't match. */
100+
public void testSingleShard_noRewrite() {
101+
RelNode plan = makeSortLimitOverGroupByCount(100);
102+
RelNode result = runPlanner(plan, singleShardContext());
103+
assertPlanShape("""
104+
OpenSearchSort(sort0=[$1], dir0=[ASC], fetch=[100], viableBackends=[[mock-parquet]])
105+
OpenSearchAggregate(group=[{0}], cnt=[COUNT()], mode=[SINGLE], viableBackends=[[mock-parquet]])
106+
OpenSearchTableScan(table=[[test_index]], viableBackends=[[mock-parquet]])
107+
""", result);
108+
}
109+
110+
/** No LIMIT in the query: rule skips (no coord-required size to derive shardSize from). */
111+
public void testNoLimit_noRewrite() {
112+
RelNode agg = makeAggregate(stubScan(mockTable("test_index", "status", "size")), countStarCall());
113+
RelNode plan = org.apache.calcite.rel.logical.LogicalSort.create(
114+
agg,
115+
RelCollations.of(new RelFieldCollation(1, RelFieldCollation.Direction.ASCENDING)),
116+
null,
117+
null
118+
);
119+
RelNode result = runPlanner(plan, multiShardContext());
120+
assertPlanShape(
121+
"""
122+
OpenSearchSort(sort0=[$1], dir0=[ASC], viableBackends=[[mock-parquet]])
123+
OpenSearchAggregate(group=[{0}], cnt=[COUNT()], mode=[FINAL], viableBackends=[[mock-parquet]])
124+
OpenSearchExchangeReducer(viableBackends=[[mock-parquet]], exchange=[ExchangeInfo[distributionType=SINGLETON, partitionKeyIndices=[]]])
125+
OpenSearchAggregate(group=[{0}], cnt=[COUNT()], mode=[PARTIAL], viableBackends=[[mock-parquet]])
126+
OpenSearchTableScan(table=[[test_index]], viableBackends=[[mock-parquet]])
127+
""",
128+
result
129+
);
130+
}
131+
132+
/**
133+
* Sort+Limit over GROUP BY shape: ORDER BY agg ASC LIMIT N. Field 0 = group key,
134+
* field 1 = COUNT(*).
135+
*/
136+
private RelNode makeSortLimitOverGroupByCount(int limit) {
137+
RelNode agg = makeAggregate(stubScan(mockTable("test_index", "status", "size")), countStarCall());
138+
return org.apache.calcite.rel.logical.LogicalSort.create(
139+
agg,
140+
RelCollations.of(new RelFieldCollation(1, RelFieldCollation.Direction.ASCENDING)),
141+
null,
142+
rexBuilder.makeLiteral(limit, typeFactory.createSqlType(SqlTypeName.INTEGER), true)
143+
);
144+
}
145+
146+
/** Helper: build a multi-shard context with a custom oversampling factor on test_index. */
147+
PlannerContext multiShardContextWithFactor(double factor) {
148+
return buildContextPerIndex(
149+
"parquet",
150+
Map.of("test_index", 2),
151+
Map.of("test_index", Settings.builder().put("index.analytics.shard_bucket_oversampling_factor", factor).build()),
152+
intFields(),
153+
List.of(DATAFUSION, LUCENE)
154+
);
155+
}
156+
}

0 commit comments

Comments
Β (0)