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

Jackie-Jiang 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 739e6a90d78 Support multi-value columns in DISTINCTCOUNTULL and 
SEGMENTPARTITIONEDDISTINCTCOUNT (#19302)
739e6a90d78 is described below

commit 739e6a90d780cf10d58c5a2ef5a39ec4acdae917
Author: Xiaotian (Jackie) Jiang <[email protected]>
AuthorDate: Wed Aug 19 10:04:31 2026 -0700

    Support multi-value columns in DISTINCTCOUNTULL and 
SEGMENTPARTITIONEDDISTINCTCOUNT (#19302)
---
 .../DistinctCountBitmapAggregationFunction.java    |   6 +-
 .../DistinctCountULLAggregationFunction.java       | 269 ++++++++++++++++++-
 ...artitionedDistinctCountAggregationFunction.java | 286 ++++++++++++++++++++-
 .../function/SumPrecisionAggregationFunction.java  |  12 +-
 .../pinot/core/common/SyntheticBlockValSets.java   | 216 +++++++++++-----
 .../DistinctCountULLAggregationFunctionTest.java   | 110 +++++++-
 ...tionedDistinctCountAggregationFunctionTest.java | 120 +++++++++
 .../tests/custom/UuidAggregationTest.java          |  11 +-
 8 files changed, 937 insertions(+), 93 deletions(-)

diff --git 
a/pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/DistinctCountBitmapAggregationFunction.java
 
b/pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/DistinctCountBitmapAggregationFunction.java
index e71632673b2..dea8eb8695a 100644
--- 
a/pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/DistinctCountBitmapAggregationFunction.java
+++ 
b/pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/DistinctCountBitmapAggregationFunction.java
@@ -39,9 +39,9 @@ import org.roaringbitmap.PeekableIntIterator;
 import org.roaringbitmap.RoaringBitmap;
 
 
-/// The `DistinctCountBitmapAggregationFunction` calculates the number of 
distinct values for a given single-value
-/// expression using RoaringBitmap. The bitmap stores the actual values for 
`INT` expression, or hash code of the
-/// values for other data types (values with the same hash code will only be 
counted once).
+/// The `DistinctCountBitmapAggregationFunction` calculates the number of 
distinct values for a given single-value or
+/// multi-value expression using RoaringBitmap. The bitmap stores the actual 
values for `INT` expression, or hash code
+/// of the values for other data types (values with the same hash code will 
only be counted once).
 public class DistinctCountBitmapAggregationFunction extends 
BaseSingleInputAggregationFunction<RoaringBitmap, Integer> {
 
   public DistinctCountBitmapAggregationFunction(List<ExpressionContext> 
arguments) {
diff --git 
a/pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/DistinctCountULLAggregationFunction.java
 
b/pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/DistinctCountULLAggregationFunction.java
index 7ef3142ddb5..6289ac0bead 100644
--- 
a/pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/DistinctCountULLAggregationFunction.java
+++ 
b/pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/DistinctCountULLAggregationFunction.java
@@ -105,6 +105,15 @@ public class DistinctCountULLAggregationFunction extends 
BaseSingleInputAggregat
 
     DataType storedType = dataType.getStoredType();
 
+    if (blockValSet.isSingleValue()) {
+      aggregateSV(length, aggregationResultHolder, blockValSet, storedType);
+    } else {
+      aggregateMV(length, aggregationResultHolder, blockValSet, storedType);
+    }
+  }
+
+  protected void aggregateSV(int length, AggregationResultHolder 
aggregationResultHolder, BlockValSet blockValSet,
+      DataType storedType) {
     // For dictionary-encoded expression, store dictionary ids into the bitmap
     Dictionary dictionary = blockValSet.isDictionaryEncoded() ? 
blockValSet.getDictionary() : null;
     if (dictionary != null) {
@@ -158,6 +167,76 @@ public class DistinctCountULLAggregationFunction extends 
BaseSingleInputAggregat
     }
   }
 
+  protected void aggregateMV(int length, AggregationResultHolder 
aggregationResultHolder, BlockValSet blockValSet,
+      DataType storedType) {
+    // For dictionary-encoded expression, store dictionary ids into the bitmap
+    Dictionary dictionary = blockValSet.isDictionaryEncoded() ? 
blockValSet.getDictionary() : null;
+    if (dictionary != null) {
+      RoaringBitmap dictIdBitmap = getDictIdBitmap(aggregationResultHolder, 
dictionary);
+      int[][] dictIds = blockValSet.getDictionaryIdsMV();
+      for (int i = 0; i < length; i++) {
+        dictIdBitmap.add(dictIds[i]);
+      }
+      return;
+    }
+
+    // For non-dictionary-encoded expression, store values into the UltraLogLog
+    UltraLogLog ull = getULL(aggregationResultHolder);
+    switch (storedType) {
+      case INT:
+        int[][] intValues = blockValSet.getIntValuesMV();
+        for (int i = 0; i < length; i++) {
+          for (int value : intValues[i]) {
+            UltraLogLogUtils.hashObject(value).ifPresent(ull::add);
+          }
+        }
+        break;
+      case LONG:
+        long[][] longValues = blockValSet.getLongValuesMV();
+        for (int i = 0; i < length; i++) {
+          for (long value : longValues[i]) {
+            UltraLogLogUtils.hashObject(value).ifPresent(ull::add);
+          }
+        }
+        break;
+      case FLOAT:
+        float[][] floatValues = blockValSet.getFloatValuesMV();
+        for (int i = 0; i < length; i++) {
+          for (float value : floatValues[i]) {
+            UltraLogLogUtils.hashObject(value).ifPresent(ull::add);
+          }
+        }
+        break;
+      case DOUBLE:
+        double[][] doubleValues = blockValSet.getDoubleValuesMV();
+        for (int i = 0; i < length; i++) {
+          for (double value : doubleValues[i]) {
+            UltraLogLogUtils.hashObject(value).ifPresent(ull::add);
+          }
+        }
+        break;
+      case STRING:
+        String[][] stringValues = blockValSet.getStringValuesMV();
+        for (int i = 0; i < length; i++) {
+          for (String value : stringValues[i]) {
+            UltraLogLogUtils.hashObject(value).ifPresent(ull::add);
+          }
+        }
+        break;
+      case BYTES:
+        byte[][][] bytesValues = blockValSet.getBytesValuesMV();
+        for (int i = 0; i < length; i++) {
+          for (byte[] value : bytesValues[i]) {
+            UltraLogLogUtils.hashObject(value).ifPresent(ull::add);
+          }
+        }
+        break;
+      default:
+        throw new IllegalStateException(
+            "Illegal data type for DISTINCT_COUNT_ULL aggregation function: " 
+ storedType);
+    }
+  }
+
   @Override
   public void aggregateGroupBySV(int length, int[] groupKeyArray, 
GroupByResultHolder groupByResultHolder,
       Map<ExpressionContext, BlockValSet> blockValSetMap) {
@@ -186,6 +265,15 @@ public class DistinctCountULLAggregationFunction extends 
BaseSingleInputAggregat
 
     DataType storedType = dataType.getStoredType();
 
+    if (blockValSet.isSingleValue()) {
+      aggregateSVGroupBySV(length, groupKeyArray, groupByResultHolder, 
blockValSet, storedType);
+    } else {
+      aggregateMVGroupBySV(length, groupKeyArray, groupByResultHolder, 
blockValSet, storedType);
+    }
+  }
+
+  protected void aggregateSVGroupBySV(int length, int[] groupKeyArray, 
GroupByResultHolder groupByResultHolder,
+      BlockValSet blockValSet, DataType storedType) {
     // For dictionary-encoded expression, store dictionary ids into the bitmap
     Dictionary dictionary = blockValSet.isDictionaryEncoded() ? 
blockValSet.getDictionary() : null;
     if (dictionary != null) {
@@ -196,7 +284,7 @@ public class DistinctCountULLAggregationFunction extends 
BaseSingleInputAggregat
       return;
     }
 
-    // For non-dictionary-encoded expression, store values into the 
HyperLogLogPlus
+    // For non-dictionary-encoded expression, store values into the UltraLogLog
     switch (storedType) {
       case INT:
         int[] intValues = blockValSet.getIntValuesSV();
@@ -246,6 +334,80 @@ public class DistinctCountULLAggregationFunction extends 
BaseSingleInputAggregat
     }
   }
 
+  protected void aggregateMVGroupBySV(int length, int[] groupKeyArray, 
GroupByResultHolder groupByResultHolder,
+      BlockValSet blockValSet, DataType storedType) {
+    // For dictionary-encoded expression, store dictionary ids into the bitmap
+    Dictionary dictionary = blockValSet.isDictionaryEncoded() ? 
blockValSet.getDictionary() : null;
+    if (dictionary != null) {
+      int[][] dictIds = blockValSet.getDictionaryIdsMV();
+      for (int i = 0; i < length; i++) {
+        getDictIdBitmap(groupByResultHolder, groupKeyArray[i], 
dictionary).add(dictIds[i]);
+      }
+      return;
+    }
+
+    // For non-dictionary-encoded expression, store values into the UltraLogLog
+    switch (storedType) {
+      case INT:
+        int[][] intValues = blockValSet.getIntValuesMV();
+        for (int i = 0; i < length; i++) {
+          UltraLogLog ull = getULL(groupByResultHolder, groupKeyArray[i]);
+          for (int value : intValues[i]) {
+            UltraLogLogUtils.hashObject(value).ifPresent(ull::add);
+          }
+        }
+        break;
+      case LONG:
+        long[][] longValues = blockValSet.getLongValuesMV();
+        for (int i = 0; i < length; i++) {
+          UltraLogLog ull = getULL(groupByResultHolder, groupKeyArray[i]);
+          for (long value : longValues[i]) {
+            UltraLogLogUtils.hashObject(value).ifPresent(ull::add);
+          }
+        }
+        break;
+      case FLOAT:
+        float[][] floatValues = blockValSet.getFloatValuesMV();
+        for (int i = 0; i < length; i++) {
+          UltraLogLog ull = getULL(groupByResultHolder, groupKeyArray[i]);
+          for (float value : floatValues[i]) {
+            UltraLogLogUtils.hashObject(value).ifPresent(ull::add);
+          }
+        }
+        break;
+      case DOUBLE:
+        double[][] doubleValues = blockValSet.getDoubleValuesMV();
+        for (int i = 0; i < length; i++) {
+          UltraLogLog ull = getULL(groupByResultHolder, groupKeyArray[i]);
+          for (double value : doubleValues[i]) {
+            UltraLogLogUtils.hashObject(value).ifPresent(ull::add);
+          }
+        }
+        break;
+      case STRING:
+        String[][] stringValues = blockValSet.getStringValuesMV();
+        for (int i = 0; i < length; i++) {
+          UltraLogLog ull = getULL(groupByResultHolder, groupKeyArray[i]);
+          for (String value : stringValues[i]) {
+            UltraLogLogUtils.hashObject(value).ifPresent(ull::add);
+          }
+        }
+        break;
+      case BYTES:
+        byte[][][] bytesValues = blockValSet.getBytesValuesMV();
+        for (int i = 0; i < length; i++) {
+          UltraLogLog ull = getULL(groupByResultHolder, groupKeyArray[i]);
+          for (byte[] value : bytesValues[i]) {
+            UltraLogLogUtils.hashObject(value).ifPresent(ull::add);
+          }
+        }
+        break;
+      default:
+        throw new IllegalStateException(
+            "Illegal data type for DISTINCT_COUNT_ULL aggregation function: " 
+ storedType);
+    }
+  }
+
   @Override
   public void aggregateGroupByMV(int length, int[][] groupKeysArray, 
GroupByResultHolder groupByResultHolder,
       Map<ExpressionContext, BlockValSet> blockValSetMap) {
@@ -276,6 +438,15 @@ public class DistinctCountULLAggregationFunction extends 
BaseSingleInputAggregat
 
     DataType storedType = dataType.getStoredType();
 
+    if (blockValSet.isSingleValue()) {
+      aggregateSVGroupByMV(length, groupKeysArray, groupByResultHolder, 
blockValSet, storedType);
+    } else {
+      aggregateMVGroupByMV(length, groupKeysArray, groupByResultHolder, 
blockValSet, storedType);
+    }
+  }
+
+  protected void aggregateSVGroupByMV(int length, int[][] groupKeysArray, 
GroupByResultHolder groupByResultHolder,
+      BlockValSet blockValSet, DataType storedType) {
     // For dictionary-encoded expression, store dictionary ids into the bitmap
     Dictionary dictionary = blockValSet.isDictionaryEncoded() ? 
blockValSet.getDictionary() : null;
     if (dictionary != null) {
@@ -326,7 +497,101 @@ public class DistinctCountULLAggregationFunction extends 
BaseSingleInputAggregat
         break;
       default:
         throw new IllegalStateException(
-            "Illegal data type for DISTINCT_COUNT_HLL_PLUS aggregation 
function: " + storedType);
+            "Illegal data type for DISTINCT_COUNT_ULL aggregation function: " 
+ storedType);
+    }
+  }
+
+  protected void aggregateMVGroupByMV(int length, int[][] groupKeysArray, 
GroupByResultHolder groupByResultHolder,
+      BlockValSet blockValSet, DataType storedType) {
+    // For dictionary-encoded expression, store dictionary ids into the bitmap
+    Dictionary dictionary = blockValSet.isDictionaryEncoded() ? 
blockValSet.getDictionary() : null;
+    if (dictionary != null) {
+      int[][] dictIds = blockValSet.getDictionaryIdsMV();
+      for (int i = 0; i < length; i++) {
+        for (int groupKey : groupKeysArray[i]) {
+          getDictIdBitmap(groupByResultHolder, groupKey, 
dictionary).add(dictIds[i]);
+        }
+      }
+      return;
+    }
+
+    // For non-dictionary-encoded expression, store values into the UltraLogLog
+    switch (storedType) {
+      case INT:
+        int[][] intValues = blockValSet.getIntValuesMV();
+        for (int i = 0; i < length; i++) {
+          int[] intRow = intValues[i];
+          for (int groupKey : groupKeysArray[i]) {
+            UltraLogLog ull = getULL(groupByResultHolder, groupKey);
+            for (int value : intRow) {
+              UltraLogLogUtils.hashObject(value).ifPresent(ull::add);
+            }
+          }
+        }
+        break;
+      case LONG:
+        long[][] longValues = blockValSet.getLongValuesMV();
+        for (int i = 0; i < length; i++) {
+          long[] longRow = longValues[i];
+          for (int groupKey : groupKeysArray[i]) {
+            UltraLogLog ull = getULL(groupByResultHolder, groupKey);
+            for (long value : longRow) {
+              UltraLogLogUtils.hashObject(value).ifPresent(ull::add);
+            }
+          }
+        }
+        break;
+      case FLOAT:
+        float[][] floatValues = blockValSet.getFloatValuesMV();
+        for (int i = 0; i < length; i++) {
+          float[] floatRow = floatValues[i];
+          for (int groupKey : groupKeysArray[i]) {
+            UltraLogLog ull = getULL(groupByResultHolder, groupKey);
+            for (float value : floatRow) {
+              UltraLogLogUtils.hashObject(value).ifPresent(ull::add);
+            }
+          }
+        }
+        break;
+      case DOUBLE:
+        double[][] doubleValues = blockValSet.getDoubleValuesMV();
+        for (int i = 0; i < length; i++) {
+          double[] doubleRow = doubleValues[i];
+          for (int groupKey : groupKeysArray[i]) {
+            UltraLogLog ull = getULL(groupByResultHolder, groupKey);
+            for (double value : doubleRow) {
+              UltraLogLogUtils.hashObject(value).ifPresent(ull::add);
+            }
+          }
+        }
+        break;
+      case STRING:
+        String[][] stringValues = blockValSet.getStringValuesMV();
+        for (int i = 0; i < length; i++) {
+          String[] stringRow = stringValues[i];
+          for (int groupKey : groupKeysArray[i]) {
+            UltraLogLog ull = getULL(groupByResultHolder, groupKey);
+            for (String value : stringRow) {
+              UltraLogLogUtils.hashObject(value).ifPresent(ull::add);
+            }
+          }
+        }
+        break;
+      case BYTES:
+        byte[][][] bytesValues = blockValSet.getBytesValuesMV();
+        for (int i = 0; i < length; i++) {
+          byte[][] bytesRow = bytesValues[i];
+          for (int groupKey : groupKeysArray[i]) {
+            UltraLogLog ull = getULL(groupByResultHolder, groupKey);
+            for (byte[] value : bytesRow) {
+              UltraLogLogUtils.hashObject(value).ifPresent(ull::add);
+            }
+          }
+        }
+        break;
+      default:
+        throw new IllegalStateException(
+            "Illegal data type for DISTINCT_COUNT_ULL aggregation function: " 
+ storedType);
     }
   }
 
diff --git 
a/pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/SegmentPartitionedDistinctCountAggregationFunction.java
 
b/pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/SegmentPartitionedDistinctCountAggregationFunction.java
index 1acae7fc332..2a2f5706e69 100644
--- 
a/pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/SegmentPartitionedDistinctCountAggregationFunction.java
+++ 
b/pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/SegmentPartitionedDistinctCountAggregationFunction.java
@@ -40,7 +40,7 @@ import org.roaringbitmap.RoaringBitmap;
 
 
 /// The `SegmentPartitionedDistinctCountAggregationFunction` calculates the 
number of distinct values for a given
-/// single-value expression.
+/// single-value or multi-value expression.
 ///
 /// IMPORTANT: This function relies on the expression values being partitioned 
for each segment, where there is no
 /// common values within different segments.
@@ -72,7 +72,14 @@ public class 
SegmentPartitionedDistinctCountAggregationFunction extends BaseSing
   public void aggregate(int length, AggregationResultHolder 
aggregationResultHolder,
       Map<ExpressionContext, BlockValSet> blockValSetMap) {
     BlockValSet blockValSet = blockValSetMap.get(_expression);
+    if (blockValSet.isSingleValue()) {
+      aggregateSV(length, aggregationResultHolder, blockValSet);
+    } else {
+      aggregateMV(length, aggregationResultHolder, blockValSet);
+    }
+  }
 
+  protected void aggregateSV(int length, AggregationResultHolder 
aggregationResultHolder, BlockValSet blockValSet) {
     // For dictionary-encoded expression, store dictionary ids into a 
RoaringBitmap
     if (blockValSet.isDictionaryEncoded()) {
       int[] dictIds = blockValSet.getDictionaryIdsSV();
@@ -159,11 +166,119 @@ public class 
SegmentPartitionedDistinctCountAggregationFunction extends BaseSing
     }
   }
 
+  protected void aggregateMV(int length, AggregationResultHolder 
aggregationResultHolder, BlockValSet blockValSet) {
+    // For dictionary-encoded expression, store dictionary ids into a 
RoaringBitmap
+    if (blockValSet.isDictionaryEncoded()) {
+      int[][] dictIds = blockValSet.getDictionaryIdsMV();
+      RoaringBitmap bitmap = aggregationResultHolder.getResult();
+      if (bitmap == null) {
+        bitmap = new RoaringBitmap();
+        aggregationResultHolder.setValue(bitmap);
+      }
+      for (int i = 0; i < length; i++) {
+        bitmap.add(dictIds[i]);
+      }
+      return;
+    }
+
+    // For non-dictionary-encoded expression, store INT values into a 
RoaringBitmap, other types into an OpenHashSet
+    DataType storedType = blockValSet.getValueType().getStoredType();
+    switch (storedType) {
+      case INT:
+        int[][] intValues = blockValSet.getIntValuesMV();
+        RoaringBitmap bitmap = aggregationResultHolder.getResult();
+        if (bitmap == null) {
+          bitmap = new RoaringBitmap();
+          aggregationResultHolder.setValue(bitmap);
+        }
+        for (int i = 0; i < length; i++) {
+          bitmap.add(intValues[i]);
+        }
+        break;
+      case LONG:
+        long[][] longValues = blockValSet.getLongValuesMV();
+        LongOpenHashSet longSet = aggregationResultHolder.getResult();
+        if (longSet == null) {
+          longSet = new LongOpenHashSet();
+          aggregationResultHolder.setValue(longSet);
+        }
+        for (int i = 0; i < length; i++) {
+          for (long value : longValues[i]) {
+            longSet.add(value);
+          }
+        }
+        break;
+      case FLOAT:
+        float[][] floatValues = blockValSet.getFloatValuesMV();
+        FloatOpenHashSet floatSet = aggregationResultHolder.getResult();
+        if (floatSet == null) {
+          floatSet = new FloatOpenHashSet();
+          aggregationResultHolder.setValue(floatSet);
+        }
+        for (int i = 0; i < length; i++) {
+          for (float value : floatValues[i]) {
+            floatSet.add(value);
+          }
+        }
+        break;
+      case DOUBLE:
+        double[][] doubleValues = blockValSet.getDoubleValuesMV();
+        DoubleOpenHashSet doubleSet = aggregationResultHolder.getResult();
+        if (doubleSet == null) {
+          doubleSet = new DoubleOpenHashSet();
+          aggregationResultHolder.setValue(doubleSet);
+        }
+        for (int i = 0; i < length; i++) {
+          for (double value : doubleValues[i]) {
+            doubleSet.add(value);
+          }
+        }
+        break;
+      case STRING:
+        String[][] stringValues = blockValSet.getStringValuesMV();
+        ObjectOpenHashSet<String> stringSet = 
aggregationResultHolder.getResult();
+        if (stringSet == null) {
+          stringSet = new ObjectOpenHashSet<>();
+          aggregationResultHolder.setValue(stringSet);
+        }
+        for (int i = 0; i < length; i++) {
+          for (String value : stringValues[i]) {
+            stringSet.add(value);
+          }
+        }
+        break;
+      case BYTES:
+        byte[][][] bytesValues = blockValSet.getBytesValuesMV();
+        ObjectOpenHashSet<ByteArray> bytesSet = 
aggregationResultHolder.getResult();
+        if (bytesSet == null) {
+          bytesSet = new ObjectOpenHashSet<>();
+          aggregationResultHolder.setValue(bytesSet);
+        }
+        for (int i = 0; i < length; i++) {
+          for (byte[] value : bytesValues[i]) {
+            bytesSet.add(new ByteArray(value));
+          }
+        }
+        break;
+      default:
+        throw new IllegalStateException(
+            "Illegal data type for PARTITIONED_DISTINCT_COUNT aggregation 
function: " + storedType);
+    }
+  }
+
   @Override
   public void aggregateGroupBySV(int length, int[] groupKeyArray, 
GroupByResultHolder groupByResultHolder,
       Map<ExpressionContext, BlockValSet> blockValSetMap) {
     BlockValSet blockValSet = blockValSetMap.get(_expression);
+    if (blockValSet.isSingleValue()) {
+      aggregateSVGroupBySV(length, groupKeyArray, groupByResultHolder, 
blockValSet);
+    } else {
+      aggregateMVGroupBySV(length, groupKeyArray, groupByResultHolder, 
blockValSet);
+    }
+  }
 
+  protected void aggregateSVGroupBySV(int length, int[] groupKeyArray, 
GroupByResultHolder groupByResultHolder,
+      BlockValSet blockValSet) {
     // For dictionary-encoded expression, store dictionary ids into a 
RoaringBitmap
     if (blockValSet.isDictionaryEncoded()) {
       int[] dictIds = blockValSet.getDictionaryIdsSV();
@@ -218,11 +333,89 @@ public class 
SegmentPartitionedDistinctCountAggregationFunction extends BaseSing
     }
   }
 
+  protected void aggregateMVGroupBySV(int length, int[] groupKeyArray, 
GroupByResultHolder groupByResultHolder,
+      BlockValSet blockValSet) {
+    // For dictionary-encoded expression, store dictionary ids into a 
RoaringBitmap
+    if (blockValSet.isDictionaryEncoded()) {
+      int[][] dictIds = blockValSet.getDictionaryIdsMV();
+      for (int i = 0; i < length; i++) {
+        for (int dictId : dictIds[i]) {
+          setIntValueForGroup(groupByResultHolder, groupKeyArray[i], dictId);
+        }
+      }
+      return;
+    }
+
+    // For non-dictionary-encoded expression, store INT values into a 
RoaringBitmap, other types into an OpenHashSet
+    DataType storedType = blockValSet.getValueType().getStoredType();
+    switch (storedType) {
+      case INT:
+        int[][] intValues = blockValSet.getIntValuesMV();
+        for (int i = 0; i < length; i++) {
+          for (int value : intValues[i]) {
+            setIntValueForGroup(groupByResultHolder, groupKeyArray[i], value);
+          }
+        }
+        break;
+      case LONG:
+        long[][] longValues = blockValSet.getLongValuesMV();
+        for (int i = 0; i < length; i++) {
+          for (long value : longValues[i]) {
+            setLongValueForGroup(groupByResultHolder, groupKeyArray[i], value);
+          }
+        }
+        break;
+      case FLOAT:
+        float[][] floatValues = blockValSet.getFloatValuesMV();
+        for (int i = 0; i < length; i++) {
+          for (float value : floatValues[i]) {
+            setFloatValueForGroup(groupByResultHolder, groupKeyArray[i], 
value);
+          }
+        }
+        break;
+      case DOUBLE:
+        double[][] doubleValues = blockValSet.getDoubleValuesMV();
+        for (int i = 0; i < length; i++) {
+          for (double value : doubleValues[i]) {
+            setDoubleValueForGroup(groupByResultHolder, groupKeyArray[i], 
value);
+          }
+        }
+        break;
+      case STRING:
+        String[][] stringValues = blockValSet.getStringValuesMV();
+        for (int i = 0; i < length; i++) {
+          for (String value : stringValues[i]) {
+            setStringValueForGroup(groupByResultHolder, groupKeyArray[i], 
value);
+          }
+        }
+        break;
+      case BYTES:
+        byte[][][] bytesValues = blockValSet.getBytesValuesMV();
+        for (int i = 0; i < length; i++) {
+          for (byte[] value : bytesValues[i]) {
+            setBytesValueForGroup(groupByResultHolder, groupKeyArray[i], new 
ByteArray(value));
+          }
+        }
+        break;
+      default:
+        throw new IllegalStateException(
+            "Illegal data type for PARTITIONED_DISTINCT_COUNT aggregation 
function: " + storedType);
+    }
+  }
+
   @Override
   public void aggregateGroupByMV(int length, int[][] groupKeysArray, 
GroupByResultHolder groupByResultHolder,
       Map<ExpressionContext, BlockValSet> blockValSetMap) {
     BlockValSet blockValSet = blockValSetMap.get(_expression);
+    if (blockValSet.isSingleValue()) {
+      aggregateSVGroupByMV(length, groupKeysArray, groupByResultHolder, 
blockValSet);
+    } else {
+      aggregateMVGroupByMV(length, groupKeysArray, groupByResultHolder, 
blockValSet);
+    }
+  }
 
+  protected void aggregateSVGroupByMV(int length, int[][] groupKeysArray, 
GroupByResultHolder groupByResultHolder,
+      BlockValSet blockValSet) {
     // For dictionary-encoded expression, store dictionary ids into a 
RoaringBitmap
     if (blockValSet.isDictionaryEncoded()) {
       int[] dictIds = blockValSet.getDictionaryIdsSV();
@@ -298,6 +491,97 @@ public class 
SegmentPartitionedDistinctCountAggregationFunction extends BaseSing
     }
   }
 
+  protected void aggregateMVGroupByMV(int length, int[][] groupKeysArray, 
GroupByResultHolder groupByResultHolder,
+      BlockValSet blockValSet) {
+    // For dictionary-encoded expression, store dictionary ids into a 
RoaringBitmap
+    if (blockValSet.isDictionaryEncoded()) {
+      int[][] dictIds = blockValSet.getDictionaryIdsMV();
+      for (int i = 0; i < length; i++) {
+        int[] rowDictIds = dictIds[i];
+        for (int groupKey : groupKeysArray[i]) {
+          for (int dictId : rowDictIds) {
+            setIntValueForGroup(groupByResultHolder, groupKey, dictId);
+          }
+        }
+      }
+      return;
+    }
+
+    // For non-dictionary-encoded expression, store INT values into a 
RoaringBitmap, other types into an OpenHashSet
+    DataType storedType = blockValSet.getValueType().getStoredType();
+    switch (storedType) {
+      case INT:
+        int[][] intValues = blockValSet.getIntValuesMV();
+        for (int i = 0; i < length; i++) {
+          int[] intRow = intValues[i];
+          for (int groupKey : groupKeysArray[i]) {
+            for (int value : intRow) {
+              setIntValueForGroup(groupByResultHolder, groupKey, value);
+            }
+          }
+        }
+        break;
+      case LONG:
+        long[][] longValues = blockValSet.getLongValuesMV();
+        for (int i = 0; i < length; i++) {
+          long[] longRow = longValues[i];
+          for (int groupKey : groupKeysArray[i]) {
+            for (long value : longRow) {
+              setLongValueForGroup(groupByResultHolder, groupKey, value);
+            }
+          }
+        }
+        break;
+      case FLOAT:
+        float[][] floatValues = blockValSet.getFloatValuesMV();
+        for (int i = 0; i < length; i++) {
+          float[] floatRow = floatValues[i];
+          for (int groupKey : groupKeysArray[i]) {
+            for (float value : floatRow) {
+              setFloatValueForGroup(groupByResultHolder, groupKey, value);
+            }
+          }
+        }
+        break;
+      case DOUBLE:
+        double[][] doubleValues = blockValSet.getDoubleValuesMV();
+        for (int i = 0; i < length; i++) {
+          double[] doubleRow = doubleValues[i];
+          for (int groupKey : groupKeysArray[i]) {
+            for (double value : doubleRow) {
+              setDoubleValueForGroup(groupByResultHolder, groupKey, value);
+            }
+          }
+        }
+        break;
+      case STRING:
+        String[][] stringValues = blockValSet.getStringValuesMV();
+        for (int i = 0; i < length; i++) {
+          String[] stringRow = stringValues[i];
+          for (int groupKey : groupKeysArray[i]) {
+            for (String value : stringRow) {
+              setStringValueForGroup(groupByResultHolder, groupKey, value);
+            }
+          }
+        }
+        break;
+      case BYTES:
+        byte[][][] bytesValues = blockValSet.getBytesValuesMV();
+        for (int i = 0; i < length; i++) {
+          for (byte[] value : bytesValues[i]) {
+            ByteArray byteArray = new ByteArray(value);
+            for (int groupKey : groupKeysArray[i]) {
+              setBytesValueForGroup(groupByResultHolder, groupKey, byteArray);
+            }
+          }
+        }
+        break;
+      default:
+        throw new IllegalStateException(
+            "Illegal data type for PARTITIONED_DISTINCT_COUNT aggregation 
function: " + storedType);
+    }
+  }
+
   @Override
   public Long extractAggregationResult(AggregationResultHolder 
aggregationResultHolder) {
     return extractIntermediateResult(aggregationResultHolder.getResult());
diff --git 
a/pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/SumPrecisionAggregationFunction.java
 
b/pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/SumPrecisionAggregationFunction.java
index 7ff32f1043d..92281afc763 100644
--- 
a/pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/SumPrecisionAggregationFunction.java
+++ 
b/pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/SumPrecisionAggregationFunction.java
@@ -253,8 +253,9 @@ public class SumPrecisionAggregationFunction extends 
NullableSingleInputAggregat
 
         forEachNotNull(length, blockValSet, (from, to) -> {
           for (int i = from; i < to; i++) {
+            BigDecimal value = BigDecimal.valueOf(intValues[i]);
             for (int groupKey : groupKeysArray[i]) {
-              updateGroupByResult(groupKey, groupByResultHolder, 
BigDecimal.valueOf(intValues[i]));
+              updateGroupByResult(groupKey, groupByResultHolder, value);
             }
           }
         });
@@ -265,8 +266,9 @@ public class SumPrecisionAggregationFunction extends 
NullableSingleInputAggregat
 
         forEachNotNull(length, blockValSet, (from, to) -> {
           for (int i = from; i < to; i++) {
+            BigDecimal value = BigDecimal.valueOf(longValues[i]);
             for (int groupKey : groupKeysArray[i]) {
-              updateGroupByResult(groupKey, groupByResultHolder, 
BigDecimal.valueOf(longValues[i]));
+              updateGroupByResult(groupKey, groupByResultHolder, value);
             }
           }
         });
@@ -279,8 +281,9 @@ public class SumPrecisionAggregationFunction extends 
NullableSingleInputAggregat
 
         forEachNotNull(length, blockValSet, (from, to) -> {
           for (int i = from; i < to; i++) {
+            BigDecimal value = new BigDecimal(stringValues[i]);
             for (int groupKey : groupKeysArray[i]) {
-              updateGroupByResult(groupKey, groupByResultHolder, new 
BigDecimal(stringValues[i]));
+              updateGroupByResult(groupKey, groupByResultHolder, value);
             }
           }
         });
@@ -303,8 +306,9 @@ public class SumPrecisionAggregationFunction extends 
NullableSingleInputAggregat
 
         forEachNotNull(length, blockValSet, (from, to) -> {
           for (int i = from; i < to; i++) {
+            BigDecimal value = BigDecimalUtils.deserialize(bytesValues[i]);
             for (int groupKey : groupKeysArray[i]) {
-              updateGroupByResult(groupKey, groupByResultHolder, 
BigDecimalUtils.deserialize(bytesValues[i]));
+              updateGroupByResult(groupKey, groupByResultHolder, value);
             }
           }
         });
diff --git 
a/pinot-core/src/test/java/org/apache/pinot/core/common/SyntheticBlockValSets.java
 
b/pinot-core/src/test/java/org/apache/pinot/core/common/SyntheticBlockValSets.java
index e5251f3337b..eeed7b9e1ba 100644
--- 
a/pinot-core/src/test/java/org/apache/pinot/core/common/SyntheticBlockValSets.java
+++ 
b/pinot-core/src/test/java/org/apache/pinot/core/common/SyntheticBlockValSets.java
@@ -27,9 +27,10 @@ import java.util.function.Supplier;
 import javax.annotation.Nullable;
 import org.apache.pinot.core.plan.DocIdSetPlanNode;
 import org.apache.pinot.segment.spi.index.reader.Dictionary;
-import org.apache.pinot.spi.data.FieldSpec;
+import org.apache.pinot.spi.data.FieldSpec.DataType;
 import org.roaringbitmap.RoaringBitmap;
 
+
 /// Synthetic [BlockValSet] for testing and benchmarking.
 public class SyntheticBlockValSets {
   private SyntheticBlockValSets() {
@@ -138,6 +139,105 @@ public class SyntheticBlockValSets {
     }
   }
 
+  /// A [BlockValSet] for a dictionary-encoded multi-value column, which 
exposes dictionary ids rather than values.
+  ///
+  /// Functions that collect dictionary ids take a different path from the one 
that reads values, and resolve the ids
+  /// against the dictionary only when the result is extracted.
+  public static class DictIdsMV extends Base {
+
+    @Nullable
+    final RoaringBitmap _nullBitmap;
+    final int[][] _dictIds;
+    final Dictionary _dictionary;
+    final DataType _valueType;
+
+    private DictIdsMV(@Nullable RoaringBitmap nullBitmap, int[][] dictIds, 
Dictionary dictionary, DataType valueType) {
+      _nullBitmap = nullBitmap;
+      _dictIds = dictIds;
+      _dictionary = dictionary;
+      _valueType = valueType;
+    }
+
+    public static DictIdsMV create(@Nullable RoaringBitmap nullBitmap, int[][] 
dictIds, Dictionary dictionary,
+        DataType valueType) {
+      return new DictIdsMV(nullBitmap, dictIds, dictionary, valueType);
+    }
+
+    @Nullable
+    @Override
+    public RoaringBitmap getNullBitmap() {
+      return _nullBitmap;
+    }
+
+    @Override
+    public DataType getValueType() {
+      return _valueType;
+    }
+
+    @Override
+    public boolean isSingleValue() {
+      return false;
+    }
+
+    @Nullable
+    @Override
+    public Dictionary getDictionary() {
+      return _dictionary;
+    }
+
+    @Override
+    public int[][] getDictionaryIdsMV() {
+      return _dictIds;
+    }
+  }
+
+  /// A simple [BlockValSet] for nullable, not dictionary-encoded int values.
+  public static class Int extends Base {
+
+    @Nullable
+    final RoaringBitmap _nullBitmap;
+    final int[] _values;
+
+    private Int(@Nullable RoaringBitmap nullBitmap, int[] values) {
+      _nullBitmap = nullBitmap;
+      _values = values;
+    }
+
+    public static Int create(int numDocs, @Nullable RoaringBitmap nullBitmap, 
IntSupplier supplier) {
+      Preconditions.checkArgument(nullBitmap == null || nullBitmap.last() < 
numDocs, "null bitmap larger than numDocs");
+      int[] values = new int[numDocs];
+      for (int i = 0; i < numDocs; i++) {
+        values[i] = supplier.getAsInt();
+      }
+      return new Int(nullBitmap, values);
+    }
+
+    public static Int create(@Nullable RoaringBitmap nullBitmap, int[] values) 
{
+      return new Int(nullBitmap, values);
+    }
+
+    @Nullable
+    @Override
+    public RoaringBitmap getNullBitmap() {
+      return _nullBitmap;
+    }
+
+    @Override
+    public DataType getValueType() {
+      return DataType.INT;
+    }
+
+    @Override
+    public boolean isSingleValue() {
+      return true;
+    }
+
+    @Override
+    public int[] getIntValuesSV() {
+      return _values;
+    }
+  }
+
   /// A simple [BlockValSet] for nullable, not dictionary-encoded long values.
   public static class Long extends Base {
 
@@ -159,8 +259,7 @@ public class SyntheticBlockValSets {
     }
 
     public static Long create(int numDocs, @Nullable RoaringBitmap nullBitmap, 
LongSupplier supplier) {
-      Preconditions.checkArgument(nullBitmap == null || nullBitmap.last() < 
numDocs,
-          "null bitmap larger than numDocs");
+      Preconditions.checkArgument(nullBitmap == null || nullBitmap.last() < 
numDocs, "null bitmap larger than numDocs");
       long[] values = new long[numDocs];
       for (int i = 0; i < numDocs; i++) {
         values[i] = supplier.getAsLong();
@@ -180,8 +279,8 @@ public class SyntheticBlockValSets {
     }
 
     @Override
-    public FieldSpec.DataType getValueType() {
-      return FieldSpec.DataType.LONG;
+    public DataType getValueType() {
+      return DataType.LONG;
     }
 
     @Override
@@ -195,6 +294,44 @@ public class SyntheticBlockValSets {
     }
   }
 
+  /// A simple [BlockValSet] for nullable, not dictionary-encoded multi-value 
long values.
+  public static class LongMV extends Base {
+
+    @Nullable
+    final RoaringBitmap _nullBitmap;
+    final long[][] _values;
+
+    private LongMV(@Nullable RoaringBitmap nullBitmap, long[][] values) {
+      _nullBitmap = nullBitmap;
+      _values = values;
+    }
+
+    public static LongMV create(@Nullable RoaringBitmap nullBitmap, long[][] 
values) {
+      return new LongMV(nullBitmap, values);
+    }
+
+    @Nullable
+    @Override
+    public RoaringBitmap getNullBitmap() {
+      return _nullBitmap;
+    }
+
+    @Override
+    public DataType getValueType() {
+      return DataType.LONG;
+    }
+
+    @Override
+    public boolean isSingleValue() {
+      return false;
+    }
+
+    @Override
+    public long[][] getLongValuesMV() {
+      return _values;
+    }
+  }
+
   /// A simple [BlockValSet] for nullable, not dictionary-encoded double 
values.
   public static class Double extends Base {
 
@@ -216,8 +353,7 @@ public class SyntheticBlockValSets {
     }
 
     public static Double create(int numDocs, @Nullable RoaringBitmap 
nullBitmap, DoubleSupplier supplier) {
-      Preconditions.checkArgument(nullBitmap == null || nullBitmap.last() < 
numDocs,
-          "null bitmap larger than numDocs");
+      Preconditions.checkArgument(nullBitmap == null || nullBitmap.last() < 
numDocs, "null bitmap larger than numDocs");
       double[] values = new double[numDocs];
       for (int i = 0; i < numDocs; i++) {
         values[i] = supplier.getAsDouble();
@@ -237,8 +373,8 @@ public class SyntheticBlockValSets {
     }
 
     @Override
-    public FieldSpec.DataType getValueType() {
-      return FieldSpec.DataType.DOUBLE;
+    public DataType getValueType() {
+      return DataType.DOUBLE;
     }
 
     @Override
@@ -269,8 +405,7 @@ public class SyntheticBlockValSets {
     }
 
     public static Str create(int numDocs, @Nullable RoaringBitmap nullBitmap, 
Supplier<String> supplier) {
-      Preconditions.checkArgument(nullBitmap == null || nullBitmap.last() < 
numDocs,
-          "null bitmap larger than numDocs");
+      Preconditions.checkArgument(nullBitmap == null || nullBitmap.last() < 
numDocs, "null bitmap larger than numDocs");
       String[] values = new String[numDocs];
       for (int i = 0; i < numDocs; i++) {
         values[i] = supplier.get();
@@ -289,8 +424,8 @@ public class SyntheticBlockValSets {
     }
 
     @Override
-    public FieldSpec.DataType getValueType() {
-      return FieldSpec.DataType.STRING;
+    public DataType getValueType() {
+      return DataType.STRING;
     }
 
     @Override
@@ -317,8 +452,7 @@ public class SyntheticBlockValSets {
     }
 
     public static Bytes create(int numDocs, @Nullable RoaringBitmap 
nullBitmap, Supplier<byte[]> supplier) {
-      Preconditions.checkArgument(nullBitmap == null || nullBitmap.last() < 
numDocs,
-          "null bitmap larger than numDocs");
+      Preconditions.checkArgument(nullBitmap == null || nullBitmap.last() < 
numDocs, "null bitmap larger than numDocs");
       byte[][] values = new byte[numDocs][];
       for (int i = 0; i < numDocs; i++) {
         values[i] = supplier.get();
@@ -337,8 +471,8 @@ public class SyntheticBlockValSets {
     }
 
     @Override
-    public FieldSpec.DataType getValueType() {
-      return FieldSpec.DataType.BYTES;
+    public DataType getValueType() {
+      return DataType.BYTES;
     }
 
     @Override
@@ -351,52 +485,4 @@ public class SyntheticBlockValSets {
       return _values;
     }
   }
-
-  /// A simple [BlockValSet] for nullable, not dictionary-encoded int values.
-  public static class Int extends Base {
-
-    @Nullable
-    final RoaringBitmap _nullBitmap;
-    final int[] _values;
-
-    private Int(@Nullable RoaringBitmap nullBitmap, int[] values) {
-      _nullBitmap = nullBitmap;
-      _values = values;
-    }
-
-    public static Int create(int numDocs, @Nullable RoaringBitmap nullBitmap, 
IntSupplier supplier) {
-      Preconditions.checkArgument(nullBitmap == null || nullBitmap.last() < 
numDocs,
-          "null bitmap larger than numDocs");
-      int[] values = new int[numDocs];
-      for (int i = 0; i < numDocs; i++) {
-        values[i] = supplier.getAsInt();
-      }
-      return new Int(nullBitmap, values);
-    }
-
-    public static Int create(@Nullable RoaringBitmap nullBitmap, int[] values) 
{
-      return new Int(nullBitmap, values);
-    }
-
-    @Nullable
-    @Override
-    public RoaringBitmap getNullBitmap() {
-      return _nullBitmap;
-    }
-
-    @Override
-    public FieldSpec.DataType getValueType() {
-      return FieldSpec.DataType.INT;
-    }
-
-    @Override
-    public boolean isSingleValue() {
-      return true;
-    }
-
-    @Override
-    public int[] getIntValuesSV() {
-      return _values;
-    }
-  }
 }
diff --git 
a/pinot-core/src/test/java/org/apache/pinot/core/query/aggregation/function/DistinctCountULLAggregationFunctionTest.java
 
b/pinot-core/src/test/java/org/apache/pinot/core/query/aggregation/function/DistinctCountULLAggregationFunctionTest.java
index 06c05b366bd..b0fcb6cd068 100644
--- 
a/pinot-core/src/test/java/org/apache/pinot/core/query/aggregation/function/DistinctCountULLAggregationFunctionTest.java
+++ 
b/pinot-core/src/test/java/org/apache/pinot/core/query/aggregation/function/DistinctCountULLAggregationFunctionTest.java
@@ -22,10 +22,23 @@ import java.util.List;
 import java.util.Map;
 import org.apache.pinot.common.request.Literal;
 import org.apache.pinot.common.request.context.ExpressionContext;
+import org.apache.pinot.core.common.BlockValSet;
+import org.apache.pinot.core.common.SyntheticBlockValSets;
+import org.apache.pinot.core.query.aggregation.AggregationResultHolder;
+import org.apache.pinot.core.query.aggregation.groupby.GroupByResultHolder;
+import 
org.apache.pinot.core.query.aggregation.groupby.ObjectGroupByResultHolder;
 import org.apache.pinot.segment.spi.Constants;
-import org.testng.Assert;
+import org.apache.pinot.segment.spi.index.reader.Dictionary;
+import org.apache.pinot.spi.data.FieldSpec.DataType;
 import org.testng.annotations.Test;
 
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertFalse;
+import static org.testng.Assert.assertTrue;
+
 
 public class DistinctCountULLAggregationFunctionTest {
 
@@ -34,18 +47,18 @@ public class DistinctCountULLAggregationFunctionTest {
     DistinctCountULLAggregationFunction function = new 
DistinctCountULLAggregationFunction(
         List.of(ExpressionContext.forIdentifier("col")));
 
-    Assert.assertTrue(function.canUseStarTree(Map.of()));
-    
Assert.assertTrue(function.canUseStarTree(Map.of(Constants.HLLPLUS_ULL_P_KEY, 
"12")));
-    
Assert.assertTrue(function.canUseStarTree(Map.of(Constants.HLLPLUS_ULL_P_KEY, 
12)));
-    
Assert.assertFalse(function.canUseStarTree(Map.of(Constants.HLLPLUS_ULL_P_KEY, 
16)));
+    assertTrue(function.canUseStarTree(Map.of()));
+    assertTrue(function.canUseStarTree(Map.of(Constants.HLLPLUS_ULL_P_KEY, 
"12")));
+    assertTrue(function.canUseStarTree(Map.of(Constants.HLLPLUS_ULL_P_KEY, 
12)));
+    assertFalse(function.canUseStarTree(Map.of(Constants.HLLPLUS_ULL_P_KEY, 
16)));
 
     function = new 
DistinctCountULLAggregationFunction(List.of(ExpressionContext.forIdentifier("col"),
         ExpressionContext.forLiteral(Literal.intValue(12))));
 
-    Assert.assertTrue(function.canUseStarTree(Map.of()));
-    
Assert.assertTrue(function.canUseStarTree(Map.of(Constants.HLLPLUS_ULL_P_KEY, 
"12")));
-    
Assert.assertTrue(function.canUseStarTree(Map.of(Constants.HLLPLUS_ULL_P_KEY, 
12)));
-    
Assert.assertFalse(function.canUseStarTree(Map.of(Constants.HLLPLUS_ULL_P_KEY, 
"16")));
+    assertTrue(function.canUseStarTree(Map.of()));
+    assertTrue(function.canUseStarTree(Map.of(Constants.HLLPLUS_ULL_P_KEY, 
"12")));
+    assertTrue(function.canUseStarTree(Map.of(Constants.HLLPLUS_ULL_P_KEY, 
12)));
+    assertFalse(function.canUseStarTree(Map.of(Constants.HLLPLUS_ULL_P_KEY, 
"16")));
   }
 
   @Test
@@ -53,9 +66,80 @@ public class DistinctCountULLAggregationFunctionTest {
     DistinctCountULLAggregationFunction function = new 
DistinctCountULLAggregationFunction(
         List.of(ExpressionContext.forIdentifier("col"), 
ExpressionContext.forLiteral(Literal.stringValue("16"))));
 
-    Assert.assertFalse(function.canUseStarTree(Map.of()));
-    
Assert.assertFalse(function.canUseStarTree(Map.of(Constants.HLLPLUS_ULL_P_KEY, 
"12")));
-    
Assert.assertTrue(function.canUseStarTree(Map.of(Constants.HLLPLUS_ULL_P_KEY, 
16)));
-    
Assert.assertTrue(function.canUseStarTree(Map.of(Constants.HLLPLUS_ULL_P_KEY, 
"16")));
+    assertFalse(function.canUseStarTree(Map.of()));
+    assertFalse(function.canUseStarTree(Map.of(Constants.HLLPLUS_ULL_P_KEY, 
"12")));
+    assertTrue(function.canUseStarTree(Map.of(Constants.HLLPLUS_ULL_P_KEY, 
16)));
+    assertTrue(function.canUseStarTree(Map.of(Constants.HLLPLUS_ULL_P_KEY, 
"16")));
+  }
+
+  private static final ExpressionContext COLUMN = 
ExpressionContext.forIdentifier("column");
+  private static final long[][] MV_ROWS = {{1L, 2L}, {3L, 4L}, {5L, 6L}, {1L, 
3L}};
+  private static final long[] FLATTENED = {1L, 2L, 3L, 4L, 5L, 6L, 1L, 3L};
+
+  private static DistinctCountULLAggregationFunction create() {
+    return new DistinctCountULLAggregationFunction(List.of(COLUMN));
+  }
+
+  private static Map<ExpressionContext, BlockValSet> mvBlock() {
+    return Map.of(COLUMN, SyntheticBlockValSets.LongMV.create(null, MV_ROWS));
+  }
+
+  /// Aggregating a multi-value column gives exactly what aggregating its 
flattened values as a single-value column
+  /// gives. Comparing the two rather than asserting an estimate keeps this 
exact for a sketch.
+  @Test
+  public void testMVColumnMatchesFlattenedSVColumn() {
+    DistinctCountULLAggregationFunction function = create();
+
+    AggregationResultHolder mvHolder = 
function.createAggregationResultHolder();
+    function.aggregate(MV_ROWS.length, mvHolder, mvBlock());
+
+    AggregationResultHolder svHolder = 
function.createAggregationResultHolder();
+    function.aggregate(FLATTENED.length, svHolder,
+        Map.of(COLUMN, SyntheticBlockValSets.Long.create(null, FLATTENED)));
+
+    
assertEquals(function.extractFinalResult(function.extractAggregationResult(mvHolder)),
+        
function.extractFinalResult(function.extractAggregationResult(svHolder)));
+  }
+
+  /// Every value of a row lands in that row's group.
+  @Test
+  public void testMVColumnGroupBySV() {
+    DistinctCountULLAggregationFunction function = create();
+    GroupByResultHolder resultHolder = new ObjectGroupByResultHolder(2, 2);
+    // Rows 0 and 3 to group 0, giving 1, 2, 1, 3; rows 1 and 2 to group 1, 
giving 3, 4, 5, 6
+    function.aggregateGroupBySV(MV_ROWS.length, new int[]{0, 1, 1, 0}, 
resultHolder, mvBlock());
+
+    
assertEquals(function.extractFinalResult(function.extractGroupByResult(resultHolder,
 0)), 3L);
+    
assertEquals(function.extractFinalResult(function.extractGroupByResult(resultHolder,
 1)), 4L);
+  }
+
+  /// A row's values land in every group key that row carries.
+  @Test
+  public void testMVColumnGroupByMV() {
+    DistinctCountULLAggregationFunction function = create();
+    GroupByResultHolder resultHolder = new ObjectGroupByResultHolder(2, 2);
+    int[][] groupKeys = {{0, 1}, {0}, {1}, {0, 1}};
+    function.aggregateGroupByMV(MV_ROWS.length, groupKeys, resultHolder, 
mvBlock());
+
+    // Group 0 sees rows 0, 1 and 3, giving 1, 2, 3, 4; group 1 sees rows 0, 2 
and 3, giving 1, 2, 5, 6, 3
+    
assertEquals(function.extractFinalResult(function.extractGroupByResult(resultHolder,
 0)), 4L);
+    
assertEquals(function.extractFinalResult(function.extractGroupByResult(resultHolder,
 1)), 5L);
+  }
+
+  /// A dictionary-encoded multi-value column collects dictionary ids and 
resolves them against the dictionary only
+  /// when the result is extracted, so it is a separate path from the raw 
multi-value column above.
+  @Test
+  public void testDictionaryEncodedMVColumn() {
+    Dictionary dictionary = mock(Dictionary.class);
+    when(dictionary.get(anyInt())).thenAnswer(invocation -> (long) (int) 
invocation.getArgument(0) + 1);
+    // Ids 0..5 stand for values 1..6, so these rows carry the same values as 
MV_ROWS
+    int[][] dictIds = {{0, 1}, {2, 3}, {4, 5}, {0, 2}};
+
+    DistinctCountULLAggregationFunction function = create();
+    AggregationResultHolder resultHolder = 
function.createAggregationResultHolder();
+    function.aggregate(dictIds.length, resultHolder,
+        Map.of(COLUMN, SyntheticBlockValSets.DictIdsMV.create(null, dictIds, 
dictionary, DataType.LONG)));
+
+    assertEquals((long) 
function.extractFinalResult(function.extractAggregationResult(resultHolder)), 
6L);
   }
 }
diff --git 
a/pinot-core/src/test/java/org/apache/pinot/core/query/aggregation/function/SegmentPartitionedDistinctCountAggregationFunctionTest.java
 
b/pinot-core/src/test/java/org/apache/pinot/core/query/aggregation/function/SegmentPartitionedDistinctCountAggregationFunctionTest.java
new file mode 100644
index 00000000000..f2ba6e5e43a
--- /dev/null
+++ 
b/pinot-core/src/test/java/org/apache/pinot/core/query/aggregation/function/SegmentPartitionedDistinctCountAggregationFunctionTest.java
@@ -0,0 +1,120 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.pinot.core.query.aggregation.function;
+
+import java.util.List;
+import java.util.Map;
+import org.apache.pinot.common.request.context.ExpressionContext;
+import org.apache.pinot.core.common.BlockValSet;
+import org.apache.pinot.core.common.SyntheticBlockValSets;
+import org.apache.pinot.core.query.aggregation.AggregationResultHolder;
+import org.apache.pinot.core.query.aggregation.groupby.GroupByResultHolder;
+import 
org.apache.pinot.core.query.aggregation.groupby.ObjectGroupByResultHolder;
+import org.apache.pinot.segment.spi.index.reader.Dictionary;
+import org.apache.pinot.spi.data.FieldSpec.DataType;
+import org.testng.annotations.Test;
+
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+import static org.testng.Assert.assertEquals;
+
+
+/// Multi-value column support for `SEGMENTPARTITIONEDDISTINCTCOUNT`.
+///
+/// This function used to read the single-value accessors unconditionally, so 
a multi-value column was not something
+/// it could aggregate at all. It now branches on 
`BlockValSet.isSingleValue()` the way the HyperLogLog and bitmap
+/// families already did. Its counts are exact, so the answers are asserted 
directly rather than as estimates.
+public class SegmentPartitionedDistinctCountAggregationFunctionTest {
+  private static final ExpressionContext COLUMN = 
ExpressionContext.forIdentifier("column");
+  private static final long[][] MV_ROWS = {{1L, 2L}, {3L, 4L}, {5L, 6L}, {1L, 
3L}};
+  private static final long[] FLATTENED = {1L, 2L, 3L, 4L, 5L, 6L, 1L, 3L};
+
+  private static SegmentPartitionedDistinctCountAggregationFunction create() {
+    return new 
SegmentPartitionedDistinctCountAggregationFunction(List.of(COLUMN));
+  }
+
+  private static Map<ExpressionContext, BlockValSet> mvBlock() {
+    return Map.of(COLUMN, SyntheticBlockValSets.LongMV.create(null, MV_ROWS));
+  }
+
+  /// Every value of every row is counted, with repeats collapsing.
+  @Test
+  public void testMVColumnCountsEveryValue() {
+    SegmentPartitionedDistinctCountAggregationFunction function = create();
+    AggregationResultHolder resultHolder = 
function.createAggregationResultHolder();
+    function.aggregate(MV_ROWS.length, resultHolder, mvBlock());
+
+    // 1, 2, 3, 4, 5 and 6, with 1 and 3 seen twice
+    
assertEquals(function.extractFinalResult(function.extractAggregationResult(resultHolder)).longValue(),
 6L);
+  }
+
+  /// Every value of a row lands in that row's group.
+  @Test
+  public void testMVColumnGroupBySV() {
+    SegmentPartitionedDistinctCountAggregationFunction function = create();
+    GroupByResultHolder resultHolder = new ObjectGroupByResultHolder(2, 2);
+    // Rows 0 and 3 to group 0, giving 1, 2, 1, 3; rows 1 and 2 to group 1, 
giving 3, 4, 5, 6
+    function.aggregateGroupBySV(MV_ROWS.length, new int[]{0, 1, 1, 0}, 
resultHolder, mvBlock());
+
+    
assertEquals(function.extractFinalResult(function.extractGroupByResult(resultHolder,
 0)).longValue(), 3L);
+    
assertEquals(function.extractFinalResult(function.extractGroupByResult(resultHolder,
 1)).longValue(), 4L);
+  }
+
+  /// A row's values land in every group key that row carries.
+  @Test
+  public void testMVColumnGroupByMV() {
+    SegmentPartitionedDistinctCountAggregationFunction function = create();
+    GroupByResultHolder resultHolder = new ObjectGroupByResultHolder(2, 2);
+    int[][] groupKeys = {{0, 1}, {0}, {1}, {0, 1}};
+    function.aggregateGroupByMV(MV_ROWS.length, groupKeys, resultHolder, 
mvBlock());
+
+    // Group 0 sees rows 0, 1 and 3, giving 1, 2, 3, 4; group 1 sees rows 0, 2 
and 3, giving 1, 2, 5, 6, 3
+    
assertEquals(function.extractFinalResult(function.extractGroupByResult(resultHolder,
 0)).longValue(), 4L);
+    
assertEquals(function.extractFinalResult(function.extractGroupByResult(resultHolder,
 1)).longValue(), 5L);
+  }
+
+  /// A single-value column still takes the single-value path, so the split 
did not disturb it.
+  @Test
+  public void testSVColumnStillAggregates() {
+    SegmentPartitionedDistinctCountAggregationFunction function = create();
+    AggregationResultHolder resultHolder = 
function.createAggregationResultHolder();
+    function.aggregate(FLATTENED.length, resultHolder,
+        Map.of(COLUMN, SyntheticBlockValSets.Long.create(null, FLATTENED)));
+
+    
assertEquals(function.extractFinalResult(function.extractAggregationResult(resultHolder)).longValue(),
 6L);
+  }
+
+  /// A dictionary-encoded multi-value column collects dictionary ids and 
resolves them against the dictionary only
+  /// when the result is extracted, so it is a separate path from the raw 
multi-value column above.
+  @Test
+  public void testDictionaryEncodedMVColumn() {
+    Dictionary dictionary = mock(Dictionary.class);
+    when(dictionary.get(anyInt())).thenAnswer(invocation -> (long) (int) 
invocation.getArgument(0) + 1);
+    // Ids 0..5 stand for values 1..6, so these rows carry the same values as 
MV_ROWS
+    int[][] dictIds = {{0, 1}, {2, 3}, {4, 5}, {0, 2}};
+
+    SegmentPartitionedDistinctCountAggregationFunction function = create();
+    AggregationResultHolder resultHolder = 
function.createAggregationResultHolder();
+    function.aggregate(dictIds.length, resultHolder,
+        Map.of(COLUMN, SyntheticBlockValSets.DictIdsMV.create(null, dictIds, 
dictionary, DataType.LONG)));
+
+    
assertEquals(function.extractFinalResult(function.extractAggregationResult(resultHolder)).longValue(),
 6L);
+  }
+}
diff --git 
a/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/custom/UuidAggregationTest.java
 
b/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/custom/UuidAggregationTest.java
index eff7f109d90..c7c95487b78 100644
--- 
a/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/custom/UuidAggregationTest.java
+++ 
b/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/custom/UuidAggregationTest.java
@@ -162,16 +162,17 @@ public class UuidAggregationTest extends 
CustomDataQueryClusterIntegrationTest {
       throws Exception {
     setUseMultiStageQueryEngine(false);
     for (String function : List.of("DISTINCTCOUNT", "DISTINCTCOUNTHLL", 
"DISTINCTCOUNTHLLPLUS",
-        "DISTINCTCOUNTBITMAP", "DISTINCTCOUNTTHETASKETCH", 
"DISTINCTCOUNTCPCSKETCH")) {
+        "DISTINCTCOUNTBITMAP", "DISTINCTCOUNTTHETASKETCH", 
"DISTINCTCOUNTCPCSKETCH", "DISTINCTCOUNTULL")) {
       JsonNode rows = query(String.format("SELECT %1$s(%2$s), %1$s(%3$s), 
%1$s(%4$s) FROM %5$s", function,
           UUID_DICT_SV_COLUMN, UUID_RAW_SV_COLUMN, UUID_RAW_MV_COLUMN, 
getTableName()));
       assertCounts(rows.get(0), 3L, 3L, 4L);
     }
 
-    // DISTINCTCOUNTULL currently supports only single-value inputs.
-    JsonNode rows = query(String.format("SELECT DISTINCTCOUNTULL(%s), 
DISTINCTCOUNTULL(%s) FROM %s",
-        UUID_DICT_SV_COLUMN, UUID_RAW_SV_COLUMN, getTableName()));
-    assertCounts(rows.get(0), 3L, 3L);
+    // A dictionary-encoded multi-value column collects dictionary ids rather 
than reading values, which is a
+    // separate path from the raw multi-value column above
+    JsonNode rows =
+        query(String.format("SELECT DISTINCTCOUNTULL(%s) FROM %s", 
UUID_DICT_MV_COLUMN, getTableName()));
+    assertCounts(rows.get(0), 4L);
 
     rows = query(String.format(
         "SELECT DISTINCTCOUNTTHETASKETCH(%1$s, '', '%1$s = ''%3$s''', '$1'), "


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

Reply via email to