Jackie-Jiang commented on code in PR #19390:
URL: https://github.com/apache/pinot/pull/19390#discussion_r3911506786


##########
pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/groupby/DictionaryBasedGroupKeyGenerator.java:
##########
@@ -132,6 +166,15 @@ public 
DictionaryBasedGroupKeyGenerator(BaseProjectOperator<?> projectOperator,
       }
       _isSingleValueColumn[i] = columnContext.isSingleValue();
     }
+    if (anyNullDictId) {
+      _nullDictIds = nullDictIds;
+      _nullReplacedSingleValueDictIds = new int[_numGroupByExpressions][];
+      _nullReplacedMultiValueDictIds = new int[_numGroupByExpressions][][];
+    } else {
+      _nullDictIds = null;
+      _nullReplacedSingleValueDictIds = null;
+      _nullReplacedMultiValueDictIds = null;
+    }
     if (groupByExpressionSizesFromPredicates != null) {

Review Comment:
   Agreed, and thanks for chasing it down rather than filing it here — #19379 
covers exactly this.
   
   One thing worth carrying over to merge ordering: null handling was *routing 
around* this bug on master, since those queries went to the no-dictionary 
generators instead. This PR removes that accidental shield, so the failure 
becomes reachable with `enableNullHandling=true` as well. #19379 should land 
first, or the two together.
   



##########
pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/groupby/DictionaryBasedGroupKeyGenerator.java:
##########
@@ -118,6 +139,19 @@ public 
DictionaryBasedGroupKeyGenerator(BaseProjectOperator<?> projectOperator,
       _dictionaries[i] = columnContext.getDictionary();
       assert _dictionaries[i] != null;
       int cardinality = _dictionaries[i].length();
+      if (nullDictIds != null) {
+        // Reserve an id unless the column is known to produce no null. A 
column read straight from a segment says so
+        // through its null value vector, which is the same vector a query 
reads nulls from. A transform exposes no
+        // data source to consult and can still hand out a null bitmap of its 
own, so it always reserves one.
+        DataSource dataSource = columnContext.getDataSource();
+        boolean tracksNulls = dataSource == null || 
dataSource.getNullValueVector() != null;

Review Comment:
   Good catch — this is the one I would not have found. Comment added at the 
decision point in `f402575`:
   
   ```java
   // This decides nullability once, from one project operator, while a shared 
generator can be handed blocks
   // from others: FilteredGroupByOperator builds a single generator for every 
filtered aggregation. Those
   // operators must therefore agree on which group-by columns can produce a 
null. They do today, because a
   // star-tree is only chosen for a null handling query when no column it 
touches holds a null.
   ```
   
   I verified your reading of the mechanism before writing it down: line 96 
takes `aggregationInfos.get(0).getProjectOperator()`, and the loop threads that 
one generator through every `AggregationInfo`, each with its own project 
operator.
   



##########
pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/groupby/DictionaryBasedGroupKeyGenerator.java:
##########
@@ -216,25 +259,89 @@ public int getGlobalGroupKeyUpperBound() {
   @Override
   public void generateKeysForBlock(ValueBlock valueBlock, int[] groupKeys) {
     // Fetch dictionary ids in the given block for all group-by columns
+    int numDocs = valueBlock.getNumDocs();
     for (int i = 0; i < _numGroupByExpressions; i++) {
-      BlockValSet blockValueSet = 
valueBlock.getBlockValueSet(_groupByExpressions[i]);
-      _singleValueDictIds[i] = blockValueSet.getDictionaryIdsSV();
+      _singleValueDictIds[i] = 
getSingleValueDictIds(valueBlock.getBlockValueSet(_groupByExpressions[i]), i, 
numDocs);
     }
-    _rawKeyHolder.processSingleValue(valueBlock.getNumDocs(), groupKeys);
+    _rawKeyHolder.processSingleValue(numDocs, groupKeys);
   }
 
   @Override
   public void generateKeysForBlock(ValueBlock valueBlock, int[][] groupKeys) {
     // Fetch dictionary ids in the given block for all group-by columns
+    int numDocs = valueBlock.getNumDocs();
     for (int i = 0; i < _numGroupByExpressions; i++) {
       BlockValSet blockValueSet = 
valueBlock.getBlockValueSet(_groupByExpressions[i]);
       if (_isSingleValueColumn[i]) {
-        _singleValueDictIds[i] = blockValueSet.getDictionaryIdsSV();
+        _singleValueDictIds[i] = getSingleValueDictIds(blockValueSet, i, 
numDocs);
       } else {
-        _multiValueDictIds[i] = blockValueSet.getDictionaryIdsMV();
+        _multiValueDictIds[i] = getMultiValueDictIds(blockValueSet, i, 
numDocs);
       }
     }
-    _rawKeyHolder.processMultiValue(valueBlock.getNumDocs(), groupKeys);
+    _rawKeyHolder.processMultiValue(numDocs, groupKeys);
+  }
+
+  /// Returns the block's single-value dictionary ids, with every null row 
moved onto the column's reserved null
+  /// dictionary id so that nulls form one group of their own.
+  ///
+  /// The block owns the array it hands out and shares it with everything else 
reading that column in this block, so
+  /// the ids are copied before being rewritten rather than replaced in place. 
Returns the block's own array untouched
+  /// when the column reserves no null dictionary id or the block holds no 
null.
+  private int[] getSingleValueDictIds(BlockValSet blockValSet, int index, int 
numDocs) {
+    int[] dictIds = blockValSet.getDictionaryIdsSV();
+    if (_nullDictIds == null || _nullDictIds[index] < 0) {
+      return dictIds;
+    }
+    int nullDictId = _nullDictIds[index];
+    RoaringBitmap nullBitmap = blockValSet.getNullBitmap();
+    if (nullBitmap == null || nullBitmap.isEmpty()) {
+      return dictIds;
+    }
+    assert _nullReplacedSingleValueDictIds != null;
+    int[] nullReplacedDictIds = _nullReplacedSingleValueDictIds[index];
+    if (nullReplacedDictIds == null || nullReplacedDictIds.length < numDocs) {
+      nullReplacedDictIds = new int[numDocs];
+      _nullReplacedSingleValueDictIds[index] = nullReplacedDictIds;
+    }
+    System.arraycopy(dictIds, 0, nullReplacedDictIds, 0, numDocs);
+    PeekableIntIterator nullIterator = nullBitmap.getIntIterator();
+    while (nullIterator.hasNext()) {
+      nullReplacedDictIds[nullIterator.next()] = nullDictId;
+    }
+    return nullReplacedDictIds;
+  }
+
+  /// Returns the block's multi-value dictionary ids, with every null row 
replaced by a single entry holding the
+  /// column's reserved null dictionary id, so the row contributes one null 
group instead of being read as the
+  /// column's default null value.
+  ///
+  /// Only the outer array is copied: the rows that are not null are handed on 
pointing at the arrays the block
+  /// already gave out. Returns the block's own array untouched when the 
column reserves no null dictionary id or the
+  /// block holds no null.
+  private int[][] getMultiValueDictIds(BlockValSet blockValSet, int index, int 
numDocs) {
+    int[][] dictIds = blockValSet.getDictionaryIdsMV();
+    if (_nullDictIds == null || _nullDictIds[index] < 0) {
+      return dictIds;
+    }
+    int nullDictId = _nullDictIds[index];
+    RoaringBitmap nullBitmap = blockValSet.getNullBitmap();
+    if (nullBitmap == null || nullBitmap.isEmpty()) {
+      return dictIds;
+    }
+    assert _nullReplacedMultiValueDictIds != null;
+    int[][] nullReplacedDictIds = _nullReplacedMultiValueDictIds[index];
+    if (nullReplacedDictIds == null || nullReplacedDictIds.length < numDocs) {
+      nullReplacedDictIds = new int[numDocs][];
+      _nullReplacedMultiValueDictIds[index] = nullReplacedDictIds;
+    }
+    System.arraycopy(dictIds, 0, nullReplacedDictIds, 0, numDocs);
+    PeekableIntIterator nullIterator = nullBitmap.getIntIterator();
+    while (nullIterator.hasNext()) {
+      // A fresh array per row rather than one shared between them: a raw key 
holder writes its group ids back over
+      // the array it is handed for a row whose only group-by column is this 
one
+      nullReplacedDictIds[nullIterator.next()] = new int[]{nullDictId};

Review Comment:
   Added to the description under behavior changes, framed the way you put it — 
one NULL group is not the Postgres answer, it is the closest one reachable 
here, since Pinot ingests `[]` as null and a segment has no representation for 
an empty multi-value row. So `[]` and SQL NULL are already indistinguishable by 
the time a query reads them, and the choice is only between a NULL group and 
the column's default null value.
   



##########
pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/groupby/NoDictionaryMultiColumnGroupKeyGenerator.java:
##########
@@ -148,147 +152,85 @@ public void generateKeysForBlock(ValueBlock valueBlock, 
int[] groupKeys) {
         }
       }
     }
-    int[] keyValues = new int[_numGroupByExpressions];
-    // note that we are mutating its backing array for memory efficiency
-    FixedIntArray flyweightKey = new FixedIntArray(keyValues);
+    // All null when null handling is disabled, so the per-column check below 
never matches. The check stays inside
+    // the row loop rather than forking the loop per mode: every iteration 
pays for a hash-map operation, which dwarfs
+    // a predicted branch.
+    RoaringBitmap[] nullBitmaps = new RoaringBitmap[_numGroupByExpressions];
     if (_nullHandlingEnabled) {
-      RoaringBitmap[] nullBitmaps = new RoaringBitmap[_numGroupByExpressions];
       for (int i = 0; i < _numGroupByExpressions; i++) {
-        nullBitmaps[i] = 
valueBlock.getBlockValueSet(_groupByExpressions[i]).getNullBitmap();
+        RoaringBitmap nullBitmap = 
valueBlock.getBlockValueSet(_groupByExpressions[i]).getNullBitmap();
+        // Normalize an empty bitmap to null so the per-row check is a plain 
null comparison
+        nullBitmaps[i] = nullBitmap != null && !nullBitmap.isEmpty() ? 
nullBitmap : null;
       }
-      for (int row = 0; row < numDocs; row++) {
-        int numGroups = _groupKeyMap.size();
-        boolean hasInvalidKeyValue = false;
-        if (numGroups < _numGroupsLimit) {
-          for (int col = 0; col < _numGroupByExpressions; col++) {
-            if (nullBitmaps[col] != null && nullBitmaps[col].contains(row)) {
-              keyValues[col] = ID_FOR_NULL;
-            } else {
-              Object columnValues = values[col];
-              ValueToIdMap onTheFlyDictionary = _onTheFlyDictionaries[col];
-              int keyValue;
-              if (columnValues instanceof int[]) {
-                keyValue = onTheFlyDictionary.put(((int[]) columnValues)[row]);
-              } else if (columnValues instanceof long[]) {
-                keyValue = onTheFlyDictionary.put(((long[]) 
columnValues)[row]);
-              } else if (columnValues instanceof float[]) {
-                keyValue = onTheFlyDictionary.put(((float[]) 
columnValues)[row]);
-              } else if (columnValues instanceof double[]) {
-                keyValue = onTheFlyDictionary.put(((double[]) 
columnValues)[row]);
-              } else if (columnValues instanceof byte[][]) {
-                keyValue = onTheFlyDictionary.put(new ByteArray(((byte[][]) 
columnValues)[row]));
-              } else {
-                keyValue = onTheFlyDictionary.put(((Object[]) 
columnValues)[row]);
-              }
-              keyValues[col] = keyValue;
-            }
-          }
-        } else {
-          for (int col = 0; col < _numGroupByExpressions; col++) {
-            if (nullBitmaps[col] != null && nullBitmaps[col].contains(row)) {
-              keyValues[col] = ID_FOR_NULL;
-            } else {
-              Object columnValues = values[col];
-              ValueToIdMap onTheFlyDictionary = _onTheFlyDictionaries[col];
-              int keyValue;
-              if (columnValues instanceof int[]) {
-                keyValue = onTheFlyDictionary.getId(((int[]) 
columnValues)[row]);
-              } else if (columnValues instanceof long[]) {
-                keyValue = onTheFlyDictionary.getId(((long[]) 
columnValues)[row]);
-              } else if (columnValues instanceof float[]) {
-                keyValue = onTheFlyDictionary.getId(((float[]) 
columnValues)[row]);
-              } else if (columnValues instanceof double[]) {
-                keyValue = onTheFlyDictionary.getId(((double[]) 
columnValues)[row]);
-              } else if (columnValues instanceof byte[][]) {
-                keyValue = onTheFlyDictionary.getId(new ByteArray(((byte[][]) 
columnValues)[row]));
-              } else {
-                keyValue = onTheFlyDictionary.getId(((Object[]) 
columnValues)[row]);
-              }
-              if (keyValue == INVALID_ID) {
-                hasInvalidKeyValue = true;
-                break;
-              }
-            }
-          }
-        }
-        if (hasInvalidKeyValue) {
-          groupKeys[row] = INVALID_ID;
-        } else {
-          int groupId = getGroupIdForKey(flyweightKey);
-          if (groupId == numGroups) {
-            // When a new group is added, create a new FixedIntArray
-            keyValues = new int[_numGroupByExpressions];
-            flyweightKey = new FixedIntArray(keyValues);
-          }
-          groupKeys[row] = groupId;
-        }
-      }
-    } else {
-      for (int row = 0; row < numDocs; row++) {
-        int numGroups = _groupKeyMap.size();
-        boolean hasInvalidKeyValue = false;
-        if (numGroups < _numGroupsLimit) {
-          for (int col = 0; col < _numGroupByExpressions; col++) {
+    }
+    int[] keyValues = new int[_numGroupByExpressions];
+    // note that we are mutating its backing array for memory efficiency
+    FixedIntArray flyweightKey = new FixedIntArray(keyValues);
+    for (int row = 0; row < numDocs; row++) {
+      int numGroups = _groupKeyMap.size();
+      boolean hasInvalidKeyValue = false;
+      if (numGroups < _numGroupsLimit) {
+        for (int col = 0; col < _numGroupByExpressions; col++) {
+          if (isNull(nullBitmaps[col], row)) {
+            keyValues[col] = ID_FOR_NULL;
+          } else {
             Object columnValues = values[col];
             ValueToIdMap onTheFlyDictionary = _onTheFlyDictionaries[col];
-            int keyValue;
-            if (columnValues instanceof int[]) {
-              int columnValue = ((int[]) columnValues)[row];
-              keyValue = onTheFlyDictionary != null ? 
onTheFlyDictionary.put(columnValue) : columnValue;
-            } else if (columnValues instanceof long[]) {
-              keyValue = onTheFlyDictionary.put(((long[]) columnValues)[row]);
-            } else if (columnValues instanceof float[]) {
-              keyValue = onTheFlyDictionary.put(((float[]) columnValues)[row]);
-            } else if (columnValues instanceof double[]) {
-              keyValue = onTheFlyDictionary.put(((double[]) 
columnValues)[row]);
-            } else if (columnValues instanceof byte[][]) {
-              keyValue = onTheFlyDictionary.put(new ByteArray(((byte[][]) 
columnValues)[row]));
-            } else {
-              keyValue = onTheFlyDictionary.put(((Object[]) 
columnValues)[row]);
-            }
-            keyValues[col] = keyValue;
+            keyValues[col] = switch (columnValues) {
+              // A dictionary-encoded column has no on-the-fly dictionary: its 
values are dictionary ids already, used
+              // as key values directly
+              case int[] ints -> onTheFlyDictionary != null ? 
onTheFlyDictionary.put(ints[row]) : ints[row];

Review Comment:
   Done in `f402575` — the selection is now just:
   
   ```java
   Dictionary dictionary = columnContext.isDictionaryEncoded() ? 
columnContext.getDictionary() : null;
   ```
   
   I checked the four places that has to hold before making it: the `int[]` 
arms already pass ids straight through when there is no on-the-fly dictionary; 
`ID_FOR_NULL` is `-2`, so it cannot collide with a dictionary id; 
`buildKeysFromIds` already branches on `_dictionaries[i] != null` when 
rendering; and the at-limit `getId` path stays correct, because a dictionary id 
is always a known value while group novelty is decided by `_groupKeyMap`. Class 
javadoc now records that nullness is read from the bitmap rather than from the 
id.
   



##########
pinot-core/src/test/java/org/apache/pinot/core/query/aggregation/groupby/DictionaryBasedGroupKeyGeneratorTest.java:
##########
@@ -166,7 +166,7 @@ public void testArrayBasedSingleValue() {
     DictionaryBasedGroupKeyGenerator dictionaryBasedGroupKeyGenerator =
         new DictionaryBasedGroupKeyGenerator(_projectOperator, 
getExpressions(groupByColumns),
             Server.DEFAULT_QUERY_EXECUTOR_NUM_GROUPS_LIMIT,
-            Server.DEFAULT_QUERY_EXECUTOR_MAX_INITIAL_RESULT_HOLDER_CAPACITY, 
null);
+            Server.DEFAULT_QUERY_EXECUTOR_MAX_INITIAL_RESULT_HOLDER_CAPACITY, 
false, null);

Review Comment:
   Added in `f402575`, though it needed the fixture to grow a nullable column 
first: the segment had no nulls at all, so passing `true` would have reserved 
no id and the test would have asserted nothing. There is now an all-null `n1` 
column plus `setDefaultNullHandlingEnabled(true)`, so the null value vector is 
actually written.
   
   Three tests on top of that: `testArrayBasedNullHandling` asserts the upper 
bound is `2` — one dictionary value plus the reserved id, which is the most 
direct evidence the reservation happened — and `testLongMapBasedNullHandling` / 
`testArrayMapBasedNullHandling` cover the two holders you named. Each asserts 
every group key reads the column back as SQL `NULL`, and the map-based ones 
assert their thread-local map is cleared on close.
   
   Existing tests are unaffected: they pass their own column lists, and the 
other columns still have no nulls, so nothing reserves an id for them.
   



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


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

Reply via email to