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 a2f96c487ec Always support nulls in DataTable serialization (#19201)
a2f96c487ec is described below
commit a2f96c487ec403302648177e88466143d4331977
Author: Xiaotian (Jackie) Jiang <[email protected]>
AuthorDate: Mon Aug 10 11:49:41 2026 -0700
Always support nulls in DataTable serialization (#19201)
---
.../common/datatable/BaseDataTableBuilder.java | 101 ++++++++-
.../core/common/datatable/DataTableBuilder.java | 13 +-
.../common/datatable/DataTableBuilderUtils.java | 9 +-
.../core/common/datatable/DataTableBuilderV4.java | 23 +--
.../apache/pinot/core/data/table/TableResizer.java | 2 +-
.../blocks/results/AggregationResultsBlock.java | 78 ++-----
.../blocks/results/GroupByResultsBlock.java | 77 ++-----
.../query/reduce/AggregationDataTableReducer.java | 83 ++------
.../core/query/reduce/GroupByDataTableReducer.java | 95 +++------
.../core/query/request/context/QueryContext.java | 10 +-
.../core/common/datatable/DataTableSerDeTest.java | 229 +++++++++++++++------
.../function/MaxStringAggregationFunctionTest.java | 70 +++++++
.../core/query/reduce/MergeDataTablesOnlyTest.java | 40 ++++
13 files changed, 494 insertions(+), 336 deletions(-)
diff --git
a/pinot-core/src/main/java/org/apache/pinot/core/common/datatable/BaseDataTableBuilder.java
b/pinot-core/src/main/java/org/apache/pinot/core/common/datatable/BaseDataTableBuilder.java
index db2f7f0214c..eba77e37300 100644
---
a/pinot-core/src/main/java/org/apache/pinot/core/common/datatable/BaseDataTableBuilder.java
+++
b/pinot-core/src/main/java/org/apache/pinot/core/common/datatable/BaseDataTableBuilder.java
@@ -23,19 +23,44 @@ import java.io.DataOutputStream;
import java.io.IOException;
import java.math.BigDecimal;
import java.nio.ByteBuffer;
+import java.util.EnumSet;
import java.util.Map;
import javax.annotation.Nullable;
import org.apache.pinot.common.CustomObject;
import org.apache.pinot.common.datatable.DataTableUtils;
import org.apache.pinot.common.utils.DataSchema;
+import org.apache.pinot.common.utils.DataSchema.ColumnDataType;
+import org.apache.pinot.common.utils.RoaringBitmapUtils;
import org.apache.pinot.core.query.aggregation.function.AggregationFunction;
import org.apache.pinot.spi.utils.BigDecimalUtils;
import org.apache.pinot.spi.utils.ByteArray;
import org.apache.pinot.spi.utils.MapUtils;
+import org.roaringbitmap.RoaringBitmap;
/// Base DataTableBuilder implementation.
+///
+/// Null values are always supported, independent of the query's null-handling
option. [#setNull] accepts any column
+/// type: types that carry their own in-band null encoding keep it, and every
other type is written as the column's
+/// null placeholder with the row recorded in a per-column null bitmap. The
bitmap section is appended by
+/// [#writeNullBitmaps], which subclasses must invoke from `build()` after the
last row.
+///
+/// The bitmap section is emitted only when at least one null was recorded, so
a table without nulls is byte-for-byte
+/// identical to one produced before null bitmaps existed. Readers detect the
section by buffer length and cannot
+/// distinguish "no section because no nulls" from "no section because the
writer never emitted one" -- both mean the
+/// same thing.
public abstract class BaseDataTableBuilder implements DataTableBuilder {
+ /// Column types whose null value is already representable in-band, and
which therefore need no bitmap entry:
+ /// - `OBJECT` and `UNKNOWN` are read back via `DataTable.getCustomObject`,
which returns `null` for the
+ /// [CustomObject#NULL_TYPE_VALUE] marker.
+ /// - `MAP` is read back via `DataTable.getMap`, which returns `null` for a
zero-length entry.
+ ///
+ /// These are also the only types for which the legacy `setNull` encoding
was ever correct: it writes an 8-byte
+ /// (offset, length) pair, which overflows or corrupts the 4-byte slot of an
`INT` / `FLOAT` / `STRING` column and
+ /// decodes as garbage in an 8-byte `LONG` / `DOUBLE` one.
+ private static final EnumSet<ColumnDataType> IN_BAND_NULL_TYPES =
+ EnumSet.of(ColumnDataType.OBJECT, ColumnDataType.UNKNOWN,
ColumnDataType.MAP);
+
protected final DataSchema _dataSchema;
protected final int _version;
protected final int[] _columnOffsets;
@@ -47,6 +72,14 @@ public abstract class BaseDataTableBuilder implements
DataTableBuilder {
protected final DataOutputStream _variableSizeDataOutputStream =
new DataOutputStream(_variableSizeDataByteArrayOutputStream);
+ private final ColumnDataType[] _storedColumnDataTypes;
+ /// Entries are allocated lazily so a table without nulls pays nothing.
+ private final RoaringBitmap[] _nullBitmaps;
+ private boolean _hasNulls;
+ private boolean _nullBitmapsWritten;
+ /// Cursor for the positional [#setNullRowIds] contract.
+ private int _nullRowIdsColId;
+
protected int _numRows;
protected ByteBuffer _currentRowDataByteBuffer;
@@ -55,6 +88,8 @@ public abstract class BaseDataTableBuilder implements
DataTableBuilder {
_version = version;
_columnOffsets = new int[dataSchema.size()];
_rowSizeInBytes = DataTableUtils.computeColumnOffsets(dataSchema,
_columnOffsets, _version);
+ _storedColumnDataTypes = dataSchema.getStoredColumnDataTypes();
+ _nullBitmaps = new RoaringBitmap[dataSchema.size()];
}
@Override
@@ -196,10 +231,27 @@ public abstract class BaseDataTableBuilder implements
DataTableBuilder {
@Override
public void setNull(int colId)
throws IOException {
- _currentRowDataByteBuffer.position(_columnOffsets[colId]);
-
_currentRowDataByteBuffer.putInt(_variableSizeDataByteArrayOutputStream.size());
- _currentRowDataByteBuffer.putInt(0);
- _variableSizeDataOutputStream.writeInt(CustomObject.NULL_TYPE_VALUE);
+ ColumnDataType storedColumnDataType = _storedColumnDataTypes[colId];
+ if (IN_BAND_NULL_TYPES.contains(storedColumnDataType)) {
+ _currentRowDataByteBuffer.position(_columnOffsets[colId]);
+
_currentRowDataByteBuffer.putInt(_variableSizeDataByteArrayOutputStream.size());
+ _currentRowDataByteBuffer.putInt(0);
+ _variableSizeDataOutputStream.writeInt(CustomObject.NULL_TYPE_VALUE);
+ return;
+ }
+ // Resolved on the logical type, not the stored type: UUID overrides
getNullPlaceholder() to return the nil UUID,
+ // whereas its stored type BYTES would yield a zero-length placeholder
that is not a valid UUID.
+ Object nullPlaceholder =
_dataSchema.getColumnDataType(colId).getNullPlaceholder();
+ assert nullPlaceholder != null;
+ DataTableBuilderUtils.setColumn(this, storedColumnDataType, colId,
nullPlaceholder);
+ RoaringBitmap nullBitmap = _nullBitmaps[colId];
+ if (nullBitmap == null) {
+ nullBitmap = new RoaringBitmap();
+ _nullBitmaps[colId] = nullBitmap;
+ _hasNulls = true;
+ }
+ // startRow() has already incremented _numRows for the row being written.
+ nullBitmap.add(_numRows - 1);
}
@Override
@@ -207,4 +259,45 @@ public abstract class BaseDataTableBuilder implements
DataTableBuilder {
throws IOException {
_fixedSizeDataByteArrayOutputStream.write(_currentRowDataByteBuffer.array());
}
+
+ @Override
+ public void setNullRowIds(@Nullable RoaringBitmap nullRowIds) {
+ int colId = _nullRowIdsColId++;
+ if (nullRowIds == null || nullRowIds.isEmpty()) {
+ return;
+ }
+ RoaringBitmap nullBitmap = _nullBitmaps[colId];
+ if (nullBitmap == null) {
+ // Copy rather than alias: the caller retains ownership of the bitmap it
passed in.
+ nullBitmap = new RoaringBitmap();
+ _nullBitmaps[colId] = nullBitmap;
+ _hasNulls = true;
+ }
+ nullBitmap.or(nullRowIds);
+ }
+
+ /// Appends the per-column null bitmap section to the fixed and variable
size buffers. Subclasses must invoke this
+ /// from `build()`, after every row has been written and before the buffers
are handed off.
+ ///
+ /// Writes nothing when no null was recorded, keeping such tables
byte-for-byte identical to the pre-null-bitmap
+ /// format. The section is all-or-nothing across columns because readers
index into it at a fixed stride.
+ ///
+ /// Idempotent, so that `build()` can be invoked more than once on the same
builder.
+ protected void writeNullBitmaps()
+ throws IOException {
+ if (!_hasNulls || _nullBitmapsWritten) {
+ return;
+ }
+ _nullBitmapsWritten = true;
+ for (RoaringBitmap nullBitmap : _nullBitmaps) {
+
_fixedSizeDataOutputStream.writeInt(_variableSizeDataByteArrayOutputStream.size());
+ if (nullBitmap == null) {
+ _fixedSizeDataOutputStream.writeInt(0);
+ } else {
+ byte[] bitmapBytes = RoaringBitmapUtils.serialize(nullBitmap);
+ _fixedSizeDataOutputStream.writeInt(bitmapBytes.length);
+ _variableSizeDataByteArrayOutputStream.write(bitmapBytes);
+ }
+ }
+ }
}
diff --git
a/pinot-core/src/main/java/org/apache/pinot/core/common/datatable/DataTableBuilder.java
b/pinot-core/src/main/java/org/apache/pinot/core/common/datatable/DataTableBuilder.java
index 0348b2f0d4c..7682b225147 100644
---
a/pinot-core/src/main/java/org/apache/pinot/core/common/datatable/DataTableBuilder.java
+++
b/pinot-core/src/main/java/org/apache/pinot/core/common/datatable/DataTableBuilder.java
@@ -92,14 +92,23 @@ public interface DataTableBuilder {
void setColumn(int colId, AggregationFunction.SerializedIntermediateResult
value)
throws IOException;
+ /// Writes a `null` for the given column of the current row.
+ ///
+ /// Valid for every column type, independent of the query's null-handling
option. Types that can represent `null`
+ /// in-band (`OBJECT`, `UNKNOWN`, `MAP`) keep their own encoding; every
other type is written as the column's null
+ /// placeholder and the row is recorded in a per-column null bitmap that
`build()` appends to the table. Readers
+ /// restore the `null` via `DataTable.getNullRowIds`.
void setNull(int colId)
throws IOException;
void finishRow()
throws IOException;
- /// NOTE: When setting nullRowIds, we don't pass the colId currently, and
this method must be invoked for all columns.
- /// TODO: Revisit this
+ /// Merges a pre-computed null bitmap into the bitmap the builder maintains
for the next column.
+ ///
+ /// NOTE: The colId is positional -- the first call targets column 0, the
second column 1, and so on -- so callers
+ /// that use this method must invoke it once for every column, in order.
Callers that instead report nulls per cell
+ /// through [#setNull] need not call this at all.
void setNullRowIds(@Nullable RoaringBitmap nullRowIds)
throws IOException;
diff --git
a/pinot-core/src/main/java/org/apache/pinot/core/common/datatable/DataTableBuilderUtils.java
b/pinot-core/src/main/java/org/apache/pinot/core/common/datatable/DataTableBuilderUtils.java
index cee3fe689f2..456a0ecb63b 100644
---
a/pinot-core/src/main/java/org/apache/pinot/core/common/datatable/DataTableBuilderUtils.java
+++
b/pinot-core/src/main/java/org/apache/pinot/core/common/datatable/DataTableBuilderUtils.java
@@ -35,10 +35,11 @@ public class DataTableBuilderUtils {
private DataTableBuilderUtils() {
}
- /// Writes a non-null value of the given stored column data type into the
[DataTableBuilder] at
- /// the given column. Supports all scalar and array stored types. OBJECT
columns are NOT handled here
- /// (callers serialize them via the owning aggregation function). Used by
the group-by result
- /// serialization on both the server (`GroupByResultsBlock`) and the
merge-only reduce path.
+ /// Writes a non-null value of the given stored column data type into the
[DataTableBuilder] at the given column.
+ ///
+ /// Supports all scalar and array stored types. `OBJECT`, `UNKNOWN` and
`MAP` are NOT handled here -- they are
+ /// written through the builder's own type-specific methods, since their
values are either serialized by an owning
+ /// aggregation function or already carry an in-band null encoding.
public static void setColumn(DataTableBuilder dataTableBuilder,
ColumnDataType storedColumnDataType,
int columnIndex, Object value)
throws IOException {
diff --git
a/pinot-core/src/main/java/org/apache/pinot/core/common/datatable/DataTableBuilderV4.java
b/pinot-core/src/main/java/org/apache/pinot/core/common/datatable/DataTableBuilderV4.java
index df18e73dc4b..f4da8848140 100644
---
a/pinot-core/src/main/java/org/apache/pinot/core/common/datatable/DataTableBuilderV4.java
+++
b/pinot-core/src/main/java/org/apache/pinot/core/common/datatable/DataTableBuilderV4.java
@@ -21,14 +21,12 @@ package org.apache.pinot.core.common.datatable;
import it.unimi.dsi.fastutil.objects.Object2IntMap;
import it.unimi.dsi.fastutil.objects.Object2IntOpenHashMap;
import java.io.IOException;
-import javax.annotation.Nullable;
+import java.io.UncheckedIOException;
import org.apache.pinot.common.datatable.DataTable;
import org.apache.pinot.common.datatable.DataTableFactory;
import org.apache.pinot.common.datatable.DataTableImplV4;
import org.apache.pinot.common.utils.DataSchema;
-import org.apache.pinot.common.utils.RoaringBitmapUtils;
import org.apache.pinot.spi.utils.ByteArray;
-import org.roaringbitmap.RoaringBitmap;
public class DataTableBuilderV4 extends BaseDataTableBuilder {
@@ -67,21 +65,14 @@ public class DataTableBuilderV4 extends
BaseDataTableBuilder {
}
}
- @Override
- public void setNullRowIds(@Nullable RoaringBitmap nullRowIds)
- throws IOException {
-
_fixedSizeDataOutputStream.writeInt(_variableSizeDataByteArrayOutputStream.size());
- if (nullRowIds == null || nullRowIds.isEmpty()) {
- _fixedSizeDataOutputStream.writeInt(0);
- } else {
- byte[] bitmapBytes = RoaringBitmapUtils.serialize(nullRowIds);
- _fixedSizeDataOutputStream.writeInt(bitmapBytes.length);
- _variableSizeDataByteArrayOutputStream.write(bitmapBytes);
- }
- }
-
@Override
public DataTable build() {
+ try {
+ writeNullBitmaps();
+ } catch (IOException e) {
+ // Both buffers are in-memory byte arrays, so this cannot happen.
+ throw new UncheckedIOException(e);
+ }
String[] reverseDictionary = new String[_dictionary.size()];
for (Object2IntMap.Entry<String> entry : _dictionary.object2IntEntrySet())
{
reverseDictionary[entry.getIntValue()] = entry.getKey();
diff --git
a/pinot-core/src/main/java/org/apache/pinot/core/data/table/TableResizer.java
b/pinot-core/src/main/java/org/apache/pinot/core/data/table/TableResizer.java
index 82d43759546..d17a14b2772 100644
---
a/pinot-core/src/main/java/org/apache/pinot/core/data/table/TableResizer.java
+++
b/pinot-core/src/main/java/org/apache/pinot/core/data/table/TableResizer.java
@@ -107,7 +107,7 @@ public class TableResizer {
}
/// Grouping-set queries produce genuine null group-key values for
rolled-up columns regardless of the
/// user's null-handling option, so ORDER BY over a grouping column must
use the null-safe comparator.
- boolean nullHandlingEnabled =
queryContext.requiresNullAwareKeySerialization();
+ boolean nullHandlingEnabled =
queryContext.requiresNullAwareKeyEvaluation();
if (nullHandlingEnabled) {
_intermediateRecordComparator = (o1, o2) -> {
for (int i = 0; i < _numOrderByExpressions; i++) {
diff --git
a/pinot-core/src/main/java/org/apache/pinot/core/operator/blocks/results/AggregationResultsBlock.java
b/pinot-core/src/main/java/org/apache/pinot/core/operator/blocks/results/AggregationResultsBlock.java
index 7e9ace4afdd..141367beb3e 100644
---
a/pinot-core/src/main/java/org/apache/pinot/core/operator/blocks/results/AggregationResultsBlock.java
+++
b/pinot-core/src/main/java/org/apache/pinot/core/operator/blocks/results/AggregationResultsBlock.java
@@ -38,7 +38,6 @@ import
org.apache.pinot.core.query.aggregation.function.AggregationFunction;
import
org.apache.pinot.core.query.aggregation.function.AggregationFunctionUtils;
import org.apache.pinot.core.query.request.context.QueryContext;
import org.apache.pinot.spi.utils.ByteArray;
-import org.roaringbitmap.RoaringBitmap;
/// Results block for aggregation queries.
@@ -124,73 +123,32 @@ public class AggregationResultsBlock extends
BaseResultsBlock {
return dataTableBuilder.build();
}
- boolean returnFinalResult = _queryContext.isServerReturnFinalResult();
- if (_queryContext.isNullHandlingEnabled()) {
- RoaringBitmap[] nullBitmaps = new RoaringBitmap[numColumns];
+ // NOTE: Nulls are serialized through the builder's null-aware path
regardless of the query's null-handling
+ // option. Aggregation functions whose accumulator has no identity element
(MINSTRING, MAXSTRING, ANYVALUE)
+ // return a null intermediate result in both modes, and their result
column type is not OBJECT.
+ dataTableBuilder.startRow();
+ if (_queryContext.isServerReturnFinalResult()) {
for (int i = 0; i < numColumns; i++) {
- nullBitmaps[i] = new RoaringBitmap();
- }
- dataTableBuilder.startRow();
- if (returnFinalResult) {
- for (int i = 0; i < numColumns; i++) {
- Object result =
_aggregationFunctions[i].extractFinalResult(_results.get(i));
- if (result == null) {
- result = columnDataTypes[i].getNullPlaceholder();
- nullBitmaps[i].add(0);
- }
- assert result != null;
+ Object result =
_aggregationFunctions[i].extractFinalResult(_results.get(i));
+ if (result == null) {
+ dataTableBuilder.setNull(i);
+ } else {
setFinalResult(dataTableBuilder, columnDataTypes, i, result);
}
- } else {
- for (int i = 0; i < numColumns; i++) {
- Object result = _results.get(i);
- if (columnDataTypes[i] == ColumnDataType.OBJECT) {
- if (result == null) {
- dataTableBuilder.setNull(i);
- } else {
- dataTableBuilder.setColumn(i,
_aggregationFunctions[i].serializeIntermediateResult(result));
- }
- } else {
- if (result == null) {
- result = columnDataTypes[i].getNullPlaceholder();
- nullBitmaps[i].add(0);
- }
- assert result != null;
- AggregationFunctionUtils.setIntermediateResult(dataTableBuilder,
columnDataTypes[i], i, result);
- }
- }
- }
- dataTableBuilder.finishRow();
- for (RoaringBitmap nullBitmap : nullBitmaps) {
- dataTableBuilder.setNullRowIds(nullBitmap);
}
} else {
- dataTableBuilder.startRow();
- if (returnFinalResult) {
- for (int i = 0; i < numColumns; i++) {
- Object result =
_aggregationFunctions[i].extractFinalResult(_results.get(i));
- if (result == null) {
- dataTableBuilder.setNull(i);
- } else {
- setFinalResult(dataTableBuilder, columnDataTypes, i, result);
- }
- }
- } else {
- for (int i = 0; i < numColumns; i++) {
- Object result = _results.get(i);
- if (result == null) {
- dataTableBuilder.setNull(i);
- } else {
- if (columnDataTypes[i] == ColumnDataType.OBJECT) {
- dataTableBuilder.setColumn(i,
_aggregationFunctions[i].serializeIntermediateResult(result));
- } else {
- AggregationFunctionUtils.setIntermediateResult(dataTableBuilder,
columnDataTypes[i], i, result);
- }
- }
+ for (int i = 0; i < numColumns; i++) {
+ Object result = _results.get(i);
+ if (result == null) {
+ dataTableBuilder.setNull(i);
+ } else if (columnDataTypes[i] == ColumnDataType.OBJECT) {
+ dataTableBuilder.setColumn(i,
_aggregationFunctions[i].serializeIntermediateResult(result));
+ } else {
+ AggregationFunctionUtils.setIntermediateResult(dataTableBuilder,
columnDataTypes[i], i, result);
}
}
- dataTableBuilder.finishRow();
}
+ dataTableBuilder.finishRow();
return dataTableBuilder.build();
}
diff --git
a/pinot-core/src/main/java/org/apache/pinot/core/operator/blocks/results/GroupByResultsBlock.java
b/pinot-core/src/main/java/org/apache/pinot/core/operator/blocks/results/GroupByResultsBlock.java
index eecffa29433..1ffc5b8ee93 100644
---
a/pinot-core/src/main/java/org/apache/pinot/core/operator/blocks/results/GroupByResultsBlock.java
+++
b/pinot-core/src/main/java/org/apache/pinot/core/operator/blocks/results/GroupByResultsBlock.java
@@ -38,7 +38,6 @@ import
org.apache.pinot.core.query.aggregation.function.AggregationFunction;
import
org.apache.pinot.core.query.aggregation.groupby.AggregationGroupByResult;
import org.apache.pinot.core.query.request.context.QueryContext;
import org.apache.pinot.spi.query.QueryThreadContext;
-import org.roaringbitmap.RoaringBitmap;
/// Results block for group-by queries.
@@ -201,66 +200,26 @@ public class GroupByResultsBlock extends BaseResultsBlock
{
int numKeyColumns = _queryContext.getNumGroupByKeyColumns();
Iterator<Record> iterator = _table.iterator();
int numRowsAdded = 0;
- /// Grouping sets produce NULL group keys (rolled-up columns) regardless
of the user's null-handling
- /// option, so they must be serialized through the null-aware path (null
bitmaps) to survive the DataTable
- /// round-trip -- the non-null path's setNull() writes to the variable
buffer that the reducer does not
- /// read back for fixed-width key columns.
- if (_queryContext.requiresNullAwareKeySerialization()) {
- RoaringBitmap[] nullBitmaps = new RoaringBitmap[numColumns];
- Object[] nullPlaceholders = new Object[numColumns];
- for (int colId = 0; colId < numColumns; colId++) {
- nullBitmaps[colId] = new RoaringBitmap();
- // Resolved on the logical type, not the stored type: UUID overrides
getNullPlaceholder() to return the nil
- // UUID, whereas its stored type BYTES would yield a zero-length
placeholder that is not a valid UUID.
- nullPlaceholders[colId] =
_dataSchema.getColumnDataType(colId).getNullPlaceholder();
- }
- int rowId = 0;
- while (iterator.hasNext()) {
-
QueryThreadContext.checkTerminationAndSampleUsagePeriodically(numRowsAdded,
"GroupByResultsBlock#getDataTable");
- dataTableBuilder.startRow();
- Object[] values = iterator.next().getValues();
- for (int i = 0; i < numColumns; i++) {
- Object value = values[i];
- if (storedColumnDataTypes[i] == ColumnDataType.OBJECT) {
- if (value == null) {
- dataTableBuilder.setNull(i);
- } else {
- dataTableBuilder.setColumn(i, aggregationFunctions[i -
numKeyColumns].serializeIntermediateResult(value));
- }
- } else {
- if (value == null) {
- value = nullPlaceholders[i];
- nullBitmaps[i].add(rowId);
- }
- assert value != null;
- DataTableBuilderUtils.setColumn(dataTableBuilder,
storedColumnDataTypes[i], i, value);
- }
- }
- dataTableBuilder.finishRow();
- numRowsAdded++;
- rowId++;
- }
- for (RoaringBitmap nullBitmap : nullBitmaps) {
- dataTableBuilder.setNullRowIds(nullBitmap);
- }
- } else {
- while (iterator.hasNext()) {
-
QueryThreadContext.checkTerminationAndSampleUsagePeriodically(numRowsAdded,
"GroupByResultsBlock#getDataTable");
- dataTableBuilder.startRow();
- Object[] values = iterator.next().getValues();
- for (int i = 0; i < numColumns; i++) {
- Object value = values[i];
- if (value == null) {
- dataTableBuilder.setNull(i);
- } else if (storedColumnDataTypes[i] == ColumnDataType.OBJECT) {
- dataTableBuilder.setColumn(i, aggregationFunctions[i -
numKeyColumns].serializeIntermediateResult(value));
- } else {
- DataTableBuilderUtils.setColumn(dataTableBuilder,
storedColumnDataTypes[i], i, value);
- }
+ // NOTE: Nulls are serialized through the builder's null-aware path
regardless of the query's null-handling
+ // option. Grouping sets produce NULL group keys for rolled-up columns,
and aggregation functions whose
+ // accumulator has no identity element (MINSTRING, MAXSTRING, ANYVALUE)
produce null intermediate results, in
+ // both modes.
+ while (iterator.hasNext()) {
+
QueryThreadContext.checkTerminationAndSampleUsagePeriodically(numRowsAdded,
"GroupByResultsBlock#getDataTable");
+ dataTableBuilder.startRow();
+ Object[] values = iterator.next().getValues();
+ for (int i = 0; i < numColumns; i++) {
+ Object value = values[i];
+ if (value == null) {
+ dataTableBuilder.setNull(i);
+ } else if (storedColumnDataTypes[i] == ColumnDataType.OBJECT) {
+ dataTableBuilder.setColumn(i, aggregationFunctions[i -
numKeyColumns].serializeIntermediateResult(value));
+ } else {
+ DataTableBuilderUtils.setColumn(dataTableBuilder,
storedColumnDataTypes[i], i, value);
}
- dataTableBuilder.finishRow();
- numRowsAdded++;
}
+ dataTableBuilder.finishRow();
+ numRowsAdded++;
}
return dataTableBuilder.build();
}
diff --git
a/pinot-core/src/main/java/org/apache/pinot/core/query/reduce/AggregationDataTableReducer.java
b/pinot-core/src/main/java/org/apache/pinot/core/query/reduce/AggregationDataTableReducer.java
index 3802a1064c0..7ddceb00b15 100644
---
a/pinot-core/src/main/java/org/apache/pinot/core/query/reduce/AggregationDataTableReducer.java
+++
b/pinot-core/src/main/java/org/apache/pinot/core/query/reduce/AggregationDataTableReducer.java
@@ -102,14 +102,11 @@ public class AggregationDataTableReducer implements
DataTableReducer {
AggregationFunction aggregationFunction = _aggregationFunctions[i];
Object intermediateResultToMerge;
ColumnDataType columnDataType = dataSchema.getColumnDataType(i);
- if (_queryContext.isNullHandlingEnabled()) {
- RoaringBitmap nullBitmap = dataTable.getNullRowIds(i);
- if (nullBitmap != null && nullBitmap.contains(0)) {
- intermediateResultToMerge = null;
- } else {
- intermediateResultToMerge =
-
AggregationFunctionUtils.getIntermediateResult(aggregationFunction, dataTable,
columnDataType, 0, i);
- }
+ // Nulls are restored regardless of the query's null-handling option:
an aggregation function whose
+ // accumulator has no identity element returns a null intermediate
result in both modes.
+ RoaringBitmap nullBitmap = dataTable.getNullRowIds(i);
+ if (nullBitmap != null && nullBitmap.contains(0)) {
+ intermediateResultToMerge = null;
} else {
intermediateResultToMerge =
AggregationFunctionUtils.getIntermediateResult(aggregationFunction, dataTable,
columnDataType, 0, i);
@@ -154,48 +151,18 @@ public class AggregationDataTableReducer implements
DataTableReducer {
ColumnDataType[] columnDataTypes = dataSchema.getColumnDataTypes();
int numColumns = columnDataTypes.length;
DataTableBuilder dataTableBuilder =
DataTableBuilderFactory.getDataTableBuilder(dataSchema);
- if (_queryContext.isNullHandlingEnabled()) {
- RoaringBitmap[] nullBitmaps = new RoaringBitmap[numColumns];
- for (int i = 0; i < numColumns; i++) {
- nullBitmaps[i] = new RoaringBitmap();
- }
- dataTableBuilder.startRow();
- for (int i = 0; i < numColumns; i++) {
- Object result = intermediateResults[i];
- if (columnDataTypes[i] == ColumnDataType.OBJECT) {
- if (result == null) {
- dataTableBuilder.setNull(i);
- } else {
- dataTableBuilder.setColumn(i,
_aggregationFunctions[i].serializeIntermediateResult(result));
- }
- } else {
- if (result == null) {
- result = columnDataTypes[i].getNullPlaceholder();
- nullBitmaps[i].add(0);
- }
- AggregationFunctionUtils.setIntermediateResult(dataTableBuilder,
columnDataTypes[i], i, result);
- }
- }
- dataTableBuilder.finishRow();
- for (RoaringBitmap nullBitmap : nullBitmaps) {
- dataTableBuilder.setNullRowIds(nullBitmap);
- }
- } else {
- dataTableBuilder.startRow();
- for (int i = 0; i < numColumns; i++) {
- Object result = intermediateResults[i];
- if (result == null) {
- dataTableBuilder.setNull(i);
- } else {
- if (columnDataTypes[i] == ColumnDataType.OBJECT) {
- dataTableBuilder.setColumn(i,
_aggregationFunctions[i].serializeIntermediateResult(result));
- } else {
- AggregationFunctionUtils.setIntermediateResult(dataTableBuilder,
columnDataTypes[i], i, result);
- }
- }
+ dataTableBuilder.startRow();
+ for (int i = 0; i < numColumns; i++) {
+ Object result = intermediateResults[i];
+ if (result == null) {
+ dataTableBuilder.setNull(i);
+ } else if (columnDataTypes[i] == ColumnDataType.OBJECT) {
+ dataTableBuilder.setColumn(i,
_aggregationFunctions[i].serializeIntermediateResult(result));
+ } else {
+ AggregationFunctionUtils.setIntermediateResult(dataTableBuilder,
columnDataTypes[i], i, result);
}
- dataTableBuilder.finishRow();
}
+ dataTableBuilder.finishRow();
return dataTableBuilder.build();
}
@@ -205,13 +172,9 @@ public class AggregationDataTableReducer implements
DataTableReducer {
Object[] finalResults = new Object[numAggregationFunctions];
for (int i = 0; i < numAggregationFunctions; i++) {
ColumnDataType columnDataType = dataSchema.getColumnDataType(i);
- if (_queryContext.isNullHandlingEnabled()) {
- RoaringBitmap nullBitmap = dataTable.getNullRowIds(i);
- if (nullBitmap != null && nullBitmap.contains(0)) {
- finalResults[i] = null;
- } else {
- finalResults[i] =
AggregationFunctionUtils.getConvertedFinalResult(dataTable, columnDataType, 0,
i);
- }
+ RoaringBitmap nullBitmap = dataTable.getNullRowIds(i);
+ if (nullBitmap != null && nullBitmap.contains(0)) {
+ finalResults[i] = null;
} else {
finalResults[i] =
AggregationFunctionUtils.getConvertedFinalResult(dataTable, columnDataType, 0,
i);
}
@@ -228,13 +191,9 @@ public class AggregationDataTableReducer implements
DataTableReducer {
QueryThreadContext.checkTerminationAndSampleUsage("AggregationDataTableReducer");
Comparable finalResultToMerge;
ColumnDataType columnDataType = dataSchema.getColumnDataType(i);
- if (_queryContext.isNullHandlingEnabled()) {
- RoaringBitmap nullBitmap = dataTable.getNullRowIds(i);
- if (nullBitmap != null && nullBitmap.contains(0)) {
- finalResultToMerge = null;
- } else {
- finalResultToMerge =
AggregationFunctionUtils.getFinalResult(dataTable, columnDataType, 0, i);
- }
+ RoaringBitmap nullBitmap = dataTable.getNullRowIds(i);
+ if (nullBitmap != null && nullBitmap.contains(0)) {
+ finalResultToMerge = null;
} else {
finalResultToMerge =
AggregationFunctionUtils.getFinalResult(dataTable, columnDataType, 0, i);
}
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 3a987a4b5fd..c1add8ca708 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
@@ -206,7 +206,7 @@ public class GroupByDataTableReducer implements
DataTableReducer {
if (havingFilter != null) {
rows = new ArrayList<>();
HavingFilterHandler havingFilterHandler = new
HavingFilterHandler(havingFilter, postAggregationHandler,
- _queryContext.requiresNullAwareKeySerialization());
+ _queryContext.requiresNullAwareKeyEvaluation());
int processedRows = 0;
while (rows.size() < limit && sortedIterator.hasNext()) {
QueryThreadContext.checkTerminationAndSampleUsagePeriodically(processedRows++,
"GroupByDataTableReducer");
@@ -293,15 +293,15 @@ public class GroupByDataTableReducer implements
DataTableReducer {
public void runJob() {
try {
for (DataTable dataTable : reduceGroup) {
- /// Grouping-set queries serialize NULL group keys via null
bitmaps regardless of the user's
- /// null-handling option, so their key nulls must be restored
here as well.
- boolean restoreNulls =
_queryContext.requiresNullAwareKeySerialization();
- RoaringBitmap[] nullBitmaps = null;
- if (restoreNulls) {
- nullBitmaps = new RoaringBitmap[_numColumns];
- for (int i = 0; i < _numColumns; i++) {
- nullBitmaps[i] = dataTable.getNullRowIds(i);
- }
+ // Nulls are restored regardless of the query's null-handling
option: a DataTable carries a null bitmap
+ // whenever the producer wrote a null, which happens in both
modes. Tables without nulls have no bitmap
+ // section at all, so getNullRowIds() returns null for every
column and the per-row restore is skipped.
+ RoaringBitmap[] nullBitmaps = new RoaringBitmap[_numColumns];
+ boolean restoreNulls = false;
+ for (int i = 0; i < _numColumns; i++) {
+ RoaringBitmap nullBitmap = dataTable.getNullRowIds(i);
+ nullBitmaps[i] = nullBitmap;
+ restoreNulls |= nullBitmap != null;
}
int numRows = dataTable.getNumberOfRows();
@@ -454,7 +454,7 @@ public class GroupByDataTableReducer implements
DataTableReducer {
if (havingFilter != null) {
rows = new ArrayList<>();
HavingFilterHandler havingFilterHandler = new
HavingFilterHandler(havingFilter, postAggregationHandler,
- _queryContext.requiresNullAwareKeySerialization());
+ _queryContext.requiresNullAwareKeyEvaluation());
for (int i = 0; i < numRows; i++) {
Object[] row = getConvertedRowWithFinalResult(dataTable, i);
if (havingFilterHandler.isMatch(row)) {
@@ -546,64 +546,25 @@ public class GroupByDataTableReducer implements
DataTableReducer {
DataTableBuilder dataTableBuilder =
DataTableBuilderFactory.getDataTableBuilder(dataSchema);
ColumnDataType[] storedColumnDataTypes =
dataSchema.getStoredColumnDataTypes();
Iterator<Record> iterator = indexedTable.iterator();
- /// Grouping-set queries carry NULL group keys that must be serialized via
the null-aware path even when
- /// the user did not enable null handling.
- if (_queryContext.requiresNullAwareKeySerialization()) {
- RoaringBitmap[] nullBitmaps = new RoaringBitmap[_numColumns];
- Object[] nullPlaceholders = new Object[_numColumns];
- for (int colId = 0; colId < _numColumns; colId++) {
- nullBitmaps[colId] = new RoaringBitmap();
- // Resolved on the logical type, not the stored type: UUID overrides
getNullPlaceholder() to return the nil
- // UUID, whereas its stored type BYTES would yield a zero-length
placeholder that is not a valid UUID.
- nullPlaceholders[colId] =
dataSchema.getColumnDataType(colId).getNullPlaceholder();
- }
- int rowId = 0;
- while (iterator.hasNext()) {
- QueryThreadContext.checkTerminationAndSampleUsagePeriodically(rowId,
"GroupByDataTableReducer#merge");
- dataTableBuilder.startRow();
- Object[] values = iterator.next().getValues();
- for (int i = 0; i < _numColumns; i++) {
- Object value = values[i];
- if (storedColumnDataTypes[i] == ColumnDataType.OBJECT) {
- if (value == null) {
- dataTableBuilder.setNull(i);
- } else {
- dataTableBuilder.setColumn(i,
- _aggregationFunctions[i -
_numKeyColumns].serializeIntermediateResult(value));
- }
- } else {
- if (value == null) {
- value = nullPlaceholders[i];
- nullBitmaps[i].add(rowId);
- }
- DataTableBuilderUtils.setColumn(dataTableBuilder,
storedColumnDataTypes[i], i, value);
- }
- }
- dataTableBuilder.finishRow();
- rowId++;
- }
- for (RoaringBitmap nullBitmap : nullBitmaps) {
- dataTableBuilder.setNullRowIds(nullBitmap);
- }
- } else {
- int rowId = 0;
- while (iterator.hasNext()) {
- QueryThreadContext.checkTerminationAndSampleUsagePeriodically(rowId++,
"GroupByDataTableReducer#merge");
- dataTableBuilder.startRow();
- Object[] values = iterator.next().getValues();
- for (int i = 0; i < _numColumns; i++) {
- Object value = values[i];
- if (value == null) {
- dataTableBuilder.setNull(i);
- } else if (storedColumnDataTypes[i] == ColumnDataType.OBJECT) {
- dataTableBuilder.setColumn(i,
- _aggregationFunctions[i -
_numKeyColumns].serializeIntermediateResult(value));
- } else {
- DataTableBuilderUtils.setColumn(dataTableBuilder,
storedColumnDataTypes[i], i, value);
- }
+ // NOTE: Nulls are serialized through the builder's null-aware path
regardless of the query's null-handling
+ // option -- grouping sets carry NULL group keys, and aggregation
functions whose accumulator has no identity
+ // element produce null intermediate results, in both modes.
+ int rowId = 0;
+ while (iterator.hasNext()) {
+ QueryThreadContext.checkTerminationAndSampleUsagePeriodically(rowId++,
"GroupByDataTableReducer#merge");
+ dataTableBuilder.startRow();
+ Object[] values = iterator.next().getValues();
+ for (int i = 0; i < _numColumns; i++) {
+ Object value = values[i];
+ if (value == null) {
+ dataTableBuilder.setNull(i);
+ } else if (storedColumnDataTypes[i] == ColumnDataType.OBJECT) {
+ dataTableBuilder.setColumn(i, _aggregationFunctions[i -
_numKeyColumns].serializeIntermediateResult(value));
+ } else {
+ DataTableBuilderUtils.setColumn(dataTableBuilder,
storedColumnDataTypes[i], i, value);
}
- dataTableBuilder.finishRow();
}
+ dataTableBuilder.finishRow();
}
return dataTableBuilder.build();
}
diff --git
a/pinot-core/src/main/java/org/apache/pinot/core/query/request/context/QueryContext.java
b/pinot-core/src/main/java/org/apache/pinot/core/query/request/context/QueryContext.java
index b3fcd8f9b06..c167d752217 100644
---
a/pinot-core/src/main/java/org/apache/pinot/core/query/request/context/QueryContext.java
+++
b/pinot-core/src/main/java/org/apache/pinot/core/query/request/context/QueryContext.java
@@ -257,10 +257,14 @@ public class QueryContext {
return numGroupByExpressions + getNumExtraGroupByKeyColumns();
}
- /// Returns whether group-by key columns must be serialized/deserialized
through the null-aware path (null
- /// bitmaps). True when the user enabled null handling, or for grouping-set
queries, which produce NULL keys
+ /// Returns whether group-by key columns must be compared and filtered
null-aware, i.e. whether ORDER BY
+ /// comparators and HAVING predicates over group keys have to treat `null`
as a distinct value rather than as the
+ /// type's default. True when the user enabled null handling, or for
grouping-set queries, which produce NULL keys
/// for rolled-up columns regardless of the user's null-handling option.
- public boolean requiresNullAwareKeySerialization() {
+ ///
+ /// NOTE: This does not gate serialization --
[org.apache.pinot.core.common.datatable.DataTableBuilder] always
+ /// supports nulls, so a null key or intermediate result round-trips through
a DataTable in either mode.
+ public boolean requiresNullAwareKeyEvaluation() {
return isNullHandlingEnabled() || isGroupingSets();
}
diff --git
a/pinot-core/src/test/java/org/apache/pinot/core/common/datatable/DataTableSerDeTest.java
b/pinot-core/src/test/java/org/apache/pinot/core/common/datatable/DataTableSerDeTest.java
index 117359b48a9..91e46c537c9 100644
---
a/pinot-core/src/test/java/org/apache/pinot/core/common/datatable/DataTableSerDeTest.java
+++
b/pinot-core/src/test/java/org/apache/pinot/core/common/datatable/DataTableSerDeTest.java
@@ -37,10 +37,11 @@ import org.apache.pinot.spi.exception.QueryErrorCode;
import org.apache.pinot.spi.utils.ByteArray;
import org.apache.pinot.spi.utils.UuidUtils;
import org.roaringbitmap.RoaringBitmap;
-import org.testng.Assert;
import org.testng.annotations.DataProvider;
import org.testng.annotations.Test;
+import static org.testng.Assert.*;
+
/// Unit test for [DataTable] serialization/de-serialization.
public class DataTableSerDeTest {
@@ -83,11 +84,11 @@ public class DataTableSerDeTest {
DataTable dataTable = DataTableBuilderFactory.getEmptyDataTable();
dataTable.addException(QueryErrorCode.QUERY_EXECUTION, expected);
DataTable newDataTable =
DataTableFactory.getDataTable(dataTable.toBytes());
- Assert.assertNull(newDataTable.getDataSchema());
- Assert.assertEquals(newDataTable.getNumberOfRows(), 0);
+ assertNull(newDataTable.getDataSchema());
+ assertEquals(newDataTable.getNumberOfRows(), 0);
String actual =
newDataTable.getExceptions().get(QueryErrorCode.QUERY_EXECUTION.getId());
- Assert.assertEquals(actual, expected);
+ assertEquals(actual, expected);
}
@Test(dataProvider = "versionProvider")
@@ -176,7 +177,7 @@ public class DataTableSerDeTest {
} else if (emptyValue instanceof ByteArray) {
dataTableBuilder.setColumn(columnId, (ByteArray) emptyValue);
} else {
- Assert.fail();
+ fail();
}
}
dataTableBuilder.finishRow();
@@ -184,8 +185,8 @@ public class DataTableSerDeTest {
DataTable dataTable = dataTableBuilder.build();
DataTable newDataTable =
DataTableFactory.getDataTable(dataTable.toBytes());
- Assert.assertEquals(newDataTable.getDataSchema(), dataSchema);
- Assert.assertEquals(newDataTable.getNumberOfRows(), numRows);
+ assertEquals(newDataTable.getDataSchema(), dataSchema);
+ assertEquals(newDataTable.getNumberOfRows(), numRows);
for (int rowId = 0; rowId < numRows; rowId++) {
for (int columnId = 0; columnId < dataSchema.size(); columnId++) {
@@ -217,7 +218,7 @@ public class DataTableSerDeTest {
entry = newDataTable.getCustomObject(rowId, columnId);
break;
}
- Assert.assertEquals(entry, emptyValues[columnId]);
+ assertEquals(entry, emptyValues[columnId]);
}
}
}
@@ -239,8 +240,8 @@ public class DataTableSerDeTest {
DataTable dataTable = dataTableBuilder.build();
DataTable newDataTable =
DataTableFactory.getDataTable(dataTable.toBytes());
- Assert.assertEquals(newDataTable.getDataSchema(), dataSchema,
ERROR_MESSAGE);
- Assert.assertEquals(newDataTable.getNumberOfRows(), NUM_ROWS,
ERROR_MESSAGE);
+ assertEquals(newDataTable.getDataSchema(), dataSchema, ERROR_MESSAGE);
+ assertEquals(newDataTable.getNumberOfRows(), NUM_ROWS, ERROR_MESSAGE);
verifyDataIsSame(newDataTable, columnDataTypes, numColumns);
}
@@ -259,15 +260,136 @@ public class DataTableSerDeTest {
fillDataTableWithRandomData(dataTableBuilder, columnDataType, 1,
numRows);
DataTable dataTable = dataTableBuilder.build();
DataTable newDataTable =
DataTableFactory.getDataTable(dataTable.toBytes());
- Assert.assertEquals(newDataTable.getDataSchema(), dataSchema,
ERROR_MESSAGE);
- Assert.assertEquals(newDataTable.getNumberOfRows(), numRows,
ERROR_MESSAGE);
+ assertEquals(newDataTable.getDataSchema(), dataSchema, ERROR_MESSAGE);
+ assertEquals(newDataTable.getNumberOfRows(), numRows, ERROR_MESSAGE);
verifyDataIsSame(newDataTable, columnDataType, 1, numRows);
}
}
}
+ /// [DataTableBuilder#setNull] must round-trip a null for every column type,
not just the ones that can encode a
+ /// null in-band. A type stored in a 4-byte slot (`INT` / `FLOAT` / `STRING`
/ `BOOLEAN`) is the sharpest case:
+ /// writing the 8-byte custom-object encoding into it overflows the row
buffer when the column is last, and
+ /// silently overwrites the following column otherwise.
+ @Test(dataProvider = "versionProvider")
+ public void testSetNullForAllDataTypes(int dataTableVersion)
+ throws IOException {
+ DataTableBuilderFactory.setDataTableVersion(dataTableVersion);
+ for (DataSchema.ColumnDataType columnDataType :
DataSchema.ColumnDataType.values()) {
+ DataSchema dataSchema =
+ new DataSchema(new String[]{columnDataType.name()}, new
DataSchema.ColumnDataType[]{columnDataType});
+ DataTableBuilder dataTableBuilder =
DataTableBuilderFactory.getDataTableBuilder(dataSchema);
+ dataTableBuilder.startRow();
+ dataTableBuilder.setNull(0);
+ dataTableBuilder.finishRow();
+ DataTable dataTable =
DataTableFactory.getDataTable(dataTableBuilder.build().toBytes());
+
+ String message = ERROR_MESSAGE + ", type: " + columnDataType;
+ assertEquals(dataTable.getNumberOfRows(), 1, message);
+ switch (columnDataType.getStoredType()) {
+ // Types that carry their own in-band null encoding need no bitmap
entry.
+ case OBJECT:
+ case UNKNOWN:
+ assertNull(dataTable.getCustomObject(0, 0), message);
+ assertNull(dataTable.getNullRowIds(0), message);
+ break;
+ case MAP:
+ assertNull(dataTable.getMap(0, 0), message);
+ assertNull(dataTable.getNullRowIds(0), message);
+ break;
+ default:
+ RoaringBitmap nullRowIds = dataTable.getNullRowIds(0);
+ assertNotNull(nullRowIds, message);
+ assertTrue(nullRowIds.contains(0), message);
+ break;
+ }
+ }
+ }
+
+ /// A null in a 4-byte column must not bleed into the column that follows it.
+ @Test(dataProvider = "versionProvider")
+ public void testSetNullDoesNotCorruptAdjacentColumn(int dataTableVersion)
+ throws IOException {
+ DataTableBuilderFactory.setDataTableVersion(dataTableVersion);
+ DataSchema dataSchema = new DataSchema(new String[]{"str", "dbl", "int"},
new DataSchema.ColumnDataType[]{
+ DataSchema.ColumnDataType.STRING, DataSchema.ColumnDataType.DOUBLE,
DataSchema.ColumnDataType.INT
+ });
+ DataTableBuilder dataTableBuilder =
DataTableBuilderFactory.getDataTableBuilder(dataSchema);
+ dataTableBuilder.startRow();
+ dataTableBuilder.setNull(0);
+ dataTableBuilder.setColumn(1, 3.5d);
+ dataTableBuilder.setColumn(2, 42);
+ dataTableBuilder.finishRow();
+ DataTable dataTable =
DataTableFactory.getDataTable(dataTableBuilder.build().toBytes());
+
+ assertEquals(dataTable.getDouble(0, 1), 3.5d, ERROR_MESSAGE);
+ assertEquals(dataTable.getInt(0, 2), 42, ERROR_MESSAGE);
+ assertEquals(dataTable.getNullRowIds(0), RoaringBitmap.bitmapOf(0),
ERROR_MESSAGE);
+ assertNull(dataTable.getNullRowIds(1), ERROR_MESSAGE);
+ assertNull(dataTable.getNullRowIds(2), ERROR_MESSAGE);
+ }
+
+ /// A table without nulls must not grow a null bitmap section, so that its
bytes stay identical to what a writer
+ /// that predates always-on null support would have produced.
+ @Test(dataProvider = "versionProvider")
+ public void testNoNullBitmapSectionWithoutNulls(int dataTableVersion)
+ throws IOException {
+ DataTableBuilderFactory.setDataTableVersion(dataTableVersion);
+ DataSchema dataSchema = new DataSchema(new String[]{"str", "int"},
+ new DataSchema.ColumnDataType[]{DataSchema.ColumnDataType.STRING,
DataSchema.ColumnDataType.INT});
+ DataTableBuilder dataTableBuilder =
DataTableBuilderFactory.getDataTableBuilder(dataSchema);
+ dataTableBuilder.startRow();
+ dataTableBuilder.setColumn(0, "foo");
+ dataTableBuilder.setColumn(1, 42);
+ dataTableBuilder.finishRow();
+ // An all-empty bitmap carries no information and must not force the
section to be emitted either.
+ dataTableBuilder.setNullRowIds(new RoaringBitmap());
+ dataTableBuilder.setNullRowIds(null);
+ byte[] bytes = dataTableBuilder.build().toBytes();
+
+ DataTableBuilder controlBuilder =
DataTableBuilderFactory.getDataTableBuilder(dataSchema);
+ controlBuilder.startRow();
+ controlBuilder.setColumn(0, "foo");
+ controlBuilder.setColumn(1, 42);
+ controlBuilder.finishRow();
+ assertEquals(bytes, controlBuilder.build().toBytes(), ERROR_MESSAGE);
+
+ DataTable dataTable = DataTableFactory.getDataTable(bytes);
+ assertNull(dataTable.getNullRowIds(0), ERROR_MESSAGE);
+ assertNull(dataTable.getNullRowIds(1), ERROR_MESSAGE);
+ }
+
+ /// A pre-computed bitmap handed to [DataTableBuilder#setNullRowIds] must
merge with the per-cell nulls recorded
+ /// by [DataTableBuilder#setNull] for the same column rather than replacing
them.
+ @Test(dataProvider = "versionProvider")
+ public void testSetNullRowIdsMergesWithSetNull(int dataTableVersion)
+ throws IOException {
+ DataTableBuilderFactory.setDataTableVersion(dataTableVersion);
+ DataSchema dataSchema =
+ new DataSchema(new String[]{"int"}, new
DataSchema.ColumnDataType[]{DataSchema.ColumnDataType.INT});
+ DataTableBuilder dataTableBuilder =
DataTableBuilderFactory.getDataTableBuilder(dataSchema);
+ for (int rowId = 0; rowId < 3; rowId++) {
+ dataTableBuilder.startRow();
+ if (rowId == 0) {
+ dataTableBuilder.setNull(0);
+ } else {
+ dataTableBuilder.setColumn(0, rowId);
+ }
+ dataTableBuilder.finishRow();
+ }
+ RoaringBitmap preComputed = RoaringBitmap.bitmapOf(2);
+ dataTableBuilder.setNullRowIds(preComputed);
+ DataTable dataTable =
DataTableFactory.getDataTable(dataTableBuilder.build().toBytes());
+
+ assertEquals(dataTable.getNullRowIds(0), RoaringBitmap.bitmapOf(0, 2),
ERROR_MESSAGE);
+ assertEquals(dataTable.getInt(1, 0), 1, ERROR_MESSAGE);
+ // The builder must not take ownership of the caller's bitmap.
+ assertEquals(preComputed, RoaringBitmap.bitmapOf(2), ERROR_MESSAGE);
+ }
+
@Test(dataProvider = "versionProvider")
- public void testThreadCPUMemMeasurement(int dataTableVersion) throws
IOException {
+ public void testThreadCPUMemMeasurement(int dataTableVersion)
+ throws IOException {
DataTableBuilderFactory.setDataTableVersion(dataTableVersion);
DataSchema.ColumnDataType[] columnDataTypes =
DataSchema.ColumnDataType.values();
int numColumns = columnDataTypes.length;
@@ -286,12 +408,11 @@ public class DataTableSerDeTest {
DataTable dataTable = dataTableBuilder.build();
DataTable newDataTable =
DataTableFactory.getDataTable(dataTable.toBytes());
// When ThreadCpuTimeMeasurement is enabled,
responseSerializationCpuTimeNs should be positive.
-
Assert.assertNull(newDataTable.getMetadata().get(MetadataKey.THREAD_CPU_TIME_NS.getName()));
-
Assert.assertNull(newDataTable.getMetadata().get(MetadataKey.SYSTEM_ACTIVITIES_CPU_TIME_NS.getName()));
-
Assert.assertNull(newDataTable.getMetadata().get(MetadataKey.THREAD_MEM_ALLOCATED_BYTES.getName()));
- Assert.assertTrue(
-
Integer.parseInt(newDataTable.getMetadata().get(MetadataKey.RESPONSE_SER_CPU_TIME_NS.getName()))
> 0);
- Assert.assertTrue(
+
assertNull(newDataTable.getMetadata().get(MetadataKey.THREAD_CPU_TIME_NS.getName()));
+
assertNull(newDataTable.getMetadata().get(MetadataKey.SYSTEM_ACTIVITIES_CPU_TIME_NS.getName()));
+
assertNull(newDataTable.getMetadata().get(MetadataKey.THREAD_MEM_ALLOCATED_BYTES.getName()));
+
assertTrue(Integer.parseInt(newDataTable.getMetadata().get(MetadataKey.RESPONSE_SER_CPU_TIME_NS.getName()))
> 0);
+ assertTrue(
Integer.parseInt(newDataTable.getMetadata().get(MetadataKey.RESPONSE_SER_MEM_ALLOCATED_BYTES.getName()))
> 0);
// Disable ThreadCpuTimeMeasurement, serialize/de-serialize data table.
@@ -300,11 +421,11 @@ public class DataTableSerDeTest {
dataTable = dataTableBuilder.build();
newDataTable = DataTableFactory.getDataTable(dataTable.toBytes());
// When measurement is disabled, response serialization metadata is absent.
-
Assert.assertNull(newDataTable.getMetadata().get(MetadataKey.THREAD_CPU_TIME_NS.getName()));
-
Assert.assertNull(newDataTable.getMetadata().get(MetadataKey.SYSTEM_ACTIVITIES_CPU_TIME_NS.getName()));
-
Assert.assertNull(newDataTable.getMetadata().get(MetadataKey.RESPONSE_SER_CPU_TIME_NS.getName()));
-
Assert.assertNull(newDataTable.getMetadata().get(MetadataKey.THREAD_MEM_ALLOCATED_BYTES.getName()));
-
Assert.assertNull(newDataTable.getMetadata().get(MetadataKey.RESPONSE_SER_MEM_ALLOCATED_BYTES.getName()));
+
assertNull(newDataTable.getMetadata().get(MetadataKey.THREAD_CPU_TIME_NS.getName()));
+
assertNull(newDataTable.getMetadata().get(MetadataKey.SYSTEM_ACTIVITIES_CPU_TIME_NS.getName()));
+
assertNull(newDataTable.getMetadata().get(MetadataKey.RESPONSE_SER_CPU_TIME_NS.getName()));
+
assertNull(newDataTable.getMetadata().get(MetadataKey.THREAD_MEM_ALLOCATED_BYTES.getName()));
+
assertNull(newDataTable.getMetadata().get(MetadataKey.RESPONSE_SER_MEM_ALLOCATED_BYTES.getName()));
}
private void fillDataTableWithRandomData(DataTableBuilder dataTableBuilder,
@@ -370,8 +491,8 @@ public class DataTableSerDeTest {
dataTableBuilder.setColumn(colId, new ByteArray(BYTES[rowId]));
break;
case UUID:
- UUIDS[rowId] = isNull ? UuidUtils.nullUuidBytes()
- : UuidUtils.toBytes(new UUID(RANDOM.nextLong(),
RANDOM.nextLong()));
+ UUIDS[rowId] =
+ isNull ? UuidUtils.nullUuidBytes() : UuidUtils.toBytes(new
UUID(RANDOM.nextLong(), RANDOM.nextLong()));
dataTableBuilder.setColumn(colId, new ByteArray(UUIDS[rowId]));
break;
case INT_ARRAY:
@@ -505,87 +626,79 @@ public class DataTableSerDeTest {
boolean isNull = nullBitmaps[colId] != null &&
nullBitmaps[colId].contains(rowId);
switch (columnDataTypes[colId]) {
case INT:
- Assert.assertEquals(newDataTable.getInt(rowId, colId), isNull ? 0
: INTS[rowId], ERROR_MESSAGE);
+ assertEquals(newDataTable.getInt(rowId, colId), isNull ? 0 :
INTS[rowId], ERROR_MESSAGE);
break;
case LONG:
- Assert.assertEquals(newDataTable.getLong(rowId, colId), isNull ? 0
: LONGS[rowId], ERROR_MESSAGE);
+ assertEquals(newDataTable.getLong(rowId, colId), isNull ? 0 :
LONGS[rowId], ERROR_MESSAGE);
break;
case FLOAT:
- Assert.assertEquals(newDataTable.getFloat(rowId, colId), isNull ?
0 : FLOATS[rowId], ERROR_MESSAGE);
+ assertEquals(newDataTable.getFloat(rowId, colId), isNull ? 0 :
FLOATS[rowId], ERROR_MESSAGE);
break;
case DOUBLE:
- Assert.assertEquals(newDataTable.getDouble(rowId, colId), isNull ?
0.0 : DOUBLES[rowId], ERROR_MESSAGE);
+ assertEquals(newDataTable.getDouble(rowId, colId), isNull ? 0.0 :
DOUBLES[rowId], ERROR_MESSAGE);
break;
case BIG_DECIMAL:
- Assert.assertEquals(newDataTable.getBigDecimal(rowId, colId),
- isNull ? BigDecimal.ZERO : BIG_DECIMALS[rowId], ERROR_MESSAGE);
+ assertEquals(newDataTable.getBigDecimal(rowId, colId), isNull ?
BigDecimal.ZERO : BIG_DECIMALS[rowId],
+ ERROR_MESSAGE);
break;
case BOOLEAN:
- Assert.assertEquals(newDataTable.getInt(rowId, colId), isNull ? 0
: BOOLEANS[rowId], ERROR_MESSAGE);
+ assertEquals(newDataTable.getInt(rowId, colId), isNull ? 0 :
BOOLEANS[rowId], ERROR_MESSAGE);
break;
case TIMESTAMP:
- Assert.assertEquals(newDataTable.getLong(rowId, colId), isNull ? 0
: TIMESTAMPS[rowId], ERROR_MESSAGE);
+ assertEquals(newDataTable.getLong(rowId, colId), isNull ? 0 :
TIMESTAMPS[rowId], ERROR_MESSAGE);
break;
case STRING:
- Assert.assertEquals(newDataTable.getString(rowId, colId), isNull ?
"" : STRINGS[rowId], ERROR_MESSAGE);
+ assertEquals(newDataTable.getString(rowId, colId), isNull ? "" :
STRINGS[rowId], ERROR_MESSAGE);
break;
case JSON:
- Assert.assertEquals(newDataTable.getString(rowId, colId), isNull ?
"" : JSONS[rowId], ERROR_MESSAGE);
+ assertEquals(newDataTable.getString(rowId, colId), isNull ? "" :
JSONS[rowId], ERROR_MESSAGE);
break;
case BYTES:
- Assert.assertEquals(newDataTable.getBytes(rowId,
colId).getBytes(), isNull ? new byte[0] : BYTES[rowId],
+ assertEquals(newDataTable.getBytes(rowId, colId).getBytes(),
isNull ? new byte[0] : BYTES[rowId],
ERROR_MESSAGE);
break;
case UUID:
- Assert.assertEquals(newDataTable.getBytes(rowId, colId).getBytes(),
+ assertEquals(newDataTable.getBytes(rowId, colId).getBytes(),
isNull ? UuidUtils.nullUuidBytes() : UUIDS[rowId],
ERROR_MESSAGE);
break;
case INT_ARRAY:
- Assert.assertTrue(Arrays.equals(newDataTable.getIntArray(rowId,
colId), INT_ARRAYS[rowId]), ERROR_MESSAGE);
+ assertTrue(Arrays.equals(newDataTable.getIntArray(rowId, colId),
INT_ARRAYS[rowId]), ERROR_MESSAGE);
break;
case LONG_ARRAY:
- Assert.assertTrue(Arrays.equals(newDataTable.getLongArray(rowId,
colId), LONG_ARRAYS[rowId]),
- ERROR_MESSAGE);
+ assertTrue(Arrays.equals(newDataTable.getLongArray(rowId, colId),
LONG_ARRAYS[rowId]), ERROR_MESSAGE);
break;
case FLOAT_ARRAY:
- Assert.assertTrue(Arrays.equals(newDataTable.getFloatArray(rowId,
colId), FLOAT_ARRAYS[rowId]),
- ERROR_MESSAGE);
+ assertTrue(Arrays.equals(newDataTable.getFloatArray(rowId, colId),
FLOAT_ARRAYS[rowId]), ERROR_MESSAGE);
break;
case DOUBLE_ARRAY:
- Assert.assertTrue(Arrays.equals(newDataTable.getDoubleArray(rowId,
colId), DOUBLE_ARRAYS[rowId]),
- ERROR_MESSAGE);
+ assertTrue(Arrays.equals(newDataTable.getDoubleArray(rowId,
colId), DOUBLE_ARRAYS[rowId]), ERROR_MESSAGE);
break;
case BIG_DECIMAL_ARRAY:
-
Assert.assertTrue(Arrays.equals(newDataTable.getBigDecimalArray(rowId, colId),
BIG_DECIMAL_ARRAYS[rowId]),
+ assertTrue(Arrays.equals(newDataTable.getBigDecimalArray(rowId,
colId), BIG_DECIMAL_ARRAYS[rowId]),
ERROR_MESSAGE);
break;
case BOOLEAN_ARRAY:
- Assert.assertTrue(Arrays.equals(newDataTable.getIntArray(rowId,
colId), BOOLEAN_ARRAYS[rowId]),
- ERROR_MESSAGE);
+ assertTrue(Arrays.equals(newDataTable.getIntArray(rowId, colId),
BOOLEAN_ARRAYS[rowId]), ERROR_MESSAGE);
break;
case TIMESTAMP_ARRAY:
- Assert.assertTrue(Arrays.equals(newDataTable.getLongArray(rowId,
colId), TIMESTAMP_ARRAYS[rowId]),
- ERROR_MESSAGE);
+ assertTrue(Arrays.equals(newDataTable.getLongArray(rowId, colId),
TIMESTAMP_ARRAYS[rowId]), ERROR_MESSAGE);
break;
case BYTES_ARRAY:
- Assert.assertTrue(Arrays.equals(newDataTable.getBytesArray(rowId,
colId), BYTES_ARRAYS[rowId]),
- ERROR_MESSAGE);
+ assertTrue(Arrays.equals(newDataTable.getBytesArray(rowId, colId),
BYTES_ARRAYS[rowId]), ERROR_MESSAGE);
break;
case UUID_ARRAY:
- Assert.assertTrue(Arrays.equals(newDataTable.getBytesArray(rowId,
colId), UUID_ARRAYS[rowId]),
- ERROR_MESSAGE);
+ assertTrue(Arrays.equals(newDataTable.getBytesArray(rowId, colId),
UUID_ARRAYS[rowId]), ERROR_MESSAGE);
break;
case STRING_ARRAY:
- Assert.assertTrue(Arrays.equals(newDataTable.getStringArray(rowId,
colId), STRING_ARRAYS[rowId]),
- ERROR_MESSAGE);
+ assertTrue(Arrays.equals(newDataTable.getStringArray(rowId,
colId), STRING_ARRAYS[rowId]), ERROR_MESSAGE);
break;
case MAP:
- Assert.assertEquals(newDataTable.getMap(rowId, colId),
MAPS[rowId], ERROR_MESSAGE);
+ assertEquals(newDataTable.getMap(rowId, colId), MAPS[rowId],
ERROR_MESSAGE);
break;
case OBJECT:
case UNKNOWN:
Object nulValue = newDataTable.getCustomObject(rowId, colId);
- Assert.assertNull(nulValue, ERROR_MESSAGE);
+ assertNull(nulValue, ERROR_MESSAGE);
break;
default:
throw new UnsupportedOperationException("Unable to generate random
data for: " + columnDataTypes[colId]);
diff --git
a/pinot-core/src/test/java/org/apache/pinot/core/query/aggregation/function/MaxStringAggregationFunctionTest.java
b/pinot-core/src/test/java/org/apache/pinot/core/query/aggregation/function/MaxStringAggregationFunctionTest.java
index 49666e554b0..459f548ae42 100644
---
a/pinot-core/src/test/java/org/apache/pinot/core/query/aggregation/function/MaxStringAggregationFunctionTest.java
+++
b/pinot-core/src/test/java/org/apache/pinot/core/query/aggregation/function/MaxStringAggregationFunctionTest.java
@@ -31,6 +31,7 @@ import org.apache.pinot.segment.spi.AggregationFunctionType;
import org.apache.pinot.spi.data.FieldSpec;
import org.apache.pinot.spi.data.Schema;
import org.apache.pinot.spi.exception.BadQueryRequestException;
+import
org.apache.pinot.spi.utils.CommonConstants.Broker.Request.QueryOptionKey;
import org.testng.annotations.Test;
import static org.mockito.Mockito.mock;
@@ -47,7 +48,13 @@ public class MaxStringAggregationFunctionTest extends
AbstractAggregationFunctio
/// This is used to simulate the DataTypeScenario concept from numeric
aggregation tests,
/// but fixed for the STRING data type.
protected FluentQueryTest.DeclaringTable getDeclaringTable(boolean
enableColumnBasedNullHandling) {
+ return getDeclaringTable(enableColumnBasedNullHandling, Map.of());
+ }
+
+ protected FluentQueryTest.DeclaringTable getDeclaringTable(boolean
enableColumnBasedNullHandling,
+ Map<String, String> extraQueryOptions) {
return FluentQueryTest.withBaseDir(_baseDir)
+ .withExtraQueryOptions(extraQueryOptions)
.givenTable(
new Schema.SchemaBuilder()
.setSchemaName("testTable")
@@ -307,4 +314,67 @@ public class MaxStringAggregationFunctionTest extends
AbstractAggregationFunctio
"tag3 | cherry" // Values for tag3: "cherry". Max is "cherry".
);
}
+
+ /// MAXSTRING has no identity element, so it yields a null intermediate
result when no row is aggregated at all --
+ /// in both null-handling modes. The intermediate result column is STRING,
whose 4-byte DataTable slot cannot hold
+ /// the custom-object null encoding, so this only round-trips if the builder
writes the null through a bitmap.
+ @Test
+ void aggregationNoMatchingRowsWithNullHandlingDisabled() {
+ getDeclaringTable(false)
+ .onFirstInstance("myField",
+ "alpha"
+ ).andOnSecondInstance("myField",
+ "beta"
+ ).whenQuery("select maxstring(myField) from testTable where myField =
'nomatch'")
+ .thenResultIs("STRING", "null");
+ }
+
+ @Test
+ void aggregationNoMatchingRowsWithNullHandlingEnabled() {
+ getDeclaringTable(true)
+ .onFirstInstance("myField",
+ "alpha"
+ ).andOnSecondInstance("myField",
+ "beta"
+ ).whenQueryWithNullHandlingEnabled("select maxstring(myField) from
testTable where myField = 'nomatch'")
+ .thenResultIs("STRING", "null");
+ }
+
+ /// The null intermediate result must survive alongside a neighbouring
aggregate: the STRING column occupies a
+ /// 4-byte slot, so an over-wide null encoding would corrupt the column that
follows it.
+ @Test
+ void aggregationNoMatchingRowsWithFollowingAggregateColumn() {
+ getDeclaringTable(false)
+ .onFirstInstance("myField",
+ "alpha"
+ ).andOnSecondInstance("myField",
+ "beta"
+ ).whenQuery("select maxstring(myField), count(*) from testTable where
myField = 'nomatch'")
+ .thenResultIs("STRING | LONG", "null | 0");
+ }
+
+ /// With `serverReturnFinalResult` the servers finalize before serializing,
so the null lands in a column typed by
+ /// `getFinalResultColumnType()` rather than the intermediate type -- STRING
here, a 4-byte slot either way. The
+ /// broker sets this option on its own for any single-server query, so this
is a routine path, not an edge case.
+ @Test
+ void aggregationNoMatchingRowsReturningFinalResultWithNullHandlingDisabled()
{
+ getDeclaringTable(false, Map.of(QueryOptionKey.SERVER_RETURN_FINAL_RESULT,
"true"))
+ .onFirstInstance("myField",
+ "alpha"
+ ).andOnSecondInstance("myField",
+ "beta"
+ ).whenQuery("select maxstring(myField) from testTable where myField =
'nomatch'")
+ .thenResultIs("STRING", "null");
+ }
+
+ @Test
+ void aggregationNoMatchingRowsReturningFinalResultWithNullHandlingEnabled() {
+ getDeclaringTable(true, Map.of(QueryOptionKey.SERVER_RETURN_FINAL_RESULT,
"true"))
+ .onFirstInstance("myField",
+ "alpha"
+ ).andOnSecondInstance("myField",
+ "beta"
+ ).whenQueryWithNullHandlingEnabled("select maxstring(myField) from
testTable where myField = 'nomatch'")
+ .thenResultIs("STRING", "null");
+ }
}
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 3efa9c0cb5b..6c5c5f2b2c4 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
@@ -269,6 +269,33 @@ public class MergeDataTablesOnlyTest {
assertRoundTrip(brokerRequest, serverTables);
}
+ /// A null intermediate result reaches the merge-only path with null
handling disabled too, so the merged
+ /// DataTable has to carry it.
+ ///
+ /// The aggregate has to be `MAXSTRING`: its accumulator has no identity
element, so a server that aggregated no
+ /// rows contributes null in either mode, and its `merge` is null-safe.
`MIN` / `SUM` cannot reach this state --
+ /// their accumulators are primitive and always hold an identity value -- so
a nulled DOUBLE fixture would be an
+ /// input no server produces, and `MinAggregationFunction#merge` would NPE
on it since it only null-guards when
+ /// null handling is enabled.
+ @Test
+ public void testNullAggregationRoundTripWithNullHandlingDisabled()
+ throws IOException {
+ String query = "SELECT MAXSTRING(s) FROM testTable";
+ DataSchema schema = new DataSchema(new String[]{"maxstring(s)"}, new
ColumnDataType[]{ColumnDataType.STRING});
+ // One server aggregated rows, the other matched none.
+ assertRoundTrip(query, List.of(buildNullableString(schema, "alpha"),
buildNullableString(schema, null)));
+ }
+
+ /// When every server contributes null, the merged intermediate DataTable
itself holds a null, so the null has to
+ /// survive `buildIntermediateDataTable`'s write as well as the restore on
the way back in.
+ @Test
+ public void testAllNullAggregationRoundTripWithNullHandlingDisabled()
+ throws IOException {
+ String query = "SELECT MAXSTRING(s) FROM testTable";
+ DataSchema schema = new DataSchema(new String[]{"maxstring(s)"}, new
ColumnDataType[]{ColumnDataType.STRING});
+ assertRoundTrip(query, List.of(buildNullableString(schema, null),
buildNullableString(schema, null)));
+ }
+
@Test
public void testConflictingSchemaSurfacedAsIncompleteMerge() {
// When one server's column types conflict with the first non-empty
table's, filterDataTablesAndPickSchema
@@ -574,6 +601,19 @@ public class MergeDataTablesOnlyTest {
return builder.build();
}
+ private static DataTable buildNullableString(DataSchema schema, String value)
+ throws IOException {
+ DataTableBuilder builder =
DataTableBuilderFactory.getDataTableBuilder(schema);
+ builder.startRow();
+ if (value == null) {
+ builder.setNull(0);
+ } else {
+ builder.setColumn(0, value);
+ }
+ builder.finishRow();
+ return builder.build();
+ }
+
private static void appendRow(DataTableBuilder builder, DataSchema schema,
Object[] values)
throws IOException {
builder.startRow();
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]