This is an automated email from the ASF dual-hosted git repository.
raghavyadav01 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 657d7805e7a Support range index on ingestion-aggregated no-dictionary
columns (#19111)
657d7805e7a is described below
commit 657d7805e7a3e9920475ef927440ba6721545bf7
Author: RAGHVENDRA KUMAR YADAV <[email protected]>
AuthorDate: Wed Oct 7 11:33:47 2026 -0700
Support range index on ingestion-aggregated no-dictionary columns (#19111)
* Support BitSliced range index on ingestion-aggregated no-dict columns
Ingestion-time metrics aggregation forces its aggregated metric columns
to be no-dictionary and skips min/max tracking during consumption (the
values mutate in place). The BitSliced range index (version 2) creator
subtracts the column min for INT/LONG columns, so with a null value
domain segment commit failed with an NPE.
Instead of rejecting the combination, recover the value domain:
- MutableNoDictColumnStatistics computes min/max with a single scan of
the sealed forward index when the mutable segment reports null,
scoped to single-value INT/LONG (the only types whose BitSliced index
reads min/max; FLOAT/DOUBLE use the full floating-point ordinal
domain). The recovered values also flow into the committed segment
metadata, improving pruning.
- RangeIndexHandler recomputes min/max from the forward index on the
index-reload path for segments committed before this change, via a
new min/max override on IndexCreationContext.Builder.
Adds unit coverage for min/max recovery and an end-to-end seal-path
test that builds and verifies a range index on an aggregated
no-dictionary column, and flips the validation tests to assert the
combination is now accepted.
* Recover min/max in the same pass that already checks sortedness
Recovering an untracked value domain was a second full scan of the column:
computeSorted() calls getMinValue(), which now scans, and then scanned again
itself. That regressed the single-pass guarantee two existing tests assert
(testIsSortedScansOnce, testUntrackedMinMaxFallsBackToScan), and charged the
extra pass to every ingestion-aggregated INT/LONG column at commit, whether
or
not a range index was configured -- BaseSegmentCreator reads min/max for
every
column when writing segment metadata.
Fold the two together: the recovery scan walks docs in the order
computeSorted()
uses (honouring sortedDocIds) and records non-decreasing order alongside
min and
max; computeSorted() reuses that answer instead of rescanning. A segment
commit
reads such a column exactly once, as before.
* Cover the RangeIndexHandler null min/max path with a real segment
The handler-side recovery had no test: the existing RangeIndexHandlerTest is
mock-based and never builds an index, leaving that path at 22% coverage.
Build a real segment over unsorted, partly negative INT and LONG
no-dictionary
columns, strip min/max from its metadata -- which is what a segment
committed
with untracked min/max looks like on disk -- then run the handler and query
the
resulting BitSliced index. Reverting RangeIndexHandler to its pre-change
form
fails both tests with the NPE this PR fixes.
The second test pins the open-ended case: the reader still takes the column
max
from metadata and so falls back to Long.MAX_VALUE, but the RangeBitmap
domain is
self-contained, so matches stay correct.
* Correct the note on what fills column metadata after the handler scan
The note claimed metadata min/max stay null until the segment is rebuilt.
Under
the default columnMinMaxValueGeneratorMode they do not:
ColumnMinMaxValueGenerator
runs later in the same SegmentPreProcessor pass and populates them. The
weaker
max-pruning caveat applies only when that mode is NONE. Also record that the
default path consequently reads the column twice per preprocess.
* Compute min/max bounds in the MutableNoDictColumnStatistics constructor
Keep plain _minValue/_maxValue fields and the existing _sorted cache:
recover
missing bounds for single-value INT/LONG columns once at construction and
record sortedness from the same scan, so the getters are field reads.
* Clarify when range-index column metadata min/max may stay absent
* Skip min/max recovery scan in CompactedNoDictColumnStatistics
The compacted subclass computes its own bounds over valid docs only, and its
sortedDocIds covers just those docs, so the base-class constructor scan
would
index past the array. Let subclasses opt out via a protected constructor.
---
.../stats/CompactedNoDictColumnStatistics.java | 2 +-
.../stats/MutableNoDictColumnStatistics.java | 69 +++++-
.../loader/invertedindex/RangeIndexHandler.java | 69 +++++-
.../converter/RealtimeSegmentConverterTest.java | 84 +++++++
.../stats/MutableNoDictColumnStatisticsTest.java | 147 +++++++++++++
.../RangeIndexHandlerNullMinMaxTest.java | 243 +++++++++++++++++++++
.../utils/IndexCombinationValidationTest.java | 117 ++++++++++
.../segment/spi/creator/IndexCreationContext.java | 31 ++-
8 files changed, 756 insertions(+), 6 deletions(-)
diff --git
a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/realtime/converter/stats/CompactedNoDictColumnStatistics.java
b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/realtime/converter/stats/CompactedNoDictColumnStatistics.java
index 6d01827bfae..ff96b76f58b 100644
---
a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/realtime/converter/stats/CompactedNoDictColumnStatistics.java
+++
b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/realtime/converter/stats/CompactedNoDictColumnStatistics.java
@@ -47,7 +47,7 @@ public class CompactedNoDictColumnStatistics extends
MutableNoDictColumnStatisti
public CompactedNoDictColumnStatistics(DataSource dataSource, @Nullable
int[] sortedDocIds, boolean isSortedColumn,
RoaringBitmap validDocIds) {
- super(dataSource, sortedDocIds, isSortedColumn);
+ super(dataSource, sortedDocIds, isSortedColumn, false);
Preconditions.checkState(!validDocIds.isEmpty(), "Use
EmptyColumnStatistics for empty column: %s",
_fieldSpec.getName());
_totalDocs = validDocIds.getCardinality();
diff --git
a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/realtime/converter/stats/MutableNoDictColumnStatistics.java
b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/realtime/converter/stats/MutableNoDictColumnStatistics.java
index 7896c13f96d..8516d230806 100644
---
a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/realtime/converter/stats/MutableNoDictColumnStatistics.java
+++
b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/realtime/converter/stats/MutableNoDictColumnStatistics.java
@@ -45,6 +45,11 @@ public class MutableNoDictColumnStatistics implements
ColumnStatistics, CLPStats
protected final boolean _isSortedColumn;
protected final MutableForwardIndex _forwardIndex;
+ @Nullable
+ private final Comparable<?> _minValue;
+ @Nullable
+ private final Comparable<?> _maxValue;
+
// Lazily computed because it may require a full scan of the forward index,
and it is queried multiple times per
// column during segment creation. Left unsynchronized: an instance
describes a single column and is reached only
// through the per-column stats map, so it is confined to whichever thread
creates that column. Even if that ever
@@ -53,6 +58,13 @@ public class MutableNoDictColumnStatistics implements
ColumnStatistics, CLPStats
private Boolean _sorted;
public MutableNoDictColumnStatistics(DataSource dataSource, @Nullable int[]
sortedDocIds, boolean isSortedColumn) {
+ this(dataSource, sortedDocIds, isSortedColumn, true);
+ }
+
+ /// @param recoverMinMax whether to scan for min/max missing from the
metadata. Subclasses that compute their own
+ /// bounds over a subset of docs pass false to skip the
scan.
+ protected MutableNoDictColumnStatistics(DataSource dataSource, @Nullable
int[] sortedDocIds, boolean isSortedColumn,
+ boolean recoverMinMax) {
_dataSourceMetadata = dataSource.getDataSourceMetadata();
_fieldSpec = _dataSourceMetadata.getFieldSpec();
Preconditions.checkState(_dataSourceMetadata.getNumDocs() > 0,
@@ -62,6 +74,55 @@ public class MutableNoDictColumnStatistics implements
ColumnStatistics, CLPStats
_forwardIndex = (MutableForwardIndex) dataSource.getForwardIndex();
Preconditions.checkState(_forwardIndex != null, "Failed to find forward
index for column: %s",
_fieldSpec.getName());
+
+ // Ingestion-aggregated metric columns mutate in place during consumption,
so the mutable segment does not track
+ // min/max for them. Recover the bounds for single-value INT/LONG columns,
the only types whose BitSliced range
+ // index needs them, and record sortedness in the same scan. Other types
keep null bounds (unchanged behavior).
+ Comparable<?> minValue = (Comparable<?>) _dataSourceMetadata.getMinValue();
+ Comparable<?> maxValue = (Comparable<?>) _dataSourceMetadata.getMaxValue();
+ if (recoverMinMax && (minValue == null || maxValue == null) &&
isSingleValue()) {
+ int numDocs = _dataSourceMetadata.getNumDocs();
+ switch (getStoredType()) {
+ case INT: {
+ int min = _forwardIndex.getInt(docId(0));
+ int max = min;
+ int prev = min;
+ boolean sorted = true;
+ for (int i = 1; i < numDocs; i++) {
+ int curr = _forwardIndex.getInt(docId(i));
+ min = Math.min(min, curr);
+ max = Math.max(max, curr);
+ sorted &= curr >= prev;
+ prev = curr;
+ }
+ minValue = min;
+ maxValue = max;
+ _sorted = _isSortedColumn || sorted;
+ break;
+ }
+ case LONG: {
+ long min = _forwardIndex.getLong(docId(0));
+ long max = min;
+ long prev = min;
+ boolean sorted = true;
+ for (int i = 1; i < numDocs; i++) {
+ long curr = _forwardIndex.getLong(docId(i));
+ min = Math.min(min, curr);
+ max = Math.max(max, curr);
+ sorted &= curr >= prev;
+ prev = curr;
+ }
+ minValue = min;
+ maxValue = max;
+ _sorted = _isSortedColumn || sorted;
+ break;
+ }
+ default:
+ break;
+ }
+ }
+ _minValue = minValue;
+ _maxValue = maxValue;
}
@Override
@@ -76,12 +137,16 @@ public class MutableNoDictColumnStatistics implements
ColumnStatistics, CLPStats
@Override
public Comparable<?> getMinValue() {
- return (Comparable<?>) _dataSourceMetadata.getMinValue();
+ return _minValue;
}
@Override
public Comparable<?> getMaxValue() {
- return (Comparable<?>) _dataSourceMetadata.getMaxValue();
+ return _maxValue;
+ }
+
+ private int docId(int index) {
+ return _sortedDocIds != null ? _sortedDocIds[index] : index;
}
@Nullable
diff --git
a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/index/loader/invertedindex/RangeIndexHandler.java
b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/index/loader/invertedindex/RangeIndexHandler.java
index 239317ef835..a4c5f042553 100644
---
a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/index/loader/invertedindex/RangeIndexHandler.java
+++
b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/index/loader/invertedindex/RangeIndexHandler.java
@@ -24,6 +24,7 @@ import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import org.apache.commons.io.FileUtils;
+import
org.apache.pinot.segment.local.segment.creator.impl.inv.BitSlicedRangeIndexCreator;
import
org.apache.pinot.segment.local.segment.index.dictionary.DictionaryIndexType;
import org.apache.pinot.segment.local.segment.index.loader.BaseIndexHandler;
import org.apache.pinot.segment.local.segment.index.loader.IndexLoadingConfig;
@@ -44,6 +45,7 @@ import
org.apache.pinot.segment.spi.index.reader.ForwardIndexReaderContext;
import org.apache.pinot.segment.spi.memory.PinotDataBuffer;
import org.apache.pinot.segment.spi.store.SegmentDirectory;
import org.apache.pinot.spi.config.table.TableConfig;
+import org.apache.pinot.spi.data.FieldSpec.DataType;
import org.apache.pinot.spi.data.Schema;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -226,7 +228,8 @@ public class RangeIndexHandler extends BaseIndexHandler {
try (ForwardIndexReader forwardIndexReader =
readerFactory.createIndexReader(segmentWriter,
_fieldIndexConfigs.get(columnMetadata.getColumnName()),
columnMetadata);
ForwardIndexReaderContext readerContext =
forwardIndexReader.createContext();
- CombinedInvertedIndexCreator rangeIndexCreator =
newRangeIndexCreator(columnMetadata)) {
+ CombinedInvertedIndexCreator rangeIndexCreator =
+ newRangeIndexCreator(columnMetadata, forwardIndexReader,
readerContext, numDocs)) {
if (columnMetadata.isSingleValue()) {
// Single-value column.
switch (columnMetadata.getDataType().getStoredType()) {
@@ -301,4 +304,68 @@ public class RangeIndexHandler extends BaseIndexHandler {
.getConfig(StandardIndexes.range());
return StandardIndexes.range().createIndexCreator(context, config);
}
+
+ /// Variant for the non-dictionary path. The BitSliced (v2) range index
subtracts the column min for INT/LONG
+ /// columns, so it needs the value domain up front. Ingestion-aggregated
no-dictionary columns committed before
+ /// min/max recovery report null min/max in their metadata, so recompute it
here with a single scan of the forward
+ /// index before building the creator. This is a deliberate extra pass over
the column: {@code RangeBitmap.appender}
+ /// requires the max at construction, so it cannot be folded into the
add-loop that follows.
+ ///
+ /// Note: the recovered domain goes into the range index header, not the
column metadata. The metadata may stay
+ /// absent when the configured {@code columnMinMaxValueGeneratorMode}
excludes the column; the reader's conservative
+ /// fallback remains correct in that case.
+ ///
+ /// Segments sealed with the recovery in {@code
MutableNoDictColumnStatistics} carry proper metadata min/max and do
+ /// not reach the scan here.
+ private CombinedInvertedIndexCreator newRangeIndexCreator(ColumnMetadata
columnMetadata,
+ ForwardIndexReader forwardIndexReader, ForwardIndexReaderContext
readerContext, int numDocs)
+ throws Exception {
+ File indexDir = _segmentDirectory.getSegmentMetadata().getIndexDir();
+ IndexCreationContext.Builder builder = new
IndexCreationContext.Builder(indexDir, _tableConfig, columnMetadata);
+ RangeIndexConfig config =
_fieldIndexConfigs.get(columnMetadata.getColumnName())
+ .getConfig(StandardIndexes.range());
+ if (config.getVersion() == BitSlicedRangeIndexCreator.VERSION &&
columnMetadata.isSingleValue()
+ && (columnMetadata.getMinValue() == null ||
columnMetadata.getMaxValue() == null)) {
+ Comparable[] minMax = computeRawMinMax(forwardIndexReader, readerContext,
+ columnMetadata.getDataType().getStoredType(), numDocs);
+ if (minMax != null) {
+ builder.withMinValue(minMax[0]).withMaxValue(minMax[1]);
+ }
+ }
+ return StandardIndexes.range().createIndexCreator(builder.build(), config);
+ }
+
+ /// Computes {@code [min, max]} for a single-value INT/LONG no-dictionary
column by scanning the forward index.
+ /// Returns {@code null} for stored types whose BitSliced range index does
not read min/max (FLOAT/DOUBLE use the
+ /// full floating-point ordinal domain) or that do not support it, and for
empty columns.
+ private static Comparable[] computeRawMinMax(ForwardIndexReader
forwardIndexReader,
+ ForwardIndexReaderContext readerContext, DataType storedType, int
numDocs) {
+ if (numDocs == 0) {
+ return null;
+ }
+ switch (storedType) {
+ case INT: {
+ int min = forwardIndexReader.getInt(0, readerContext);
+ int max = min;
+ for (int i = 1; i < numDocs; i++) {
+ int curr = forwardIndexReader.getInt(i, readerContext);
+ min = Math.min(min, curr);
+ max = Math.max(max, curr);
+ }
+ return new Comparable[]{min, max};
+ }
+ case LONG: {
+ long min = forwardIndexReader.getLong(0, readerContext);
+ long max = min;
+ for (int i = 1; i < numDocs; i++) {
+ long curr = forwardIndexReader.getLong(i, readerContext);
+ min = Math.min(min, curr);
+ max = Math.max(max, curr);
+ }
+ return new Comparable[]{min, max};
+ }
+ default:
+ return null;
+ }
+ }
}
diff --git
a/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/realtime/converter/RealtimeSegmentConverterTest.java
b/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/realtime/converter/RealtimeSegmentConverterTest.java
index da96d42c904..56f5f374a0d 100644
---
a/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/realtime/converter/RealtimeSegmentConverterTest.java
+++
b/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/realtime/converter/RealtimeSegmentConverterTest.java
@@ -66,6 +66,8 @@ import
org.apache.pinot.spi.config.table.SegmentPartitionConfig;
import org.apache.pinot.spi.config.table.SegmentZKPropsConfig;
import org.apache.pinot.spi.config.table.TableConfig;
import org.apache.pinot.spi.config.table.TableType;
+import org.apache.pinot.spi.config.table.ingestion.AggregationConfig;
+import org.apache.pinot.spi.config.table.ingestion.IngestionConfig;
import org.apache.pinot.spi.data.FieldSpec;
import org.apache.pinot.spi.data.FieldSpec.DataType;
import org.apache.pinot.spi.data.Schema;
@@ -1557,6 +1559,88 @@ public class RealtimeSegmentConverterTest implements
PinotBuffersAfterMethodChec
return storedType.compare(v1, v2);
}
+ /// Ingestion-aggregated metric columns are forced to be no-dictionary and
skip min/max tracking during consumption
+ /// (their values mutate in place). A BitSliced (version 2) range index on
such a column previously failed to build
+ /// because the creator had no value domain. This test verifies the full
seal path: after conversion, the aggregated
+ /// no-dictionary metric column has recovered min/max in its column metadata
and carries a range index.
+ @Test
+ public void testRangeIndexOnIngestionAggregatedNoDictColumn()
+ throws Exception {
+ File tmpDir = new File(TMP_DIR, "tmp_" + System.currentTimeMillis());
+ IngestionConfig ingestionConfig = new IngestionConfig();
+ List<AggregationConfig> aggregationConfigs =
+ List.of(new AggregationConfig(LONG_COLUMN4, "SUM(" + LONG_COLUMN4 +
")"));
+ ingestionConfig.setAggregationConfigs(aggregationConfigs);
+ TableConfig tableConfig = new TableConfigBuilder(TableType.REALTIME)
+ .setTableName("testTable")
+ .setTimeColumnName(DATE_TIME_COLUMN)
+ .setIngestionConfig(ingestionConfig)
+ .setNoDictionaryColumns(List.of(LONG_COLUMN4))
+ .setRangeIndexColumns(List.of(LONG_COLUMN4))
+ .setColumnMajorSegmentBuilderEnabled(false)
+ .build();
+ Schema schema = new Schema.SchemaBuilder()
+ .setSchemaName("testTable")
+ .addSingleValueDimension(STRING_COLUMN1, DataType.STRING)
+ .addMetric(LONG_COLUMN4, DataType.LONG)
+ .addDateTime(DATE_TIME_COLUMN, DataType.LONG, "1:MILLISECONDS:EPOCH",
"1:MILLISECONDS")
+ .build();
+
+ String tableNameWithType = tableConfig.getTableName();
+ String segmentName = "testTable__0__0__123456";
+
+ RealtimeSegmentConfig realtimeSegmentConfig = new
RealtimeSegmentConfig.Builder()
+ .setTableNameWithType(tableNameWithType)
+ .setSegmentName(segmentName)
+ .setStreamName(tableNameWithType)
+ .setSchema(schema)
+ .setTimeColumnName(DATE_TIME_COLUMN)
+ .setCapacity(1000)
+ .setIngestionAggregationConfigs(aggregationConfigs)
+ .setIndex(Set.of(LONG_COLUMN4), StandardIndexes.dictionary(),
DictionaryIndexConfig.DISABLED)
+ .setIndex(Set.of(LONG_COLUMN4), StandardIndexes.forward(),
+ ForwardIndexConfig.getDefault(FieldConfig.EncodingType.RAW))
+ .setSegmentZKMetadata(getSegmentZKMetadata(segmentName))
+ .setOffHeap(true)
+ .setMemoryManager(new DirectMemoryManager(segmentName))
+ .setStatsHistory(RealtimeSegmentStatsHistory.deserializeFrom(new
File(tmpDir, "stats")))
+ .setConsumerDir(new File(tmpDir, "consumerDir").getAbsolutePath())
+ .build();
+
+ MutableSegmentImpl mutableSegmentImpl = new
MutableSegmentImpl(realtimeSegmentConfig, null);
+ try {
+ // Each row carries a distinct dimension/time value, so no rows collapse
and the aggregated metric retains its
+ // per-row values 64..73 (SUM over a single-row group). The mutable
segment reports null min/max for it.
+ List<GenericRow> rows = generateTestData();
+ for (GenericRow row : rows) {
+ mutableSegmentImpl.index(row, null);
+ }
+
+ File outputDir = new File(tmpDir, "outputDir");
+ RealtimeSegmentConverter converter =
+ new RealtimeSegmentConverter(mutableSegmentImpl, new
SegmentZKPropsConfig(), outputDir.getAbsolutePath(),
+ schema, tableNameWithType, tableConfig, segmentName, false);
+ converter.build(SegmentVersion.v3);
+
+ File indexDir = new File(outputDir, segmentName);
+ SegmentMetadataImpl segmentMetadata = new SegmentMetadataImpl(indexDir);
+ ColumnMetadata metricMetadata =
segmentMetadata.getColumnMetadataFor(LONG_COLUMN4);
+ assertFalse(metricMetadata.hasDictionary(), "Aggregated metric column
must stay no-dictionary");
+ // Min/max are recovered by scanning the sealed forward index despite
the mutable segment not tracking them.
+ assertEquals(metricMetadata.getMinValue(), 64L);
+ assertEquals(metricMetadata.getMaxValue(), 73L);
+
+ // The BitSliced range index must be present on the aggregated
no-dictionary column.
+ try (SegmentLocalFSDirectory segmentDir = new
SegmentLocalFSDirectory(indexDir, segmentMetadata, ReadMode.mmap);
+ SegmentDirectory.Reader segmentReader = segmentDir.createReader()) {
+ assertTrue(segmentReader.hasIndexFor(LONG_COLUMN4,
StandardIndexes.range()),
+ "Range index should be built on the aggregated no-dictionary
column");
+ }
+ } finally {
+ mutableSegmentImpl.destroy();
+ }
+ }
+
private List<GenericRow> generateTestData() {
LinkedList<GenericRow> rows = new LinkedList<>();
diff --git
a/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/realtime/converter/stats/MutableNoDictColumnStatisticsTest.java
b/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/realtime/converter/stats/MutableNoDictColumnStatisticsTest.java
index 7131513310b..22ae84fb6b3 100644
---
a/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/realtime/converter/stats/MutableNoDictColumnStatisticsTest.java
+++
b/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/realtime/converter/stats/MutableNoDictColumnStatisticsTest.java
@@ -722,6 +722,153 @@ public class MutableNoDictColumnStatisticsTest {
assertEquals(stats.getMaxRowLengthInBytes(), 5);
}
+ // ======== Computed min/max when the mutable segment does not track it
(ingestion-aggregated columns) ========
+
+ @DataProvider(name = "computedMinMaxTypes")
+ public Object[][] computedMinMaxTypes() {
+ // {type, unsorted forward-index values, expectedMin, expectedMax}. Only
INT/LONG are recovered — those are the
+ // only types whose BitSliced range index reads the value domain.
+ return new Object[][]{
+ {DataType.INT, new Comparable[]{20, 10, 30}, 10, 30},
+ {DataType.LONG, new Comparable[]{200L, 100L, 300L}, 100L, 300L}
+ };
+ }
+
+ @Test(dataProvider = "computedMinMaxTypes")
+ public void testComputesMinMaxWhenMetadataNull(DataType type, Comparable[]
values, Comparable expectedMin,
+ Comparable expectedMax) {
+ // Ingestion-aggregated no-dictionary metric columns skip min/max
tracking, so the mutable metadata reports null.
+ // The statistics must recover the value domain by scanning the sealed
forward index.
+ int numDocs = values.length;
+ FieldSpec fieldSpec = new DimensionFieldSpec("col", type, true);
+
+ DataSourceMetadata metadata = mockMetadata(fieldSpec, numDocs);
+ when(metadata.getMinValue()).thenReturn(null);
+ when(metadata.getMaxValue()).thenReturn(null);
+
+ MutableForwardIndex forwardIndex = mock(MutableForwardIndex.class);
+ when(forwardIndex.isSingleValue()).thenReturn(true);
+ stubForwardIndexReads(forwardIndex, type, values);
+
+ MutableNoDictColumnStatistics stats =
+ new MutableNoDictColumnStatistics(mockNoDictDataSource(metadata,
forwardIndex), null, false);
+
+ assertEquals(stats.getMinValue(), expectedMin);
+ assertEquals(stats.getMaxValue(), expectedMax);
+ }
+
+ @DataProvider(name = "unrecoveredMinMaxTypes")
+ public Object[][] unrecoveredMinMaxTypes() {
+ return new Object[][]{{DataType.FLOAT}, {DataType.DOUBLE}};
+ }
+
+ @Test(dataProvider = "unrecoveredMinMaxTypes")
+ public void testMinMaxNotRecoveredForFloatingPointWhenMetadataNull(DataType
type) {
+ // FLOAT/DOUBLE range indexes use the full floating-point ordinal domain
and never read min/max, so the scan is
+ // intentionally skipped for them: min/max stay null (unchanged behavior)
and no forward-index read is expected.
+ FieldSpec fieldSpec = new DimensionFieldSpec("col", type, true);
+ DataSourceMetadata metadata = mockMetadata(fieldSpec, 3);
+ when(metadata.getMinValue()).thenReturn(null);
+ when(metadata.getMaxValue()).thenReturn(null);
+
+ MutableForwardIndex forwardIndex = mock(MutableForwardIndex.class);
+ when(forwardIndex.isSingleValue()).thenReturn(true);
+
+ MutableNoDictColumnStatistics stats =
+ new MutableNoDictColumnStatistics(mockNoDictDataSource(metadata,
forwardIndex), null, false);
+
+ assertNull(stats.getMinValue());
+ assertNull(stats.getMaxValue());
+ }
+
+ @Test
+ public void testMinMaxNullForMultiValueWhenMetadataNull() {
+ // Multi-value columns have no scalar min/max; when metadata reports null
there is nothing to recover.
+ FieldSpec fieldSpec = new DimensionFieldSpec("col", DataType.INT, false);
+ DataSourceMetadata metadata = mockMetadata(fieldSpec, 3);
+ when(metadata.getMinValue()).thenReturn(null);
+ when(metadata.getMaxValue()).thenReturn(null);
+
+ MutableForwardIndex forwardIndex = mock(MutableForwardIndex.class);
+ when(forwardIndex.isSingleValue()).thenReturn(false);
+
+ MutableNoDictColumnStatistics stats =
+ new MutableNoDictColumnStatistics(mockNoDictDataSource(metadata,
forwardIndex), null, false);
+
+ assertNull(stats.getMinValue());
+ assertNull(stats.getMaxValue());
+ }
+
+ @Test
+ public void testComputedMinMaxAndIsSortedShareOneScan() {
+ // Recovering an untracked value domain must not cost a second pass: the
recovery scan walks the same docs in the
+ // same order computeSorted() would, so it answers both. A segment commit
still reads such a column exactly once.
+ int numDocs = 3;
+ FieldSpec fieldSpec = new DimensionFieldSpec("col", DataType.INT, true);
+ Comparable[] values = fixedWidthValues(DataType.INT);
+
+ DataSourceMetadata metadata = mockMetadata(fieldSpec, numDocs);
+
+ MutableForwardIndex forwardIndex = mock(MutableForwardIndex.class);
+ when(forwardIndex.isSingleValue()).thenReturn(true);
+ stubForwardIndexReads(forwardIndex, DataType.INT, values);
+
+ MutableNoDictColumnStatistics stats =
+ new MutableNoDictColumnStatistics(mockNoDictDataSource(metadata,
forwardIndex), null, false);
+
+ assertEquals(stats.getMinValue(), 10);
+ assertEquals(stats.getMaxValue(), 30);
+ assertTrue(stats.isSorted());
+ verify(forwardIndex, times(numDocs)).getInt(anyInt());
+ }
+
+ @Test
+ public void testComputedMinMaxReportsUnsortedInOneScan() {
+ // Same single pass, but the values are not non-decreasing: the recovered
domain is still correct and sortedness
+ // comes back false without a second scan.
+ Comparable[] values = new Comparable[]{20, 10, 30};
+ int numDocs = values.length;
+ FieldSpec fieldSpec = new DimensionFieldSpec("col", DataType.INT, true);
+
+ DataSourceMetadata metadata = mockMetadata(fieldSpec, numDocs);
+
+ MutableForwardIndex forwardIndex = mock(MutableForwardIndex.class);
+ when(forwardIndex.isSingleValue()).thenReturn(true);
+ stubForwardIndexReads(forwardIndex, DataType.INT, values);
+
+ MutableNoDictColumnStatistics stats =
+ new MutableNoDictColumnStatistics(mockNoDictDataSource(metadata,
forwardIndex), null, false);
+
+ assertFalse(stats.isSorted());
+ assertEquals(stats.getMinValue(), 10);
+ assertEquals(stats.getMaxValue(), 30);
+ verify(forwardIndex, times(numDocs)).getInt(anyInt());
+ }
+
+ @Test
+ public void testComputedMinMaxHonoursSortedDocIdOrder() {
+ // With an explicit sort order the recovery scan must walk docs in that
order, so sortedness is judged on the
+ // sorted view while min/max stay order-independent.
+ Comparable[] values = new Comparable[]{30, 10, 20};
+ int numDocs = values.length;
+ FieldSpec fieldSpec = new DimensionFieldSpec("col", DataType.INT, true);
+
+ DataSourceMetadata metadata = mockMetadata(fieldSpec, numDocs);
+
+ MutableForwardIndex forwardIndex = mock(MutableForwardIndex.class);
+ when(forwardIndex.isSingleValue()).thenReturn(true);
+ stubForwardIndexReads(forwardIndex, DataType.INT, values);
+
+ // Read in the order 10, 20, 30 -> sorted, even though the raw docId order
is not.
+ MutableNoDictColumnStatistics stats =
+ new MutableNoDictColumnStatistics(mockNoDictDataSource(metadata,
forwardIndex), new int[]{1, 2, 0}, false);
+
+ assertEquals(stats.getMinValue(), 10);
+ assertEquals(stats.getMaxValue(), 30);
+ assertTrue(stats.isSorted());
+ verify(forwardIndex, times(numDocs)).getInt(anyInt());
+ }
+
// ======== Helpers ========
private static DataSourceMetadata mockMetadata(FieldSpec fieldSpec, int
numDocs) {
diff --git
a/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/segment/index/loader/invertedindex/RangeIndexHandlerNullMinMaxTest.java
b/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/segment/index/loader/invertedindex/RangeIndexHandlerNullMinMaxTest.java
new file mode 100644
index 00000000000..4f64f530f16
--- /dev/null
+++
b/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/segment/index/loader/invertedindex/RangeIndexHandlerNullMinMaxTest.java
@@ -0,0 +1,243 @@
+/**
+ * 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.segment.local.segment.index.loader.invertedindex;
+
+import java.io.File;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+import org.apache.commons.configuration2.PropertiesConfiguration;
+import org.apache.commons.io.FileUtils;
+import
org.apache.pinot.segment.local.segment.creator.impl.SegmentIndexCreationDriverImpl;
+import
org.apache.pinot.segment.local.segment.index.readers.BitSlicedRangeIndexReader;
+import org.apache.pinot.segment.local.segment.readers.GenericRowRecordReader;
+import org.apache.pinot.segment.local.segment.store.SegmentLocalFSDirectory;
+import org.apache.pinot.segment.spi.ColumnMetadata;
+import org.apache.pinot.segment.spi.V1Constants;
+import org.apache.pinot.segment.spi.creator.SegmentGeneratorConfig;
+import org.apache.pinot.segment.spi.index.FieldIndexConfigs;
+import org.apache.pinot.segment.spi.index.RangeIndexConfig;
+import org.apache.pinot.segment.spi.index.StandardIndexes;
+import org.apache.pinot.segment.spi.index.metadata.SegmentMetadataImpl;
+import org.apache.pinot.segment.spi.memory.PinotDataBuffer;
+import org.apache.pinot.segment.spi.store.SegmentDirectory;
+import org.apache.pinot.segment.spi.utils.SegmentMetadataUtils;
+import org.apache.pinot.spi.config.table.TableConfig;
+import org.apache.pinot.spi.config.table.TableType;
+import org.apache.pinot.spi.data.FieldSpec.DataType;
+import org.apache.pinot.spi.data.Schema;
+import org.apache.pinot.spi.data.readers.GenericRow;
+import org.apache.pinot.spi.utils.ReadMode;
+import org.apache.pinot.spi.utils.builder.TableConfigBuilder;
+import org.roaringbitmap.buffer.ImmutableRoaringBitmap;
+import org.testng.annotations.AfterClass;
+import org.testng.annotations.Test;
+
+import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertNotNull;
+import static org.testng.Assert.assertNull;
+import static org.testng.Assert.assertTrue;
+
+
+/// Tests [RangeIndexHandler] building a BitSliced (v2) range index on a
no-dictionary column whose metadata carries
+/// no min/max value.
+///
+/// Ingestion-aggregated metric columns are forced no-dictionary and skip
min/max tracking while consuming, so
+/// segments committed before that domain is recovered at seal time report
null min/max. The BitSliced creator
+/// subtracts the column min for INT/LONG, so without recovery it dereferences
a null and the load fails. The handler
+/// recovers the domain by scanning the forward index; these tests pin that
the index is built and answers correctly.
+///
+/// The segment is built normally and then stripped of its min/max metadata,
which is what such a segment looks like
+/// on disk.
+public class RangeIndexHandlerNullMinMaxTest {
+ private static final File TEMP_DIR =
+ new File(FileUtils.getTempDirectory(),
RangeIndexHandlerNullMinMaxTest.class.getSimpleName());
+ private static final String SEGMENT_NAME = "testSegment";
+ private static final String INT_COLUMN = "intCol";
+ private static final String LONG_COLUMN = "longCol";
+
+ // Deliberately unsorted, and negative so the subtract-min in the creator is
actually exercised.
+ private static final int[] INT_VALUES = {40, -10, 25, -10, 100};
+ private static final long[] LONG_VALUES = {4000L, -1000L, 2500L, -1000L,
10000L};
+
+ @Test
+ public void testBuildsRangeIndexWhenMetadataMinMaxMissing()
+ throws Exception {
+ File indexDir = buildSegmentWithoutMinMax();
+
+ updateRangeIndices(indexDir);
+
+ SegmentMetadataImpl reloaded = new SegmentMetadataImpl(indexDir);
+ // The recovery happens inside the handler and is not written back to
metadata, so the column still reports null.
+ assertNull(reloaded.getColumnMetadataFor(INT_COLUMN).getMinValue());
+ assertNull(reloaded.getColumnMetadataFor(INT_COLUMN).getMaxValue());
+
+ try (SegmentDirectory segmentDirectory = new
SegmentLocalFSDirectory(indexDir, reloaded, ReadMode.mmap);
+ SegmentDirectory.Reader reader = segmentDirectory.createReader()) {
+ assertNotNull(reader);
+
+ PinotDataBuffer intBuffer = reader.getIndexFor(INT_COLUMN,
StandardIndexes.range());
+ assertNotNull(intBuffer, "Range index expected to be built for the INT
column");
+ ColumnMetadata intMetadata = reloaded.getColumnMetadataFor(INT_COLUMN);
+ BitSlicedRangeIndexReader intReader = new
BitSlicedRangeIndexReader(intBuffer, intMetadata);
+
+ // Range fully inside the recovered domain.
+ assertDocIds(intReader.getMatchingDocIds(-10, 25), 1, 2, 3);
+ // Single point at the recovered minimum.
+ assertDocIds(intReader.getMatchingDocIds(-10, -10), 1, 3);
+ // Range covering the whole column.
+ assertDocIds(intReader.getMatchingDocIds(-10, 100), 0, 1, 2, 3, 4);
+ // Range entirely below the recovered minimum matches nothing.
+ assertDocIds(intReader.getMatchingDocIds(-100, -50));
+
+ PinotDataBuffer longBuffer = reader.getIndexFor(LONG_COLUMN,
StandardIndexes.range());
+ assertNotNull(longBuffer, "Range index expected to be built for the LONG
column");
+ BitSlicedRangeIndexReader longReader =
+ new BitSlicedRangeIndexReader(longBuffer,
reloaded.getColumnMetadataFor(LONG_COLUMN));
+
+ assertDocIds(longReader.getMatchingDocIds(-1000L, 2500L), 1, 2, 3);
+ assertDocIds(longReader.getMatchingDocIds(2500L, 10000L), 0, 2, 4);
+ assertDocIds(longReader.getMatchingDocIds(-5000L, -2000L));
+ }
+ }
+
+ @Test
+ public void testOpenEndedQueryIsCorrectWithoutMetadataMax()
+ throws Exception {
+ // The reader takes the column max from metadata, which is still null
here, so it falls back to Long.MAX_VALUE.
+ // The RangeBitmap domain is self-contained, so results must stay correct
even though max pruning is weaker.
+ File indexDir = buildSegmentWithoutMinMax();
+
+ updateRangeIndices(indexDir);
+
+ SegmentMetadataImpl reloaded = new SegmentMetadataImpl(indexDir);
+ try (SegmentDirectory segmentDirectory = new
SegmentLocalFSDirectory(indexDir, reloaded, ReadMode.mmap);
+ SegmentDirectory.Reader reader = segmentDirectory.createReader()) {
+ BitSlicedRangeIndexReader intReader =
+ new BitSlicedRangeIndexReader(reader.getIndexFor(INT_COLUMN,
StandardIndexes.range()),
+ reloaded.getColumnMetadataFor(INT_COLUMN));
+
+ // Upper bound far beyond the real max: every doc at or above the lower
bound must still match.
+ assertDocIds(intReader.getMatchingDocIds(25, Integer.MAX_VALUE), 0, 2,
4);
+ assertDocIds(intReader.getMatchingDocIds(Integer.MIN_VALUE,
Integer.MAX_VALUE), 0, 1, 2, 3, 4);
+ }
+ }
+
+ private static void assertDocIds(ImmutableRoaringBitmap actual, int...
expected) {
+ assertNotNull(actual);
+ int[] actualDocIds = actual.toArray();
+ assertEquals(actualDocIds.length, expected.length,
+ "Unexpected match count, got " + Arrays.toString(actualDocIds));
+ for (int i = 0; i < expected.length; i++) {
+ assertEquals(actualDocIds[i], expected[i]);
+ }
+ }
+
+ /// Runs the handler over both columns with a v2 range index configured,
which is the path that needs the value
+ /// domain up front.
+ private static void updateRangeIndices(File indexDir)
+ throws Exception {
+ SegmentMetadataImpl segmentMetadata = new SegmentMetadataImpl(indexDir);
+ FieldIndexConfigs rangeV2 =
+ new FieldIndexConfigs.Builder().add(StandardIndexes.range(), new
RangeIndexConfig(2)).build();
+ Map<String, FieldIndexConfigs> configs = Map.of(INT_COLUMN, rangeV2,
LONG_COLUMN, rangeV2);
+ // A directory hands out either a reader or a writer, not both, so check
and build in separate opens.
+ try (SegmentDirectory segmentDirectory = new
SegmentLocalFSDirectory(indexDir, segmentMetadata, ReadMode.mmap);
+ SegmentDirectory.Reader reader = segmentDirectory.createReader()) {
+ RangeIndexHandler handler =
+ new RangeIndexHandler(segmentDirectory, configs,
createTableConfig(), createSchema());
+ assertTrue(handler.needUpdateIndices(reader),
+ "Range index is absent on disk, so the handler is expected to build
it");
+ }
+ try (SegmentDirectory segmentDirectory = new
SegmentLocalFSDirectory(indexDir, segmentMetadata, ReadMode.mmap);
+ SegmentDirectory.Writer writer = segmentDirectory.createWriter()) {
+ RangeIndexHandler handler =
+ new RangeIndexHandler(segmentDirectory, configs,
createTableConfig(), createSchema());
+ handler.updateIndices(writer);
+ }
+ }
+
+ private static Schema createSchema() {
+ return new Schema.SchemaBuilder().setSchemaName("testSchema")
+ .addSingleValueDimension(INT_COLUMN, DataType.INT)
+ .addSingleValueDimension(LONG_COLUMN, DataType.LONG)
+ .build();
+ }
+
+ private static TableConfig createTableConfig() {
+ return new TableConfigBuilder(TableType.OFFLINE).setTableName("testTable")
+ .setNoDictionaryColumns(List.of(INT_COLUMN, LONG_COLUMN))
+ .build();
+ }
+
+ private static File buildSegmentWithoutMinMax()
+ throws Exception {
+ FileUtils.deleteQuietly(TEMP_DIR);
+ SegmentGeneratorConfig config = new
SegmentGeneratorConfig(createTableConfig(), createSchema());
+ config.setOutDir(TEMP_DIR.getAbsolutePath());
+ config.setSegmentName(SEGMENT_NAME);
+
+ SegmentIndexCreationDriverImpl driver = new
SegmentIndexCreationDriverImpl();
+ driver.init(config, new GenericRowRecordReader(createRows()));
+ driver.build();
+
+ File indexDir = new File(TEMP_DIR, SEGMENT_NAME);
+ removeMinMaxValuesFromMetadataFile(indexDir);
+ return indexDir;
+ }
+
+ private static List<GenericRow> createRows() {
+ List<GenericRow> rows = new ArrayList<>();
+ for (int i = 0; i < INT_VALUES.length; i++) {
+ GenericRow row = new GenericRow();
+ row.putValue(INT_COLUMN, INT_VALUES[i]);
+ row.putValue(LONG_COLUMN, LONG_VALUES[i]);
+ rows.add(row);
+ }
+ return rows;
+ }
+
+ /// Strips min/max from the segment metadata, reproducing what a segment
committed with untracked min/max looks
+ /// like on disk.
+ private static void removeMinMaxValuesFromMetadataFile(File indexDir)
+ throws Exception {
+ PropertiesConfiguration configuration =
SegmentMetadataUtils.getPropertiesConfiguration(indexDir);
+ Iterator<String> keys = configuration.getKeys();
+ List<String> keysToClear = new ArrayList<>();
+ while (keys.hasNext()) {
+ String key = keys.next();
+ if (key.endsWith(V1Constants.MetadataKeys.Column.MIN_VALUE)
+ || key.endsWith(V1Constants.MetadataKeys.Column.MAX_VALUE)
+ ||
key.endsWith(V1Constants.MetadataKeys.Column.MIN_MAX_VALUE_INVALID)) {
+ keysToClear.add(key);
+ }
+ }
+ for (String key : keysToClear) {
+ configuration.clearProperty(key);
+ }
+ SegmentMetadataUtils.savePropertiesConfiguration(configuration, indexDir);
+ }
+
+ @AfterClass
+ public void tearDown() {
+ FileUtils.deleteQuietly(TEMP_DIR);
+ }
+}
diff --git
a/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/utils/IndexCombinationValidationTest.java
b/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/utils/IndexCombinationValidationTest.java
index b2fb2c5b5cf..80d18b6d615 100644
---
a/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/utils/IndexCombinationValidationTest.java
+++
b/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/utils/IndexCombinationValidationTest.java
@@ -20,13 +20,17 @@ package org.apache.pinot.segment.local.utils;
import com.fasterxml.jackson.databind.node.ObjectNode;
import java.util.Arrays;
+import java.util.HashMap;
import java.util.List;
+import java.util.Map;
import org.apache.pinot.spi.config.table.FieldConfig;
import org.apache.pinot.spi.config.table.FieldConfig.CompressionCodec;
import org.apache.pinot.spi.config.table.FieldConfig.EncodingType;
import org.apache.pinot.spi.config.table.FieldConfig.IndexType;
import org.apache.pinot.spi.config.table.TableConfig;
import org.apache.pinot.spi.config.table.TableType;
+import org.apache.pinot.spi.config.table.ingestion.AggregationConfig;
+import org.apache.pinot.spi.config.table.ingestion.IngestionConfig;
import org.apache.pinot.spi.data.FieldSpec.DataType;
import org.apache.pinot.spi.data.Schema;
import org.apache.pinot.spi.utils.JsonUtils;
@@ -660,4 +664,117 @@ public class IndexCombinationValidationTest {
assertTrue(msg.contains("without dictionary"), "Error should explain the
problem");
}
}
+
+ // ============================================================
+ // 13. BitSliced range index (version 2) on ingestion-aggregated columns
+ // Ingestion-time metrics aggregation forces its aggregated metric
columns to be no-dictionary and does not
+ // track their min/max during consumption. The value domain is recovered
by scanning the sealed forward index
+ // at segment build time (see MutableNoDictColumnStatistics), so the
combination is supported and must pass
+ // table-config validation.
+ // ============================================================
+
+ private static final String AGG_METRIC_COL = "m1"; // LONG metric,
aggregation destination
+ private static final String AGG_SOURCE_COL = "s1"; // INT dimension,
aggregation source
+ private static final String TIME_COL = "ts"; // realtime tables
require a time column
+
+ /// Schema with a metric (aggregation destination), a dimension source
column, and a time column.
+ private static Schema aggregationSchema() {
+ return new Schema.SchemaBuilder()
+ .setSchemaName(TABLE_NAME)
+ .addSingleValueDimension(AGG_SOURCE_COL, DataType.INT)
+ .addMetric(AGG_METRIC_COL, DataType.LONG)
+ .addDateTime(TIME_COL, DataType.TIMESTAMP, "1:MILLISECONDS:EPOCH",
"1:MILLISECONDS")
+ .build();
+ }
+
+ private static Map<String, String> streamConfigs() {
+ Map<String, String> streamConfigs = new HashMap<>();
+ streamConfigs.put("streamType", "kafka");
+ streamConfigs.put("stream.kafka.topic.name", "test");
+ streamConfigs.put("stream.kafka.decoder.class.name",
+ "org.apache.pinot.plugin.stream.kafka.KafkaJSONMessageDecoder");
+ return streamConfigs;
+ }
+
+ /// REALTIME table builder wired with a stream config and time column so
full validation can run.
+ private static TableConfigBuilder realtimeBuilder() {
+ return new TableConfigBuilder(TableType.REALTIME).setTableName(TABLE_NAME)
+ .setTimeColumnName(TIME_COL)
+ .setStreamConfigs(streamConfigs());
+ }
+
+ /// IngestionConfig that aggregates `SUM(s1)` into the no-dictionary metric
column `m1`.
+ private static IngestionConfig sumAggregationIngestionConfig() {
+ IngestionConfig ingestionConfig = new IngestionConfig();
+ ingestionConfig.setAggregationConfigs(
+ List.of(new AggregationConfig(AGG_METRIC_COL, "SUM(" + AGG_SOURCE_COL
+ ")")));
+ return ingestionConfig;
+ }
+
+ private static void assertValid(TableConfig tableConfig, Schema schema) {
+ try {
+ TableConfigUtils.validate(tableConfig, schema);
+ } catch (Exception e) {
+ fail("Expected validation to pass but got: " + e.getMessage(), e);
+ }
+ }
+
+ @Test
+ public void testRangeIndexOnIngestionAggregatedColumnPasses() {
+ // aggregationConfigs (SUM into a no-dictionary metric column) + BitSliced
range index on that metric column.
+ // Min/max are recovered at segment build time, so this combination is
accepted.
+ TableConfig tc = realtimeBuilder()
+ .setIngestionConfig(sumAggregationIngestionConfig())
+ .setNoDictionaryColumns(List.of(AGG_METRIC_COL))
+ .setRangeIndexColumns(List.of(AGG_METRIC_COL))
+ .build();
+ assertValid(tc, aggregationSchema());
+ }
+
+ @Test
+ public void testRangeIndexOnAggregateMetricsColumnPasses() {
+ // Legacy aggregateMetrics flag (implicitly SUMs every metric) + BitSliced
range index on the no-dictionary
+ // metric column. Also accepted for the same reason.
+ TableConfig tc = realtimeBuilder()
+ .setAggregateMetrics(true)
+ .setNoDictionaryColumns(List.of(AGG_METRIC_COL))
+ .setRangeIndexColumns(List.of(AGG_METRIC_COL))
+ .build();
+ assertValid(tc, aggregationSchema());
+ }
+
+ @Test
+ public void testRangeIndexV1OnIngestionAggregatedColumnPasses() {
+ // Version 1 (legacy) range index does not read min/max, so it is safe on
an aggregated no-dictionary column.
+ TableConfig tc = realtimeBuilder()
+ .setIngestionConfig(sumAggregationIngestionConfig())
+ .setNoDictionaryColumns(List.of(AGG_METRIC_COL))
+ .setRangeIndexColumns(List.of(AGG_METRIC_COL))
+ .build();
+ tc.getIndexingConfig().setRangeIndexVersion(1);
+ assertValid(tc, aggregationSchema());
+ }
+
+ @Test
+ public void testRangeIndexOnNonAggregatedNoDictNumericColumnPasses() {
+ // A plain no-dictionary numeric column (not an aggregation destination)
still supports the range index.
+ TableConfig tc = realtimeBuilder()
+ .setIngestionConfig(sumAggregationIngestionConfig())
+ .setNoDictionaryColumns(List.of(AGG_METRIC_COL, AGG_SOURCE_COL))
+ .setRangeIndexColumns(List.of(AGG_SOURCE_COL))
+ .build();
+ assertValid(tc, aggregationSchema());
+ }
+
+ @Test
+ public void testRangeIndexOnAggregatedColumnOfflineTablePasses() {
+ // Ingestion aggregation is inert for OFFLINE tables (min/max are computed
normally), so the range index is
+ // allowed there - the validation is scoped to REALTIME to avoid
over-rejecting inert offline configs.
+ TableConfig tc = new
TableConfigBuilder(TableType.OFFLINE).setTableName(TABLE_NAME)
+ .setIngestionConfig(sumAggregationIngestionConfig())
+ .setNoDictionaryColumns(List.of(AGG_METRIC_COL))
+ .setRangeIndexColumns(List.of(AGG_METRIC_COL))
+ .build();
+ assertValid(tc, aggregationSchema());
+ }
}
diff --git
a/pinot-segment-spi/src/main/java/org/apache/pinot/segment/spi/creator/IndexCreationContext.java
b/pinot-segment-spi/src/main/java/org/apache/pinot/segment/spi/creator/IndexCreationContext.java
index 17ca2edbf1a..763772a5b6d 100644
---
a/pinot-segment-spi/src/main/java/org/apache/pinot/segment/spi/creator/IndexCreationContext.java
+++
b/pinot-segment-spi/src/main/java/org/apache/pinot/segment/spi/creator/IndexCreationContext.java
@@ -127,6 +127,12 @@ public interface IndexCreationContext extends ColumnShape {
private int _maxNumberOfMultiValues;
private int _maxRowLengthInBytes;
private boolean _hasDictionary;
+ // Overrides for min/max; null means "delegate to the source ColumnShape".
Set only when the source reports a
+ // null value domain that the caller has recomputed (see withMinValue /
withMaxValue).
+ @Nullable
+ private Comparable<?> _minValue;
+ @Nullable
+ private Comparable<?> _maxValue;
// Build-time toggles.
private boolean _onHeap;
@@ -226,6 +232,20 @@ public interface IndexCreationContext extends ColumnShape {
return this;
}
+ /// Overrides the min value that would otherwise be sourced from the
[ColumnShape]. Used when the source reports a
+ /// null min/max (e.g. ingestion-aggregated no-dictionary columns on the
index-handler path) and the caller has
+ /// recomputed it. A null argument leaves delegation to the source
unchanged.
+ public Builder withMinValue(@Nullable Comparable<?> minValue) {
+ _minValue = minValue;
+ return this;
+ }
+
+ /// Overrides the max value derived from the source [ColumnShape]. See
[#withMinValue].
+ public Builder withMaxValue(@Nullable Comparable<?> maxValue) {
+ _maxValue = maxValue;
+ return this;
+ }
+
// Build-time toggle setters.
public Builder withOnHeap(boolean onHeap) {
@@ -299,6 +319,10 @@ public interface IndexCreationContext extends ColumnShape {
private final int _maxNumberOfMultiValues;
private final int _maxRowLengthInBytes;
private final boolean _hasDictionary;
+ @Nullable
+ private final Comparable<?> _minValue;
+ @Nullable
+ private final Comparable<?> _maxValue;
// Build-time toggles.
private final boolean _onHeap;
@@ -328,6 +352,8 @@ public interface IndexCreationContext extends ColumnShape {
_maxNumberOfMultiValues = builder._maxNumberOfMultiValues;
_maxRowLengthInBytes = builder._maxRowLengthInBytes;
_hasDictionary = builder._hasDictionary;
+ _minValue = builder._minValue;
+ _maxValue = builder._maxValue;
_onHeap = builder._onHeap;
_optimizeDictionary = builder._optimizedDictionary;
_textCommitOnClose = builder._textCommitOnClose;
@@ -388,13 +414,14 @@ public interface IndexCreationContext extends ColumnShape
{
@Override
@Nullable
public Comparable<?> getMinValue() {
- return _columnShape.getMinValue();
+ // Delegate to the source ColumnShape unless a caller supplied an
explicit override.
+ return _minValue != null ? _minValue : _columnShape.getMinValue();
}
@Override
@Nullable
public Comparable<?> getMaxValue() {
- return _columnShape.getMaxValue();
+ return _maxValue != null ? _maxValue : _columnShape.getMaxValue();
}
@Override
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]