This is an automated email from the ASF dual-hosted git repository.

xiangfu0 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/pinot.git


The following commit(s) were added to refs/heads/master by this push:
     new bd488ae28d5 Fix GROUP BY DISTINCTCOUNT ClassCastException in 
mergeDataTablesOnly (#18842)
bd488ae28d5 is described below

commit bd488ae28d55ca8b16cef8c5b01905b78c7a1928
Author: Navina Ramesh <[email protected]>
AuthorDate: Tue Jul 7 01:16:33 2026 -0400

    Fix GROUP BY DISTINCTCOUNT ClassCastException in mergeDataTablesOnly 
(#18842)
    
    mergeDataTablesOnly reused getIndexedTable, which unconditionally called
    indexedTable.finish(true, true). For a GROUP BY query with an OBJECT-typed
    intermediate aggregate (DISTINCTCOUNT, DISTINCTCOUNTHLL, etc.), that
    storeFinalResult=true call:
    - mutated DataSchema._columnDataTypes from OBJECT to the final-result type
      in place (IndexedTable.java:170-174), and
    - replaced each row's value with extractFinalResult(value) (Set → Integer
      for DISTINCTCOUNT) at IndexedTable.java:195.
    
    The subsequent buildIntermediateDataTable read DataSchema's cached
    _storedColumnDataTypes — populated earlier from the pre-finalize OBJECT
    schema and never invalidated by finish — so the OBJECT branch fired and
    handed the now-Integer value into
    BaseDistinctAggregateAggregationFunction.serializeIntermediateResult(Set),
    which threw ClassCastException.
    
    Fix: move finish() out of getIndexedTable and let each caller pick the
    right mode. reduceResult keeps finish(true, true) (downstream consumers
    expect final scalars). mergeDataTablesOnly calls finish(true, false) so
    aggregate values stay as intermediates and round-trip through
    buildIntermediateDataTable correctly.
    
    Adds a testGroupByDistinctCountObjectRoundTrip regression: server emits
    intermediate OBJECT-encoded Sets, merge produces a re-injectable
    intermediate DataTable, and reducing that intermediate matches a direct
    reduce of the same servers.
---
 .../core/query/reduce/GroupByDataTableReducer.java | 19 +++++--
 .../core/query/reduce/MergeDataTablesOnlyTest.java | 61 ++++++++++++++++++++++
 2 files changed, 76 insertions(+), 4 deletions(-)

diff --git 
a/pinot-core/src/main/java/org/apache/pinot/core/query/reduce/GroupByDataTableReducer.java
 
b/pinot-core/src/main/java/org/apache/pinot/core/query/reduce/GroupByDataTableReducer.java
index 63413c2b996..bfbd8bb8486 100644
--- 
a/pinot-core/src/main/java/org/apache/pinot/core/query/reduce/GroupByDataTableReducer.java
+++ 
b/pinot-core/src/main/java/org/apache/pinot/core/query/reduce/GroupByDataTableReducer.java
@@ -133,11 +133,11 @@ public class GroupByDataTableReducer implements 
DataTableReducer {
     // When servers are configured to return final aggregate state, the input 
DataTables hold final
     // (not intermediate) values, so the merge-only contract — "produce an 
intermediate DataTable that
     // can be re-merged via the normal reduce path" — cannot be honored.
-    if (_queryContext.isServerReturnFinalResult()) {
+    if (_queryContext.isServerReturnFinalResult() || 
_queryContext.isServerReturnFinalResultKeyUnpartitioned()) {
       throw new UnsupportedOperationException(
           "Merge-only reduction is not supported when servers return final 
aggregate results "
-              + "(server.returnFinalResult / isServerReturnFinalResult); input 
would be final-typed, "
-              + "not intermediate.");
+              + "(serverReturnFinalResult / 
serverReturnFinalResultKeyUnpartitioned); input would be "
+              + "final-typed, not intermediate.");
     }
     dataSchema = 
ReducerDataSchemaUtils.canonicalizeDataSchemaForGroupBy(_queryContext, 
dataSchema);
     try {
@@ -147,6 +147,8 @@ public class GroupByDataTableReducer implements 
DataTableReducer {
       Collection<DataTable> dataTables = dataTableMap.values();
       // Reuse the regular reduce's merge: builds the IndexedTable of group 
keys + intermediate agg state.
       IndexedTable indexedTable = getIndexedTable(dataSchema, dataTables, 
reducerContext);
+      // Keep aggregate values as intermediates so the output can be re-merged 
through the regular reduce path.
+      indexedTable.finish(false, false);
       DataTable mergedDataTable = buildIntermediateDataTable(dataSchema, 
indexedTable);
       if (indexedTable.isTrimmed() && _queryContext.isUnsafeTrim()) {
         
mergedDataTable.getMetadata().put(MetadataKey.GROUPS_TRIMMED.getName(), "true");
@@ -175,6 +177,10 @@ public class GroupByDataTableReducer implements 
DataTableReducer {
       BrokerMetrics brokerMetrics) {
     // NOTE: This step will modify the data schema and also return final 
aggregate results.
     IndexedTable indexedTable = getIndexedTable(dataSchema, dataTables, 
reducerContext);
+    // Sort + finalize: mutate _dataSchema column types to final-result types 
and replace each row's value
+    // with extractFinalResult(...). Required by the regular reduce path: the 
downstream consumers
+    // (PostAggregationHandler, HavingFilterHandler, ResultTable 
serialization) expect final scalars.
+    indexedTable.finish(true, true);
     if (indexedTable.isTrimmed() && _queryContext.isUnsafeTrim()) {
       brokerResponseNative.setGroupsTrimmed(true);
     }
@@ -408,7 +414,12 @@ public class GroupByDataTableReducer implements 
DataTableReducer {
       }
     }
 
-    indexedTable.finish(true, true);
+    // NOTE: finish(...) is invoked by the caller, not here. The two callers 
want different semantics:
+    //   - reduceResult (regular reduce → ResultTable) needs finish(true, 
true) to extract final results.
+    //   - mergeDataTablesOnly (merge-only intermediate output) needs 
finish(true, false) so the aggregate
+    //     values stay as intermediates (e.g. Set for DISTINCTCOUNT, not 
Integer); otherwise the next
+    //     buildIntermediateDataTable's serializeIntermediateResult(...) call 
casts the final scalar back to
+    //     the intermediate type and throws ClassCastException.
     return indexedTable;
   }
 
diff --git 
a/pinot-core/src/test/java/org/apache/pinot/core/query/reduce/MergeDataTablesOnlyTest.java
 
b/pinot-core/src/test/java/org/apache/pinot/core/query/reduce/MergeDataTablesOnlyTest.java
index 7aea1fe90fb..f944b528576 100644
--- 
a/pinot-core/src/test/java/org/apache/pinot/core/query/reduce/MergeDataTablesOnlyTest.java
+++ 
b/pinot-core/src/test/java/org/apache/pinot/core/query/reduce/MergeDataTablesOnlyTest.java
@@ -118,6 +118,34 @@ public class MergeDataTablesOnlyTest {
     assertRoundTrip(query, serverTables);
   }
 
+  /// Verifies that a GROUP BY query selecting DISTINCTCOUNT keeps OBJECT 
aggregate state re-mergeable.
+  @Test
+  public void testGroupByDistinctCountObjectRoundTrip()
+      throws IOException {
+    String query = "SELECT col1, DISTINCTCOUNT(col2) FROM testTable GROUP BY 
col1";
+    BrokerRequest brokerRequest = 
CalciteSqlCompiler.compileToBrokerRequest(query);
+    BrokerResponseNative baseline = reduce(brokerRequest, 
toMap(buildDistinctCountServerTables(query)));
+    DataTable merged = merge(brokerRequest, 
toMap(buildDistinctCountServerTables(query)));
+    assertNotNull(merged, "merge produced null");
+    BrokerResponseNative viaMerge = reduce(brokerRequest, 
singletonMap(merged));
+    assertResultTablesEquivalent(baseline.getResultTable(), 
viaMerge.getResultTable());
+  }
+
+  /// Two per-server intermediate DataTables for selecting 
`DISTINCTCOUNT(col2)` grouped by `col1`.
+  private static List<DataTable> buildDistinctCountServerTables(String query)
+      throws IOException {
+    AggregationFunction aggFunction = aggFunctions(query)[0];
+    DataSchema schema = new DataSchema(new String[]{"col1", 
"distinctcount(col2)"},
+        new ColumnDataType[]{ColumnDataType.INT, ColumnDataType.OBJECT});
+    return List.of(
+        buildGroupByWithObject(schema, aggFunction,
+            new Object[]{1, new IntOpenHashSet(new int[]{1, 2, 3})},
+            new Object[]{2, new IntOpenHashSet(new int[]{4, 5})}),
+        buildGroupByWithObject(schema, aggFunction,
+            new Object[]{1, new IntOpenHashSet(new int[]{3, 4, 5})},
+            new Object[]{3, new IntOpenHashSet(new int[]{6, 7})}));
+  }
+
   @Test
   public void testDistinctRoundTrip() {
     String query = "SELECT DISTINCT col1 FROM testTable";
@@ -151,6 +179,18 @@ public class MergeDataTablesOnlyTest {
     assertThrows(UnsupportedOperationException.class, () -> 
merge(brokerRequest, map));
   }
 
+  @Test
+  public void testGroupByServerReturnFinalResultKeyUnpartitionedRejected() {
+    BrokerRequest brokerRequest = CalciteSqlCompiler.compileToBrokerRequest(
+        "SELECT col1, DISTINCTCOUNT(col2) FROM testTable GROUP BY col1");
+    brokerRequest.getPinotQuery()
+        
.putToQueryOptions(Broker.Request.QueryOptionKey.SERVER_RETURN_FINAL_RESULT_KEY_UNPARTITIONED,
 "true");
+    DataSchema schema = new DataSchema(new String[]{"col1", 
"distinctcount(col2)"},
+        new ColumnDataType[]{ColumnDataType.INT, ColumnDataType.INT});
+    Map<ServerRoutingInstance, DataTable> map = 
singletonMap(buildGroupBy(schema, new Object[][]{{1, 2}}));
+    assertThrows(UnsupportedOperationException.class, () -> 
merge(brokerRequest, map));
+  }
+
   @Test
   public void testGroupByLimitZeroRoundTrip() {
     // LIMIT 0 on group-by: mergeDataTablesOnly does NOT apply LIMIT 
(intermediate is unlimited),
@@ -489,6 +529,27 @@ public class MergeDataTablesOnlyTest {
     }
   }
 
+  /**
+   * Builds a GROUP BY DataTable with one INT key column followed by one 
OBJECT aggregate column whose
+   * value is the intermediate {@code Set} (or other aggregate state) the 
server would have emitted.
+   * Mirrors what {@code GroupByResultsBlock.getDataTable()} produces on the 
server side for the
+   * non-null-handling path: scalar key written directly, OBJECT column 
written via
+   * {@code serializeIntermediateResult}.
+   */
+  private static DataTable buildGroupByWithObject(DataSchema schema, 
AggregationFunction aggFunction,
+      Object[]... rows)
+      throws IOException {
+    DataTableBuilder builder = 
DataTableBuilderFactory.getDataTableBuilder(schema);
+    for (Object[] row : rows) {
+      builder.startRow();
+      // Single INT key column at index 0; OBJECT aggregate intermediate at 
index 1.
+      builder.setColumn(0, (int) row[0]);
+      builder.setColumn(1, aggFunction.serializeIntermediateResult(row[1]));
+      builder.finishRow();
+    }
+    return builder.build();
+  }
+
   private static DataTable buildObjectRow(DataSchema schema, 
AggregationFunction aggFunction, Object intermediate)
       throws IOException {
     DataTableBuilder builder = 
DataTableBuilderFactory.getDataTableBuilder(schema);


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to