Skip to content

Commit 80bddcb

Browse files
authored
[Analytics-Engine] Fix multi-shard aggregate split for non-prefix group keys (#22065)
Signed-off-by: Marc Handalian <marc.handalian@gmail.com>
1 parent 2fec590 commit 80bddcb

79 files changed

Lines changed: 1592 additions & 6 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

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

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,15 @@ public OpenSearchAggregate(
120120
Map<Integer, List<RexLiteral>> finalExtraLiteralArgs,
121121
List<IntermediateField> perCallIntermediateField
122122
) {
123-
super(cluster, traitSet, List.of(), input, groupSet, groupSets, aggCalls);
123+
super(
124+
cluster,
125+
traitSet,
126+
List.of(),
127+
input,
128+
frontGroupSetForFinal(groupSet, mode),
129+
frontGroupSetsForFinal(groupSet, groupSets, mode),
130+
aggCalls
131+
);
124132
this.mode = mode;
125133
this.viableBackends = viableBackends;
126134
this.callAnnotations = Map.copyOf(callAnnotations);
@@ -129,6 +137,30 @@ public OpenSearchAggregate(
129137
this.perCallIntermediateField = Collections.unmodifiableList(new ArrayList<>(perCallIntermediateField));
130138
}
131139

140+
/**
141+
* FINAL reads PARTIAL's output, where Calcite has fronted the group keys to {@code 0..n-1}; group
142+
* on the prefix range so a non-prefix key (e.g. {@code avg(x) by span(y,5)} → {@code {1}}) isn't
143+
* read as an agg-state column. No-op for SINGLE/PARTIAL (raw input) and already-fronted sets.
144+
*/
145+
private static ImmutableBitSet frontGroupSetForFinal(ImmutableBitSet groupSet, AggregateMode mode) {
146+
return mode == AggregateMode.FINAL ? ImmutableBitSet.range(groupSet.cardinality()) : groupSet;
147+
}
148+
149+
/**
150+
* Keeps {@code groupSets} consistent with the fronted {@code groupSet}. PPL only emits simple
151+
* (single-set) aggregates, so this collapses to one set; revisit if GROUPING SETS is ever added.
152+
*/
153+
private static List<ImmutableBitSet> frontGroupSetsForFinal(
154+
ImmutableBitSet groupSet,
155+
List<ImmutableBitSet> groupSets,
156+
AggregateMode mode
157+
) {
158+
if (mode != AggregateMode.FINAL || groupSets == null || groupSets.isEmpty()) {
159+
return groupSets;
160+
}
161+
return List.of(ImmutableBitSet.range(groupSet.cardinality()));
162+
}
163+
132164
/** Builds a FINAL aggregate post-rewrite; clears both stashes so a later {@code copy()} can't replay them. */
133165
public static OpenSearchAggregate finalAfterRewrite(OpenSearchAggregate prior, RelNode newInput, List<AggregateCall> rebuiltCalls) {
134166
return new OpenSearchAggregate(

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

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,13 @@
99
package org.opensearch.analytics.planner;
1010

1111
import org.apache.calcite.rel.RelNode;
12+
import org.apache.calcite.rel.core.AggregateCall;
13+
import org.apache.calcite.sql.fun.SqlStdOperatorTable;
14+
import org.apache.calcite.sql.type.SqlTypeName;
1215
import org.apache.calcite.util.ImmutableBitSet;
1316

17+
import java.util.List;
18+
1419
/**
1520
* Plan-shape tests for {@link org.opensearch.analytics.planner.rel.OpenSearchAggregate}.
1621
*
@@ -20,6 +25,49 @@
2025
*/
2126
public class AggregatePlanShapeTests extends PlanShapeTestBase {
2227

28+
// ---- Non-prefix groupSet: agg arg projected BEFORE the group key (the `avg(x) by span(y,5)` ----
29+
// ---- shape). PARTIAL fronts the key per Calcite's contract, so FINAL must group on the prefix ----
30+
// ---- range {0}, NOT the original {1}. Regression guard for the multi-shard span bug where FINAL ----
31+
// ---- grouped on the partial-state column and produced spurious un-coalesced buckets. ----
32+
33+
/** Builds sum(col0) grouped by col1 → non-prefix groupSet {1}. */
34+
private RelNode nonPrefixGroupedSum() {
35+
RelNode scan = stubScan(mockTable("test_index", "status", "size"));
36+
AggregateCall sum = AggregateCall.create(
37+
SqlStdOperatorTable.SUM,
38+
false,
39+
List.of(0),
40+
-1,
41+
scan,
42+
typeFactory.createSqlType(SqlTypeName.INTEGER),
43+
"total_status"
44+
);
45+
return makeAggregate(scan, ImmutableBitSet.of(1), sum);
46+
}
47+
48+
public void testNonPrefixGroupSet_1shard() {
49+
RelNode result = runPlanner(nonPrefixGroupedSum(), singleShardContext());
50+
assertPlanShape("""
51+
OpenSearchAggregate(group=[{1}], total_status=[SUM($0)], mode=[SINGLE], viableBackends=[[mock-parquet]])
52+
OpenSearchTableScan(table=[[test_index]], viableBackends=[[mock-parquet]])
53+
""", result);
54+
}
55+
56+
public void testNonPrefixGroupSet_2shard() {
57+
// FINAL groups on {0} (key fronted by PARTIAL), NOT the original {1}. Before the fix FINAL
58+
// carried {1} and grouped on the SUM column instead of the key.
59+
RelNode result = runPlanner(nonPrefixGroupedSum(), multiShardContext());
60+
assertPlanShape(
61+
"""
62+
OpenSearchAggregate(group=[{0}], total_status=[SUM($1)], mode=[FINAL], viableBackends=[[mock-parquet]])
63+
OpenSearchExchangeReducer(viableBackends=[[mock-parquet]], exchange=[ExchangeInfo[distributionType=SINGLETON, partitionKeyIndices=[]]])
64+
OpenSearchAggregate(group=[{1}], total_status=[SUM($0)], mode=[PARTIAL], viableBackends=[[mock-parquet]])
65+
OpenSearchTableScan(table=[[test_index]], viableBackends=[[mock-parquet]])
66+
""",
67+
result
68+
);
69+
}
70+
2371
public void testStatsCountStarByKey_1shard() {
2472
RelNode scan = stubScan(mockTable("test_index", "status", "size"));
2573
RelNode plan = makeAggregate(scan, countStarCall(scan));

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -327,7 +327,7 @@ public void testJoinWithDifferentGroupKeys_multiShard() {
327327
OpenSearchExchangeReducer(viableBackends=[[mock-parquet]], exchange=[ExchangeInfo[distributionType=SINGLETON, partitionKeyIndices=[]]])
328328
OpenSearchAggregate(group=[{0}], s=[SUM($1)], mode=[PARTIAL], viableBackends=[[mock-parquet]])
329329
OpenSearchTableScan(table=[[test_index]], viableBackends=[[mock-parquet]])
330-
OpenSearchAggregate(group=[{1}], s=[SUM($1)], mode=[FINAL], viableBackends=[[mock-parquet]])
330+
OpenSearchAggregate(group=[{0}], s=[SUM($1)], mode=[FINAL], viableBackends=[[mock-parquet]])
331331
OpenSearchExchangeReducer(viableBackends=[[mock-parquet]], exchange=[ExchangeInfo[distributionType=SINGLETON, partitionKeyIndices=[]]])
332332
OpenSearchAggregate(group=[{1}], s=[SUM($0)], mode=[PARTIAL], viableBackends=[[mock-parquet]])
333333
OpenSearchTableScan(table=[[test_index]], viableBackends=[[mock-parquet]])

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,16 @@
1616
* stddev/var, span, values/list) and approximate tier {@code approx/} (distinct_count/dc/percentile/
1717
* median). {@code distinct_count}/{@code dc} are rewritten to {@code APPROX_COUNT_DISTINCT} and
1818
* computed once at the coordinator, so per-shard distinct counts are never summed.
19+
*
20+
* <p>The {@code agg/span_*} set covers every aggregate over a {@code span(...)} group key — a
21+
* non-prefix groupSet that exercises the PARTIAL/FINAL key-fronting fix. A few of those
22+
* ({@code span_distinct_count_label}, {@code span_percentile_50}, {@code span_median}) use
23+
* approximate algorithms (HLL / percentile_approx) yet sit in the EXACT tier: on the fixed 30-row
24+
* {@code merge_coverage} dataset they are below the approximation threshold and return exact,
25+
* shard-invariant results, so the 1-shard==2-shard differential holds. If that dataset ever grows
26+
* past the approximation threshold these must move to the {@code approx/} tier — but note the
27+
* approx tier's tolerance aligner keys rows on non-numeric group cells, so a numeric {@code span}
28+
* key can't be aligned there without a harness change.
1929
*/
2030
public class TwoShardAggregationIT extends TwoShardReduceTestCase {
2131

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

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,6 @@ protected Map<String, String> knownIssues() {
3131
Map<String, String> m = new LinkedHashMap<>();
3232
m.put("cmd_search", "search numeric comparison lowers to a Lucene query_string that matches zero docs on numeric fields (wrong result)");
3333
m.put("cmd_appendcols", "not in the PPL grammar (SyntaxCheckException)");
34-
m.put("cmd_timechart", "requires an @timestamp default field");
3534
return m;
3635
}
3736
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
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.qa;
10+
11+
import org.opensearch.client.Request;
12+
13+
import java.io.IOException;
14+
import java.util.LinkedHashMap;
15+
import java.util.Map;
16+
17+
/**
18+
* Same exact {@code agg/} differential + golden suite as {@link TwoShardAggregationIT}, but with
19+
* {@code analytics.shard_bucket_oversampling_factor} turned on. Oversampling changes how many
20+
* candidate buckets each shard ships to the coordinator (and inserts the TopK shard-limit rewrite),
21+
* so this exercises that rewrite composing with the non-prefix-groupSet span aggregates (and with
22+
* the sort/top-N-over-span shapes). At this dataset scale oversampling does not change the final
23+
* exact result, so the 1-shard == 2-shard differential and the goldens must still hold — proving
24+
* the oversampling/TopK rewrite doesn't perturb exact aggregation over a fronted-key FINAL stage.
25+
*
26+
* <p>Only the exact {@code agg} tier is run — the {@code approx} tier is genuinely non-deterministic
27+
* under oversampling and is covered without oversampling by {@link TwoShardAggregationIT}.
28+
*/
29+
public class TwoShardOversamplingAggregationIT extends TwoShardReduceTestCase {
30+
31+
@Override
32+
protected Map<String, Boolean> tiers() {
33+
Map<String, Boolean> t = new LinkedHashMap<>();
34+
t.put("agg", false); // exact tier only — oversampling must be a no-op here
35+
return t;
36+
}
37+
38+
@Override
39+
protected void onBeforeQuery() throws IOException {
40+
super.onBeforeQuery();
41+
Request req = new Request("PUT", "/_cluster/settings");
42+
req.setJsonEntity("{\"persistent\":{\"analytics.shard_bucket_oversampling_factor\": 2.0}}");
43+
client().performRequest(req);
44+
}
45+
}

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

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -158,9 +158,17 @@ private String runDifferential(String dir, String name) {
158158
Map<String, Object> golden = loadGolden(queryDir + "/expected/" + name + ".json");
159159
if (golden != null) {
160160
normalizeArrayCells(golden);
161-
String pin = ResponseValidator.compareData(golden, r2, name + " [2-shard vs golden]");
162-
if (pin != null) {
163-
return pin;
161+
// Pin BOTH shard counts to the golden. The differential above already proves
162+
// r1 == r2, so 2-shard-vs-golden alone would catch a regression transitively — but
163+
// pinning 1-shard directly guards the baseline even if the differential is ever
164+
// relaxed, and a baseline-vs-coordinator regression surfaces on the right side.
165+
String pin1 = ResponseValidator.compareData(golden, r1, name + " [1-shard vs golden]");
166+
if (pin1 != null) {
167+
return pin1;
168+
}
169+
String pin2 = ResponseValidator.compareData(golden, r2, name + " [2-shard vs golden]");
170+
if (pin2 != null) {
171+
return pin2;
164172
}
165173
}
166174
return null;
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
{
2+
"rows": [
3+
[
4+
3.75,
5+
0
6+
],
7+
[
8+
10.5,
9+
5
10+
],
11+
[
12+
18.0,
13+
10
14+
],
15+
[
16+
25.5,
17+
15
18+
],
19+
[
20+
33.0,
21+
20
22+
],
23+
[
24+
40.5,
25+
25
26+
],
27+
[
28+
45.0,
29+
30
30+
]
31+
]
32+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
{
2+
"rows": [
3+
[
4+
4,
5+
0
6+
],
7+
[
8+
5,
9+
5
10+
],
11+
[
12+
5,
13+
10
14+
],
15+
[
16+
5,
17+
15
18+
],
19+
[
20+
5,
21+
20
22+
],
23+
[
24+
5,
25+
25
26+
],
27+
[
28+
1,
29+
30
30+
]
31+
]
32+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
{
2+
"rows": [
3+
[
4+
4,
5+
3.75,
6+
0
7+
],
8+
[
9+
5,
10+
10.5,
11+
5
12+
],
13+
[
14+
5,
15+
18.0,
16+
10
17+
],
18+
[
19+
5,
20+
25.5,
21+
15
22+
],
23+
[
24+
5,
25+
33.0,
26+
20
27+
],
28+
[
29+
5,
30+
40.5,
31+
25
32+
],
33+
[
34+
1,
35+
45.0,
36+
30
37+
]
38+
]
39+
}

0 commit comments

Comments
 (0)