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

swaminathanmanish 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 7ef952d8342 Address review on H3 empty-default fix: null-filter 
correctness, reload perf, raw-path test (#19128)
7ef952d8342 is described below

commit 7ef952d8342834ea2d4e3d42be34aa87b2558b09
Author: Arunkumar Saravanan <[email protected]>
AuthorDate: Fri Jul 31 16:09:20 2026 +0530

    Address review on H3 empty-default fix: null-filter correctness, reload 
perf, raw-path test (#19128)
    
    * Address review on H3 empty-default fix: null-filter correctness, reload 
perf, raw-path test
    
    Follow-up to #19032.
    
    - Correctness: H3IndexFilterOperator and H3InclusionIndexFilterOperator 
excluded
      null rows (which have no H3 posting) from match-all, lower-bound-flip and
      negative-check complement results, so they no longer leak into
      ST_Contains/Within(...)=false and lower-bound/match-all distance filters 
when
      query null handling is enabled.
    - Performance: GeoSpatialIndexCreator.toGeometry() fast-paths the empty
      default-null byte array to null instead of throwing (and catching) a
      BufferUnderflowException per row on reload; H3IndexHandler decodes each
      dictionary id at most once instead of per doc.
    - Tests: SegmentPreProcessorTest now covers V1/V3 x DICTIONARY/RAW reload 
paths;
      H3IndexQueriesTest adds a null-handling query test asserting null rows are
      excluded from match-all / lower-bound / negative-ST_Within results.
    
    Co-Authored-By: Claude Opus 4.8 <[email protected]>
    Signed-off-by: Arunkumar Saravanan 
<[email protected]>
    
    * Address Copilot review: bound H3 reload decode cache to avoid large 
retained heap
    
    Instead of caching one decoded Geometry per dict id (which for a 
high-cardinality
    geo column retains ~one Geometry per doc and risks OOM during reload), only 
reuse
    the single decoded value for the cardinality-1 empty-default case; 
higher-cardinality
    columns decode per doc via toGeometry(), which still fast-paths the empty 
default
    value so no per-row exception is thrown.
    
    Co-Authored-By: Claude Opus 4.8 <[email protected]>
    Signed-off-by: Arunkumar Saravanan 
<[email protected]>
    
    * Fix checkstyle: use /// markdown doc comments instead of Javadoc
    
    Master added a checkstyle rule (JEP 467) banning /** */ Javadoc in favor of
    /// markdown doc comments. Convert the doc comments added in this PR
    (GeoSpatialIndexCreator.toGeometry, the two H3 filter operators' 
getNullDocIds,
    and the null-handling query test) to /// style.
    
    Co-Authored-By: Claude Opus 4.8 <[email protected]>
    Signed-off-by: Arunkumar Saravanan 
<[email protected]>
    
    ---------
    
    Signed-off-by: Arunkumar Saravanan 
<[email protected]>
    Co-authored-by: Claude Opus 4.8 <[email protected]>
---
 .../filter/H3InclusionIndexFilterOperator.java     | 30 ++++++-
 .../operator/filter/H3IndexFilterOperator.java     | 43 +++++++++-
 .../apache/pinot/queries/H3IndexQueriesTest.java   | 93 ++++++++++++++++++++--
 .../index/loader/invertedindex/H3IndexHandler.java | 30 +++++--
 .../index/loader/SegmentPreProcessorTest.java      | 55 ++++++++++---
 .../spi/index/creator/GeoSpatialIndexCreator.java  | 24 ++++--
 6 files changed, 238 insertions(+), 37 deletions(-)

diff --git 
a/pinot-core/src/main/java/org/apache/pinot/core/operator/filter/H3InclusionIndexFilterOperator.java
 
b/pinot-core/src/main/java/org/apache/pinot/core/operator/filter/H3InclusionIndexFilterOperator.java
index 880c534a0f4..15d7f26610c 100644
--- 
a/pinot-core/src/main/java/org/apache/pinot/core/operator/filter/H3InclusionIndexFilterOperator.java
+++ 
b/pinot-core/src/main/java/org/apache/pinot/core/operator/filter/H3InclusionIndexFilterOperator.java
@@ -22,6 +22,7 @@ import com.google.common.base.CaseFormat;
 import it.unimi.dsi.fastutil.longs.LongIterator;
 import it.unimi.dsi.fastutil.longs.LongSet;
 import java.util.List;
+import javax.annotation.Nullable;
 import org.apache.commons.lang3.tuple.Pair;
 import org.apache.pinot.common.request.context.ExpressionContext;
 import org.apache.pinot.common.request.context.predicate.EqPredicate;
@@ -36,6 +37,7 @@ import 
org.apache.pinot.segment.local.utils.GeometrySerializer;
 import org.apache.pinot.segment.local.utils.H3Utils;
 import org.apache.pinot.segment.spi.IndexSegment;
 import org.apache.pinot.segment.spi.index.reader.H3IndexReader;
+import org.apache.pinot.segment.spi.index.reader.NullValueVectorReader;
 import org.apache.pinot.spi.utils.BooleanUtils;
 import org.locationtech.jts.geom.Geometry;
 import org.roaringbitmap.buffer.BufferFastAggregation;
@@ -51,6 +53,7 @@ public class H3InclusionIndexFilterOperator extends 
BaseFilterOperator {
   private final IndexSegment _segment;
   private final QueryContext _queryContext;
   private final Predicate _predicate;
+  private final String _column;
   private final H3IndexReader _h3IndexReader;
   private final Geometry _geometry;
   private final boolean _isPositiveCheck;
@@ -67,12 +70,13 @@ public class H3InclusionIndexFilterOperator extends 
BaseFilterOperator {
     _isPositiveCheck = BooleanUtils.toBoolean(eqPredicate.getValue());
 
     if (arguments.get(0).getType() == ExpressionContext.Type.IDENTIFIER) {
-      _h3IndexReader = 
segment.getDataSource(arguments.get(0).getIdentifier()).getH3Index();
+      _column = arguments.get(0).getIdentifier();
       _geometry = 
GeometrySerializer.deserialize(arguments.get(1).getLiteral().getBytesValue());
     } else {
-      _h3IndexReader = 
segment.getDataSource(arguments.get(1).getIdentifier()).getH3Index();
+      _column = arguments.get(1).getIdentifier();
       _geometry = 
GeometrySerializer.deserialize(arguments.get(0).getLiteral().getBytesValue());
     }
+    _h3IndexReader = segment.getDataSource(_column).getH3Index();
     // must be some h3 index
     assert _h3IndexReader != null : "the column must have H3 index setup.";
   }
@@ -111,6 +115,12 @@ public class H3InclusionIndexFilterOperator extends 
BaseFilterOperator {
       MutableRoaringBitmap fullNotMatch = potentialMatch.clone();
       fullNotMatch.flip(0L, _numDocs);
       fullNotMatch.andNot(fullMatch);
+      // The flip turns non-matching docs into full not-matches, but null 
geometries have no posting and must not be
+      // reported as matches for the negative check when query null handling 
is enabled, so exclude them.
+      ImmutableRoaringBitmap nullDocIds = getNullDocIds();
+      if (nullDocIds != null) {
+        fullNotMatch.andNot(nullDocIds);
+      }
       return getFilterBlock(fullNotMatch, potentialMatch);
     }
   }
@@ -130,6 +140,22 @@ public class H3InclusionIndexFilterOperator extends 
BaseFilterOperator {
     };
   }
 
+  /// Returns the null document IDs for the indexed column when query null 
handling is enabled and the column has a
+  /// non-empty null-value vector, otherwise `null`. Used to exclude null rows 
from the negative-check complement
+  /// result, which is built across all document IDs and would otherwise 
include null rows that have no H3 posting.
+  @Nullable
+  private ImmutableRoaringBitmap getNullDocIds() {
+    if (!_queryContext.isNullHandlingEnabled()) {
+      return null;
+    }
+    NullValueVectorReader nullValueVector = 
_segment.getDataSource(_column).getNullValueVector();
+    if (nullValueVector == null) {
+      return null;
+    }
+    ImmutableRoaringBitmap nullDocIds = nullValueVector.getNullBitmap();
+    return nullDocIds != null && !nullDocIds.isEmpty() ? nullDocIds : null;
+  }
+
   @Override
   public List<Operator> getChildOperators() {
     return List.of();
diff --git 
a/pinot-core/src/main/java/org/apache/pinot/core/operator/filter/H3IndexFilterOperator.java
 
b/pinot-core/src/main/java/org/apache/pinot/core/operator/filter/H3IndexFilterOperator.java
index 931f10072d0..e9d20aa49c3 100644
--- 
a/pinot-core/src/main/java/org/apache/pinot/core/operator/filter/H3IndexFilterOperator.java
+++ 
b/pinot-core/src/main/java/org/apache/pinot/core/operator/filter/H3IndexFilterOperator.java
@@ -24,6 +24,7 @@ import com.uber.h3core.LengthUnit;
 import java.util.HashSet;
 import java.util.List;
 import java.util.Set;
+import javax.annotation.Nullable;
 import org.apache.pinot.common.request.context.ExpressionContext;
 import org.apache.pinot.common.request.context.predicate.Predicate;
 import org.apache.pinot.common.request.context.predicate.RangePredicate;
@@ -39,7 +40,9 @@ import 
org.apache.pinot.segment.local.utils.GeometrySerializer;
 import org.apache.pinot.segment.local.utils.H3Utils;
 import org.apache.pinot.segment.spi.IndexSegment;
 import org.apache.pinot.segment.spi.index.reader.H3IndexReader;
+import org.apache.pinot.segment.spi.index.reader.NullValueVectorReader;
 import org.locationtech.jts.geom.Coordinate;
+import org.roaringbitmap.buffer.ImmutableRoaringBitmap;
 import org.roaringbitmap.buffer.MutableRoaringBitmap;
 
 
@@ -50,6 +53,7 @@ public class H3IndexFilterOperator extends BaseFilterOperator 
{
   private final IndexSegment _segment;
   private final QueryContext _queryContext;
   private final Predicate _predicate;
+  private final String _column;
   private final H3IndexReader _h3IndexReader;
   private final long _h3Id;
   private final double _edgeLength;
@@ -66,12 +70,13 @@ public class H3IndexFilterOperator extends 
BaseFilterOperator {
     List<ExpressionContext> arguments = 
predicate.getLhs().getFunction().getArguments();
     Coordinate coordinate;
     if (arguments.get(0).getType() == ExpressionContext.Type.IDENTIFIER) {
-      _h3IndexReader = 
segment.getDataSource(arguments.get(0).getIdentifier()).getH3Index();
+      _column = arguments.get(0).getIdentifier();
       coordinate = 
GeometrySerializer.deserialize(arguments.get(1).getLiteral().getBytesValue()).getCoordinate();
     } else {
-      _h3IndexReader = 
segment.getDataSource(arguments.get(1).getIdentifier()).getH3Index();
+      _column = arguments.get(1).getIdentifier();
       coordinate = 
GeometrySerializer.deserialize(arguments.get(0).getLiteral().getBytesValue()).getCoordinate();
     }
+    _h3IndexReader = segment.getDataSource(_column).getH3Index();
     assert _h3IndexReader != null;
     int resolution = 
_h3IndexReader.getH3IndexResolution().getLowestResolution();
     _h3Id = H3Utils.H3_CORE.latLngToCell(coordinate.y, coordinate.x, 
resolution);
@@ -102,8 +107,16 @@ public class H3IndexFilterOperator extends 
BaseFilterOperator {
         // No lower bound
 
         if (Double.isNaN(_upperBound)) {
-          // No bound, return a match-all block
-          return new MatchAllDocIdSet(_numDocs);
+          // No bound, return a match-all block. Null geometries have no H3 
posting, so with query null handling
+          // enabled they must be excluded from the match-all result instead 
of being reported as matches.
+          ImmutableRoaringBitmap nullDocIds = getNullDocIds();
+          if (nullDocIds == null) {
+            return new MatchAllDocIdSet(_numDocs);
+          }
+          MutableRoaringBitmap matchAllDocIds = new MutableRoaringBitmap();
+          matchAllDocIds.add(0L, _numDocs);
+          matchAllDocIds.andNot(nullDocIds);
+          return new BitmapDocIdSet(matchAllDocIds, _numDocs);
         }
 
         // Upper bound only
@@ -136,6 +149,12 @@ public class H3IndexFilterOperator extends 
BaseFilterOperator {
           fullMatchDocIds.or(_h3IndexReader.getDocIds(partialMatchH3Id));
         }
         fullMatchDocIds.flip(0L, _numDocs);
+        // The flip turns non-matching docs into full matches, but null 
geometries have no posting and must not be
+        // reported as matches when query null handling is enabled, so exclude 
them.
+        ImmutableRoaringBitmap nullDocIds = getNullDocIds();
+        if (nullDocIds != null) {
+          fullMatchDocIds.andNot(nullDocIds);
+        }
 
         // Remove the always not match H3 ids from possible not match H3 ids 
to get the partial match H3 ids
         possibleNotMatchH3Ids.removeAll(alwaysNotMatchH3Ids);
@@ -235,6 +254,22 @@ public class H3IndexFilterOperator extends 
BaseFilterOperator {
     };
   }
 
+  /// Returns the null document IDs for the indexed column when query null 
handling is enabled and the column has a
+  /// non-empty null-value vector, otherwise `null`. Used to exclude null rows 
from match-all and complement results,
+  /// which are built across all document IDs and would otherwise include null 
rows that have no H3 posting.
+  @Nullable
+  private ImmutableRoaringBitmap getNullDocIds() {
+    if (!_queryContext.isNullHandlingEnabled()) {
+      return null;
+    }
+    NullValueVectorReader nullValueVector = 
_segment.getDataSource(_column).getNullValueVector();
+    if (nullValueVector == null) {
+      return null;
+    }
+    ImmutableRoaringBitmap nullDocIds = nullValueVector.getNullBitmap();
+    return nullDocIds != null && !nullDocIds.isEmpty() ? nullDocIds : null;
+  }
+
   @Override
   public List<Operator> getChildOperators() {
     return List.of();
diff --git 
a/pinot-core/src/test/java/org/apache/pinot/queries/H3IndexQueriesTest.java 
b/pinot-core/src/test/java/org/apache/pinot/queries/H3IndexQueriesTest.java
index f4457ebb8e7..32af1e46509 100644
--- a/pinot-core/src/test/java/org/apache/pinot/queries/H3IndexQueriesTest.java
+++ b/pinot-core/src/test/java/org/apache/pinot/queries/H3IndexQueriesTest.java
@@ -39,6 +39,7 @@ import 
org.apache.pinot.segment.spi.creator.SegmentGeneratorConfig;
 import org.apache.pinot.spi.config.table.FieldConfig;
 import org.apache.pinot.spi.config.table.TableConfig;
 import org.apache.pinot.spi.config.table.TableType;
+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.data.readers.GenericRow;
@@ -68,12 +69,24 @@ public class H3IndexQueriesTest extends BaseQueriesTest {
           .addSingleValueDimension(H3_INDEX_GEOMETRY_COLUMN, DataType.BYTES)
           .addSingleValueDimension(NON_H3_INDEX_GEOMETRY_COLUMN, 
DataType.BYTES).build();
   private static final Map<String, String> H3_INDEX_PROPERTIES = 
Map.of("resolutions", "5");
+  private static final List<FieldConfig> H3_FIELD_CONFIGS = List.of(
+      new FieldConfig(H3_INDEX_COLUMN, FieldConfig.EncodingType.DICTIONARY, 
FieldConfig.IndexType.H3, null,
+          H3_INDEX_PROPERTIES),
+      new FieldConfig(H3_INDEX_GEOMETRY_COLUMN, 
FieldConfig.EncodingType.DICTIONARY, FieldConfig.IndexType.H3, null,
+          H3_INDEX_PROPERTIES));
   private static final TableConfig TABLE_CONFIG = new 
TableConfigBuilder(TableType.OFFLINE).setTableName(RAW_TABLE_NAME)
-      .setFieldConfigList(List.of(
-          new FieldConfig(H3_INDEX_COLUMN, 
FieldConfig.EncodingType.DICTIONARY, FieldConfig.IndexType.H3, null,
-              H3_INDEX_PROPERTIES),
-          new FieldConfig(H3_INDEX_GEOMETRY_COLUMN, 
FieldConfig.EncodingType.DICTIONARY, FieldConfig.IndexType.H3, null,
-              H3_INDEX_PROPERTIES))).build();
+      .setFieldConfigList(H3_FIELD_CONFIGS).build();
+  // Null handling enabled so the segment stores a null-value vector, and 
continueOnError enabled so the H3 index
+  // creator skips (rather than fails on) the null geometries during segment 
creation.
+  private static final TableConfig NULL_ENABLED_TABLE_CONFIG =
+      new 
TableConfigBuilder(TableType.OFFLINE).setTableName(RAW_TABLE_NAME).setFieldConfigList(H3_FIELD_CONFIGS)
+          
.setNullHandlingEnabled(true).setIngestionConfig(continueOnErrorIngestionConfig()).build();
+
+  private static IngestionConfig continueOnErrorIngestionConfig() {
+    IngestionConfig ingestionConfig = new IngestionConfig();
+    ingestionConfig.setContinueOnError(true);
+    return ingestionConfig;
+  }
 
   private IndexSegment _indexSegment;
 
@@ -94,18 +107,24 @@ public class H3IndexQueriesTest extends BaseQueriesTest {
 
   public void setUp(List<GenericRow> records)
       throws Exception {
+    setUp(records, TABLE_CONFIG, false);
+  }
+
+  private void setUp(List<GenericRow> records, TableConfig tableConfig, 
boolean nullHandlingEnabled)
+      throws Exception {
     FileUtils.deleteDirectory(INDEX_DIR);
 
-    SegmentGeneratorConfig segmentGeneratorConfig = new 
SegmentGeneratorConfig(TABLE_CONFIG, SCHEMA);
+    SegmentGeneratorConfig segmentGeneratorConfig = new 
SegmentGeneratorConfig(tableConfig, SCHEMA);
     segmentGeneratorConfig.setTableName(RAW_TABLE_NAME);
     segmentGeneratorConfig.setSegmentName(SEGMENT_NAME);
+    segmentGeneratorConfig.setDefaultNullHandlingEnabled(nullHandlingEnabled);
     segmentGeneratorConfig.setOutDir(INDEX_DIR.getPath());
 
     SegmentIndexCreationDriverImpl driver = new 
SegmentIndexCreationDriverImpl();
     driver.init(segmentGeneratorConfig, new GenericRowRecordReader(records));
     driver.build();
 
-    IndexLoadingConfig indexLoadingConfig = new 
IndexLoadingConfig(TABLE_CONFIG, SCHEMA);
+    IndexLoadingConfig indexLoadingConfig = new 
IndexLoadingConfig(tableConfig, SCHEMA);
     _indexSegment = ImmutableSegmentLoader.load(new File(INDEX_DIR, 
SEGMENT_NAME), indexLoadingConfig);
   }
 
@@ -122,6 +141,15 @@ public class H3IndexQueriesTest extends BaseQueriesTest {
     records.add(record);
   }
 
+  private void addNullRecord(List<GenericRow> records) {
+    GenericRow record = new GenericRow();
+    record.putValue(H3_INDEX_COLUMN, null);
+    record.putValue(NON_H3_INDEX_COLUMN, null);
+    record.putValue(H3_INDEX_GEOMETRY_COLUMN, null);
+    record.putValue(NON_H3_INDEX_GEOMETRY_COLUMN, null);
+    records.add(record);
+  }
+
   @Test
   public void testH3Index()
       throws Exception {
@@ -291,6 +319,57 @@ public class H3IndexQueriesTest extends BaseQueriesTest {
     }
   }
 
+  /// With query null handling enabled, rows whose geometry is null have no H3 
posting and must not leak into
+  /// match-all, complement (negative check), or lower-bound H3-index results. 
Each query below matches every non-null
+  /// row, so the correct answer is the non-null record count; before the fix 
the null rows were wrongly counted too.
+  ///
+  /// Note: this compares against the expected count rather than the scan 
path, because the geospatial transform
+  /// functions do not tolerate the null/empty geometry input on the scan path 
(they throw a BufferUnderflowException),
+  /// so the scan path cannot serve as an oracle for all-null rows here. The 
H3-index path never deserializes the
+  /// column values, so it is unaffected.
+  @Test
+  public void testH3IndexWithNullHandling()
+      throws Exception {
+    int numNonNullRecords = 0;
+    List<GenericRow> records = new ArrayList<>(NUM_RECORDS);
+    for (int i = 0; i < NUM_RECORDS; i++) {
+      if (i % 2 == 0) {
+        double longitude = -122.5 + RANDOM.nextDouble();
+        double latitude = 37 + RANDOM.nextDouble();
+        addRecord(records, longitude, latitude);
+        numNonNullRecords++;
+      } else {
+        addNullRecord(records);
+      }
+    }
+    setUp(records, NULL_ENABLED_TABLE_CONFIG, true);
+
+    // Match-all (no bound): every non-null row matches, null rows are 
excluded.
+    validateNullHandlingResult("SELECT COUNT(*) FROM testTable WHERE 
ST_Distance(%s, ST_Point(-122, 37.5, 1)) > -1",
+        H3_INDEX_COLUMN, numNonNullRecords);
+    // Lower bound only: distance is always positive for non-null rows, null 
rows are excluded.
+    validateNullHandlingResult("SELECT COUNT(*) FROM testTable WHERE 
ST_Distance(%s, ST_Point(-122, 37.5, 1)) > 0",
+        H3_INDEX_COLUMN, numNonNullRecords);
+    // Negative ST_Within against a polygon that contains none of the points: 
every non-null row satisfies "= 0",
+    // exercising the inclusion operator complement; null rows are excluded.
+    validateNullHandlingResult(
+        "SELECT COUNT(*) FROM testTable WHERE ST_Within(%s, 
ST_GeomFromText('POLYGON ((\n"
+            + "             122.0008564 -37.5004316, \n"
+            + "             121.9991291 -37.5005168, \n"
+            + "             121.9990325 -37.4995294, \n"
+            + "             122.0001268 -37.4993506,  \n"
+            + "             122.0008564 -37.5004316))')) = 0",
+        H3_INDEX_GEOMETRY_COLUMN, numNonNullRecords);
+  }
+
+  private void validateNullHandlingResult(String queryTemplate, String 
h3Column, long expectedCount) {
+    String h3IndexQuery = "SET enableNullHandling=true;\n" + 
String.format(queryTemplate, h3Column);
+    AggregationOperator h3IndexOperator = getOperator(h3IndexQuery);
+    long h3IndexCount = (long) h3IndexOperator.nextBlock().getResults().get(0);
+    // The H3-index path must exclude the null rows (which have no posting) 
from the match-all / complement result.
+    Assert.assertEquals(h3IndexCount, expectedCount);
+  }
+
   @Test
   public void stContainPointVeryCloseToBorderTest()
       throws Exception {
diff --git 
a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/index/loader/invertedindex/H3IndexHandler.java
 
b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/index/loader/invertedindex/H3IndexHandler.java
index b0fae087d5f..e7d03c288a5 100644
--- 
a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/index/loader/invertedindex/H3IndexHandler.java
+++ 
b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/index/loader/invertedindex/H3IndexHandler.java
@@ -46,6 +46,7 @@ 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.locationtech.jts.geom.Geometry;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -212,12 +213,23 @@ public class H3IndexHandler extends BaseIndexHandler {
             .createIndexReader(segmentWriter, colIndexConf, columnMetadata);
         GeoSpatialIndexCreator h3IndexCreator = 
StandardIndexes.h3().createIndexCreator(context, config)) {
       int numDocs = columnMetadata.getTotalDocs();
-      for (int i = 0; i < numDocs; i++) {
-        int dictId = forwardIndexReader.getDictId(i, readerContext);
-        // Route through add(value, dictId) so that empty/default geometry 
values (e.g. old segments reloaded after
-        // the geo column was added, which have no source data to build a 
Point from) are tolerated the same way the
-        // segment-creation path tolerates them, instead of failing the whole 
reload with a BufferUnderflowException.
-        h3IndexCreator.add(dictionary.getBytesValue(dictId), dictId);
+      // Old segments reloaded after a geo column was added hold a single 
empty default value (cardinality 1). Decode
+      // that one value once and reuse it for every doc, so the empty default 
is decoded a single time rather than per
+      // doc. Decoding through the creator's toGeometry() fast-paths the empty 
default value to null instead of failing
+      // the whole reload with a BufferUnderflowException, tolerating it the 
same way the segment-creation path does.
+      // For higher-cardinality columns, decode per doc instead of retaining 
one Geometry per dict id (which for a
+      // high-cardinality geo column would be roughly one per doc and risk a 
large heap during reload); the per-doc
+      // path still goes through toGeometry(), so the empty default value 
never throws.
+      if (dictionary.length() == 1) {
+        Geometry geometry = 
h3IndexCreator.toGeometry(dictionary.getBytesValue(0));
+        for (int i = 0; i < numDocs; i++) {
+          h3IndexCreator.add(geometry);
+        }
+      } else {
+        for (int i = 0; i < numDocs; i++) {
+          int dictId = forwardIndexReader.getDictId(i, readerContext);
+          
h3IndexCreator.add(h3IndexCreator.toGeometry(dictionary.getBytesValue(dictId)));
+        }
       }
       h3IndexCreator.seal();
     }
@@ -236,8 +248,10 @@ public class H3IndexHandler extends BaseIndexHandler {
         GeoSpatialIndexCreator h3IndexCreator = 
StandardIndexes.h3().createIndexCreator(context, config)) {
       int numDocs = columnMetadata.getTotalDocs();
       for (int i = 0; i < numDocs; i++) {
-        // See handleDictionaryBasedColumn: add(value, dictId) tolerates 
empty/default geometry values on reload.
-        h3IndexCreator.add(forwardIndexReader.getBytes(i, readerContext), -1);
+        // See handleDictionaryBasedColumn: toGeometry() fast-paths 
empty/default geometry values to null (no
+        // per-row exception) and add(Geometry) tolerates them on reload the 
same way segment creation does. The raw
+        // path has no dictionary, so values cannot be cached across docs.
+        
h3IndexCreator.add(h3IndexCreator.toGeometry(forwardIndexReader.getBytes(i, 
readerContext)));
       }
       h3IndexCreator.seal();
     }
diff --git 
a/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/segment/index/loader/SegmentPreProcessorTest.java
 
b/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/segment/index/loader/SegmentPreProcessorTest.java
index 3db15e33406..05234c1f2a8 100644
--- 
a/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/segment/index/loader/SegmentPreProcessorTest.java
+++ 
b/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/segment/index/loader/SegmentPreProcessorTest.java
@@ -343,6 +343,19 @@ public class SegmentPreProcessorTest implements 
PinotBuffersAfterClassCheckRule
     return new SegmentVersion[][]{{SegmentVersion.v1}, {SegmentVersion.v3}};
   }
 
+  /// Cartesian product of segment version (v1/v3) and geo-column encoding 
(DICTIONARY/RAW), so the H3 reload test
+  /// exercises both 
[org.apache.pinot.segment.local.segment.index.loader.invertedindex.H3IndexHandler]
+  /// paths: the dictionary path and the raw 
`forwardIndexReader.getBytes(...)` path.
+  @DataProvider(name = "h3VersionAndEncoding")
+  public Object[][] h3VersionAndEncoding() {
+    return new Object[][]{
+        {SegmentVersion.v1, FieldConfig.EncodingType.DICTIONARY},
+        {SegmentVersion.v3, FieldConfig.EncodingType.DICTIONARY},
+        {SegmentVersion.v1, FieldConfig.EncodingType.RAW},
+        {SegmentVersion.v3, FieldConfig.EncodingType.RAW}
+    };
+  }
+
   /// Test to check for default column handling and text index creation during
   /// segment load after a new raw column is added to the schema with text 
index
   /// creation enabled.
@@ -1663,36 +1676,56 @@ public class SegmentPreProcessorTest implements 
PinotBuffersAfterClassCheckRule
     assertEquals(singleFileIndex.length(), initFileSize);
   }
 
-  /// Regression test for the H3 index builder crashing a segment on 
empty/default geometry values.
+  /// Regression test for the H3 index builder crashing a segment on 
empty/default geometry values,
+  /// covering both the dictionary and the raw reload paths across v1/v3 
segments.
   ///
   /// When a geo column is added to the schema after a segment was built, old 
segments have no source
   /// data to derive it from, so the derived BYTES column is filled with its 
default null value -- the
   /// empty byte array. Building an H3 index over those rows used to call
   /// 
[org.apache.pinot.segment.local.utils.GeometrySerializer#deserialize(byte\[\])] 
directly on the
   /// empty bytes, throwing a `BufferUnderflowException` that propagated out 
of the reload and parked
-  /// the segment in an ERROR state. The handler now routes through the 
creator's tolerant add path,
-  /// which skips undeserializable/default values when `continueOnError` is 
enabled (set by
-  /// [#resetIndexConfigs()]), exactly like the segment-creation path.
-  @Test(dataProvider = "bothV1AndV3")
-  public void testH3IndexCreationOnEmptyDefaultValue(SegmentVersion 
segmentVersion)
+  /// the segment in an ERROR state. The handler now routes through the 
creator's tolerant path, which
+  /// fast-paths the empty default value and skips undeserializable values 
when `continueOnError` is
+  /// enabled (set by [#resetIndexConfigs()]), exactly like the 
segment-creation path.
+  ///
+  /// The `DICTIONARY` case is the empty-default column itself (auto-generated 
columns are always
+  /// dictionary-encoded, so their `cardinality == 1` value cannot be stored 
raw). The `RAW` case
+  /// derives a non-dictionary BYTES column holding values that are not 
decodable as geometry, so the
+  /// raw `forwardIndexReader.getBytes(...)` path is exercised and its values 
are tolerated (skipped)
+  /// the same way empty defaults are.
+  @Test(dataProvider = "h3VersionAndEncoding")
+  public void testH3IndexCreationOnEmptyDefaultValue(SegmentVersion 
segmentVersion,
+      FieldConfig.EncodingType encodingType)
       throws Exception {
     buildSegment(segmentVersion);
 
-    // Add newH3Col as a derived column whose default null value is the empty 
byte array (no explicit
-    // defaultNullValue in the schema), mirroring old segments reloaded after 
a geo column was added.
+    boolean rawEncoding = encodingType == FieldConfig.EncodingType.RAW;
+    if (rawEncoding) {
+      // Auto-generated empty-default columns are always dictionary-encoded, 
so to exercise the raw reload path
+      // derive newH3Col from an existing column as raw BYTES. The values are 
not valid serialized geometry, so the
+      // handler must skip them (like empty defaults) rather than fail.
+      _noDictionaryColumns.add("newH3Col");
+      _ingestionConfig.setTransformConfigs(List.of(new 
TransformConfig("newH3Col", "toUtf8(column3)")));
+    }
+
+    // Add newH3Col. For DICTIONARY it is a pure default column whose default 
null value is the empty byte array (no
+    // explicit defaultNullValue in the schema), mirroring old segments 
reloaded after a geo column was added.
     runPreProcessor(_newColumnsSchemaWithH3EmptyDefault);
     SegmentMetadataImpl segmentMetadata = new SegmentMetadataImpl(INDEX_DIR);
-    assertNotNull(segmentMetadata.getColumnMetadataFor("newH3Col"));
+    ColumnMetadata newH3ColMetadata = 
segmentMetadata.getColumnMetadataFor("newH3Col");
+    assertNotNull(newH3ColMetadata);
+    assertEquals(newH3ColMetadata.hasDictionary(), !rawEncoding);
 
-    // Build the H3 index over the empty/default values. This must not throw 
and must produce the index.
+    // Build the H3 index over the values. This must not throw and must 
produce the index.
     _fieldConfigMap.put("newH3Col",
-        new FieldConfig("newH3Col", FieldConfig.EncodingType.DICTIONARY, 
List.of(FieldConfig.IndexType.H3), null,
+        new FieldConfig("newH3Col", encodingType, 
List.of(FieldConfig.IndexType.H3), null,
             Map.of("resolutions", "5")));
     runPreProcessor(_newColumnsSchemaWithH3EmptyDefault);
 
     try (SegmentDirectory segmentDirectory = new 
SegmentLocalFSDirectory(INDEX_DIR, ReadMode.mmap);
         SegmentDirectory.Reader reader = segmentDirectory.createReader()) {
       assertTrue(reader.hasIndexFor("newH3Col", StandardIndexes.h3()));
+      assertEquals(reader.hasIndexFor("newH3Col", 
StandardIndexes.dictionary()), !rawEncoding);
     }
   }
 
diff --git 
a/pinot-segment-spi/src/main/java/org/apache/pinot/segment/spi/index/creator/GeoSpatialIndexCreator.java
 
b/pinot-segment-spi/src/main/java/org/apache/pinot/segment/spi/index/creator/GeoSpatialIndexCreator.java
index 87e4795e26f..d573f173145 100644
--- 
a/pinot-segment-spi/src/main/java/org/apache/pinot/segment/spi/index/creator/GeoSpatialIndexCreator.java
+++ 
b/pinot-segment-spi/src/main/java/org/apache/pinot/segment/spi/index/creator/GeoSpatialIndexCreator.java
@@ -32,14 +32,28 @@ public interface GeoSpatialIndexCreator extends 
IndexCreator {
   @Override
   default void add(Object value, int dictId)
       throws IOException {
-    Geometry geometry;
+    add(toGeometry((byte[]) value));
+  }
+
+  /// Converts a serialized geometry value into a [Geometry], returning `null` 
for values that cannot be decoded into
+  /// a geometry.
+  ///
+  /// The empty byte array is fast-pathed to `null` without attempting 
deserialization: it is the BYTES default-null
+  /// value assigned to a derived geo column when it is added to the schema of 
segments that predate its source data,
+  /// so on reload every such row would otherwise throw a 
`BufferUnderflowException`. Skipping the deserialization
+  /// avoids one exception (and its stack-trace capture) per row for these 
all-default segments. Any other undecodable
+  /// value is also mapped to `null`; [#add(Geometry)] then decides whether to 
skip or fail it based on whether
+  /// `continueOnError` is enabled.
+  @Nullable
+  default Geometry toGeometry(@Nullable byte[] value) {
+    if (value == null || value.length == 0) {
+      return null;
+    }
     try {
-      geometry = deserialize((byte[]) value);
+      return deserialize(value);
     } catch (Exception e) {
-      // Swallow the exception and treat the geometry as null
-      geometry = null;
+      return null;
     }
-    add(geometry);
   }
 
   @Override


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

Reply via email to