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 ab3fd2b214b Support backfilling null value vector from the default 
null value (#19072)
ab3fd2b214b is described below

commit ab3fd2b214bf393b1feeb3b8ad8e3e250e31881a
Author: Xiaotian (Jackie) Jiang <[email protected]>
AuthorDate: Sat Jul 25 23:23:57 2026 -0700

    Support backfilling null value vector from the default null value (#19072)
---
 .../segment/creator/impl/BaseSegmentCreator.java   |   8 +
 .../impl/nullvalue/NullValueVectorCreator.java     |  37 +-
 .../index/nullvalue/NullValueIndexType.java        |  93 ++--
 .../index/nullvalue/NullValueVectorHandler.java    | 372 +++++++++++++++
 .../local/utils/NullValueTransformerUtils.java     |  33 +-
 .../segment/local/utils/TableConfigUtils.java      |  30 ++
 .../index/nullvalue/NullValueIndexTypeTest.java    | 105 ++++-
 .../nullvalue/NullValueVectorHandlerTest.java      | 511 +++++++++++++++++++++
 .../segment/local/utils/TableConfigUtilsTest.java  |  65 +++
 .../apache/pinot/segment/spi/ColumnMetadata.java   |   9 +
 .../org/apache/pinot/segment/spi/V1Constants.java  |   6 +
 .../pinot/segment/spi/index/StandardIndexes.java   |   5 +-
 .../spi/index/metadata/ColumnMetadataImpl.java     |  31 +-
 .../spi/config/table/NullValueVectorConfig.java    |  66 +++
 14 files changed, 1300 insertions(+), 71 deletions(-)

diff --git 
a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/creator/impl/BaseSegmentCreator.java
 
b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/creator/impl/BaseSegmentCreator.java
index c5b1b5c2b71..d79f9c2c738 100644
--- 
a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/creator/impl/BaseSegmentCreator.java
+++ 
b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/creator/impl/BaseSegmentCreator.java
@@ -559,6 +559,14 @@ public abstract class BaseSegmentCreator implements 
SegmentCreator {
           
columnIndexCreators.getIndexConfigs().getConfig(StandardIndexes.forward());
       addColumnMetadataInfo(properties, column, columnStatistics, _totalDocs, 
_schema.getFieldSpecFor(column),
           hasDictionary, dictionaryElementSize, fwdConfig.getEncodingType(), 
false);
+      // When null handling is enabled for a column but it has no null values, 
NullValueVectorCreator.seal() writes no
+      // bitmap file. Record a metadata flag for that case so such a column is 
distinguishable from one that never had
+      // null handling (both lack a bitmap file), which is what the 
reload-time backfill relies on. Columns that do
+      // have null values are identified by the bitmap file itself and need no 
flag.
+      NullValueVectorCreator nullValueVectorCreator = 
columnIndexCreators.getNullValueVectorCreator();
+      if (nullValueVectorCreator != null && 
nullValueVectorCreator.isNonNull()) {
+        properties.setProperty(getKeyFor(column, IS_NON_NULL), 
String.valueOf(true));
+      }
     }
 
     if (_config.isCompressionStatsEnabled()) {
diff --git 
a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/creator/impl/nullvalue/NullValueVectorCreator.java
 
b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/creator/impl/nullvalue/NullValueVectorCreator.java
index ee5bb527238..1db2b2d8a55 100644
--- 
a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/creator/impl/nullvalue/NullValueVectorCreator.java
+++ 
b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/creator/impl/nullvalue/NullValueVectorCreator.java
@@ -43,6 +43,9 @@ import org.roaringbitmap.RoaringBitmapWriter;
 public class NullValueVectorCreator implements IndexCreator {
   private final RoaringBitmapWriter<RoaringBitmap> _bitmapWriter;
   private final File _nullValueVectorFile;
+  private boolean _hasNulls;
+  // Materialized from the writer on first access; see getNullBitmap() for the 
contract
+  private RoaringBitmap _nullBitmap;
 
   @Override
   public void add(Object value, int dictId)
@@ -62,23 +65,47 @@ public class NullValueVectorCreator implements IndexCreator 
{
   }
 
   public void setNull(int docId) {
+    // Enforces the contract documented on getNullBitmap(). Kept as an assert 
so the check is free in production while
+    // still catching a misordered caller in tests, where assertions are 
enabled.
+    assert _nullBitmap == null : "setNull() called after the null bitmap was 
materialized";
     _bitmapWriter.add(docId);
+    _hasNulls = true;
+  }
+
+  /// Returns `true` when no doc has been marked null, i.e. [#seal] writes no 
bitmap file.
+  public boolean isNonNull() {
+    return !_hasNulls;
+  }
+
+  /// Returns the number of docs marked null. Subject to the same contract as 
[#getNullBitmap].
+  public int getNumNulls() {
+    return _hasNulls ? getNullBitmap().getCardinality() : 0;
   }
 
   public void seal()
       throws IOException {
-    // Create null value vector file only if the bitmap is not empty
-    RoaringBitmap nullBitmap = _bitmapWriter.get();
-    if (!nullBitmap.isEmpty()) {
+    // Create null value vector file only if at least one doc was marked null
+    if (_hasNulls) {
       try (DataOutputStream outputStream = new DataOutputStream(new 
FileOutputStream(_nullValueVectorFile))) {
-        nullBitmap.serialize(outputStream);
+        getNullBitmap().serialize(outputStream);
       }
     }
   }
 
+  /// Returns the bitmap of null doc ids.
+  ///
+  /// Must be called only once every [#setNull] call has been made: 
materializing the bitmap flushes the writer, and the
+  /// result is cached here so that repeated calls (e.g. [#getNumNulls] 
followed by [#seal]) flush at most once. Doc ids
+  /// marked after the first call are therefore not guaranteed to be reflected.
+  ///
+  /// No explicit `runOptimize` is needed: the writer run-length encodes each 
container as it is appended
+  /// (`runCompress` defaults to `true`), which is what keeps a clustered or 
all-null vector compact.
   @VisibleForTesting
   RoaringBitmap getNullBitmap() {
-    return _bitmapWriter.get();
+    if (_nullBitmap == null) {
+      _nullBitmap = _bitmapWriter.get();
+    }
+    return _nullBitmap;
   }
 
   @Override
diff --git 
a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/index/nullvalue/NullValueIndexType.java
 
b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/index/nullvalue/NullValueIndexType.java
index 9bafad83bb9..6d7b14d37e7 100644
--- 
a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/index/nullvalue/NullValueIndexType.java
+++ 
b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/index/nullvalue/NullValueIndexType.java
@@ -19,6 +19,7 @@
 
 package org.apache.pinot.segment.local.segment.index.nullvalue;
 
+import com.google.common.base.Preconditions;
 import com.google.common.collect.Maps;
 import java.io.File;
 import java.io.IOException;
@@ -34,6 +35,7 @@ import 
org.apache.pinot.segment.spi.creator.IndexCreationContext;
 import org.apache.pinot.segment.spi.index.AbstractIndexType;
 import org.apache.pinot.segment.spi.index.ColumnConfigDeserializer;
 import org.apache.pinot.segment.spi.index.FieldIndexConfigs;
+import org.apache.pinot.segment.spi.index.IndexConfigDeserializer;
 import org.apache.pinot.segment.spi.index.IndexHandler;
 import org.apache.pinot.segment.spi.index.IndexReaderFactory;
 import org.apache.pinot.segment.spi.index.IndexType;
@@ -41,35 +43,37 @@ import org.apache.pinot.segment.spi.index.StandardIndexes;
 import org.apache.pinot.segment.spi.index.reader.NullValueVectorReader;
 import org.apache.pinot.segment.spi.memory.PinotDataBuffer;
 import org.apache.pinot.segment.spi.store.SegmentDirectory;
-import org.apache.pinot.spi.config.table.IndexConfig;
+import org.apache.pinot.spi.config.table.NullValueVectorConfig;
 import org.apache.pinot.spi.config.table.TableConfig;
 import org.apache.pinot.spi.data.FieldSpec;
+import org.apache.pinot.spi.data.FieldSpec.DataType;
 import org.apache.pinot.spi.data.Schema;
 
 
-public class NullValueIndexType extends AbstractIndexType<IndexConfig, 
NullValueVectorReader, NullValueVectorCreator> {
+public class NullValueIndexType
+    extends AbstractIndexType<NullValueVectorConfig, NullValueVectorReader, 
NullValueVectorCreator> {
   public static final String INDEX_DISPLAY_NAME = "null";
-  private static final List<String> EXTENSIONS =
-      List.of(V1Constants.Indexes.NULLVALUE_VECTOR_FILE_EXTENSION);
+  private static final NullValueVectorConfig DEFAULT_CONFIG = new 
NullValueVectorConfig(false, false);
+  private static final List<String> EXTENSIONS = 
List.of(V1Constants.Indexes.NULLVALUE_VECTOR_FILE_EXTENSION);
 
   protected NullValueIndexType() {
     super(StandardIndexes.NULL_VALUE_VECTOR_ID);
   }
 
   @Override
-  public Class<IndexConfig> getIndexConfigClass() {
-    return IndexConfig.class;
+  public Class<NullValueVectorConfig> getIndexConfigClass() {
+    return NullValueVectorConfig.class;
   }
 
   @Override
-  public NullValueVectorCreator createIndexCreator(IndexCreationContext 
context, IndexConfig indexConfig)
+  public NullValueVectorCreator createIndexCreator(IndexCreationContext 
context, NullValueVectorConfig indexConfig)
       throws Exception {
     return new NullValueVectorCreator(context.getIndexDir(), 
context.getFieldSpec().getName());
   }
 
   @Override
-  public IndexConfig getDefaultConfig() {
-    return IndexConfig.ENABLED;
+  public NullValueVectorConfig getDefaultConfig() {
+    return DEFAULT_CONFIG;
   }
 
   @Override
@@ -77,25 +81,27 @@ public class NullValueIndexType extends 
AbstractIndexType<IndexConfig, NullValue
     return INDEX_DISPLAY_NAME;
   }
 
+  /// Resolves the per-column null value vector config. Unlike a normal index, 
the `enabled` state is not set directly
+  /// by the user but derived from null handling (column-based 
[FieldSpec#isNullable] when the schema opts into
+  /// column-based null handling, otherwise the table-level 
`nullHandlingEnabled` flag). The user-facing part is the
+  /// `backfill` flag, read from the column's `indexes` config. The two are 
merged here rather than treated as
+  /// exclusive alternatives (which is what the default 
[#createDeserializerForLegacyConfigs] composition would do).
   @Override
-  public ColumnConfigDeserializer<IndexConfig> 
createDeserializerForLegacyConfigs() {
+  protected ColumnConfigDeserializer<NullValueVectorConfig> 
createDeserializer() {
+    ColumnConfigDeserializer<NullValueVectorConfig> fromIndexes =
+        IndexConfigDeserializer.fromIndexes(getPrettyName(), 
getIndexConfigClass());
     return (TableConfig tableConfig, Schema schema) -> {
+      Map<String, NullValueVectorConfig> fromIndexesMap = 
fromIndexes.deserialize(tableConfig, schema);
       Collection<FieldSpec> allFieldSpecs = schema.getAllFieldSpecs();
-      Map<String, IndexConfig> configMap = 
Maps.newHashMapWithExpectedSize(allFieldSpecs.size());
-
+      Map<String, NullValueVectorConfig> configMap = 
Maps.newHashMapWithExpectedSize(allFieldSpecs.size());
       boolean columnBasedNullHandlingEnabled = 
schema.isEnableColumnBasedNullHandling();
       boolean nullHandlingEnabled = 
tableConfig.getIndexingConfig().isNullHandlingEnabled();
-
       for (FieldSpec fieldSpec : allFieldSpecs) {
-        IndexConfig indexConfig;
-        boolean enabled;
-        if (columnBasedNullHandlingEnabled) {
-          enabled = fieldSpec.isNullable();
-        } else {
-          enabled = nullHandlingEnabled;
-        }
-        indexConfig = enabled ? IndexConfig.ENABLED : IndexConfig.DISABLED;
-        configMap.put(fieldSpec.getName(), indexConfig);
+        String column = fieldSpec.getName();
+        boolean enabled = columnBasedNullHandlingEnabled ? 
fieldSpec.isNullable() : nullHandlingEnabled;
+        NullValueVectorConfig fromIndex = fromIndexesMap.get(column);
+        boolean backfill = fromIndex != null && fromIndex.isBackfill();
+        configMap.put(column, new NullValueVectorConfig(!enabled, backfill));
       }
       return configMap;
     };
@@ -110,20 +116,53 @@ public class NullValueIndexType extends 
AbstractIndexType<IndexConfig, NullValue
     return ReaderFactory.INSTANCE;
   }
 
+  @Override
+  public void validate(FieldIndexConfigs indexConfigs, FieldSpec fieldSpec, 
TableConfig tableConfig) {
+    if (indexConfigs.getConfig(this).isBackfill()) {
+      // Backfill reconstructs nulls by comparing each stored value against 
the column's default null value, which is
+      // only meaningful for scalar stored types. MAP (and other complex 
types) are not supported because:
+      //   - the default null value for a MAP is an empty map — an ordinary 
value rather than a rare sentinel — so
+      //     treating every empty map as null would be far too lossy to be 
safe; and
+      //   - an OPEN_STRUCT-backed MAP is materialized into child columns with 
no single scannable parent forward
+      //     index, so there is nothing coherent to scan for the parent column.
+      // TODO: Revisit MAP/complex backfill if complex-type null handling 
matures and a safe (non-occurring) sentinel
+      //   default null value becomes available.
+      DataType storedType = fieldSpec.getDataType().getStoredType();
+      Preconditions.checkState(isBackfillSupported(storedType),
+          "Null value vector backfill is not supported for column: %s of type: 
%s", fieldSpec.getName(),
+          fieldSpec.getDataType());
+    }
+  }
+
+  private static boolean isBackfillSupported(DataType storedType) {
+    switch (storedType) {
+      case INT:
+      case LONG:
+      case FLOAT:
+      case DOUBLE:
+      case BIG_DECIMAL:
+      case STRING:
+      case BYTES:
+        return true;
+      default:
+        return false;
+    }
+  }
+
   @Override
   public IndexHandler createIndexHandler(SegmentDirectory segmentDirectory, 
Map<String, FieldIndexConfigs> configsByCol,
       Schema schema, TableConfig tableConfig) {
-    return IndexHandler.NoOp.INSTANCE;
+    return new NullValueVectorHandler(segmentDirectory, configsByCol, 
tableConfig, schema);
   }
 
   @Override
-  public boolean requiresDictionary(FieldSpec fieldSpec, IndexConfig 
indexConfig) {
+  public boolean requiresDictionary(FieldSpec fieldSpec, NullValueVectorConfig 
indexConfig) {
     // The null value vector is a bitmap of doc IDs whose value is null; no 
dictionary involvement.
     return false;
   }
 
   @Override
-  public boolean shouldInvalidateOnDictionaryChange(FieldSpec fieldSpec, 
IndexConfig indexConfig) {
+  public boolean shouldInvalidateOnDictionaryChange(FieldSpec fieldSpec, 
NullValueVectorConfig indexConfig) {
     // The null value vector is keyed by doc ID and independent of the 
column's value representation.
     return false;
   }
@@ -144,8 +183,8 @@ public class NullValueIndexType extends 
AbstractIndexType<IndexConfig, NullValue
     @Override
     public NullValueVectorReader createIndexReader(SegmentDirectory.Reader 
segmentReader,
         FieldIndexConfigs fieldIndexConfigs, ColumnMetadata metadata)
-          throws IOException {
-      IndexType<IndexConfig, NullValueVectorReader, ?> indexType = 
StandardIndexes.nullValueVector();
+        throws IOException {
+      IndexType<NullValueVectorConfig, NullValueVectorReader, ?> indexType = 
StandardIndexes.nullValueVector();
       if (fieldIndexConfigs.getConfig(indexType).isDisabled()) {
         return null;
       }
diff --git 
a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/index/nullvalue/NullValueVectorHandler.java
 
b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/index/nullvalue/NullValueVectorHandler.java
new file mode 100644
index 00000000000..94a382b09af
--- /dev/null
+++ 
b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/index/nullvalue/NullValueVectorHandler.java
@@ -0,0 +1,372 @@
+/**
+ * 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.nullvalue;
+
+import java.io.File;
+import java.math.BigDecimal;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import org.apache.commons.io.FileUtils;
+import 
org.apache.pinot.segment.local.segment.creator.impl.nullvalue.NullValueVectorCreator;
+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.LoaderUtils;
+import org.apache.pinot.segment.local.segment.readers.PinotSegmentColumnReader;
+import org.apache.pinot.segment.spi.ColumnMetadata;
+import org.apache.pinot.segment.spi.V1Constants;
+import org.apache.pinot.segment.spi.creator.SegmentVersion;
+import org.apache.pinot.segment.spi.index.FieldIndexConfigs;
+import org.apache.pinot.segment.spi.index.IndexReaderFactory;
+import org.apache.pinot.segment.spi.index.StandardIndexes;
+import org.apache.pinot.segment.spi.index.reader.Dictionary;
+import org.apache.pinot.segment.spi.index.reader.ForwardIndexReader;
+import org.apache.pinot.segment.spi.store.SegmentDirectory;
+import org.apache.pinot.segment.spi.utils.SegmentMetadataUtils;
+import org.apache.pinot.spi.config.table.NullValueVectorConfig;
+import org.apache.pinot.spi.config.table.TableConfig;
+import org.apache.pinot.spi.data.Schema;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import static 
org.apache.pinot.segment.spi.V1Constants.MetadataKeys.Column.IS_NON_NULL;
+import static 
org.apache.pinot.segment.spi.V1Constants.MetadataKeys.Column.getKeyFor;
+
+
+/// Backfills a null value vector for opted-in columns that don't already have 
one, by treating every value equal to the
+/// column's default null value as null.
+///
+/// Backfill is per-column opt-in via the null value vector index config 
([NullValueVectorConfig#isBackfill], set under
+/// the column's `indexes` config), because it is a lossy reconstruction: a 
genuine value equal to the default null
+/// value is also marked null. It should be enabled only for columns whose 
default null value is a sentinel that does
+/// not occur in the data (e.g. dimension `MIN_VALUE`), not for columns whose 
default legitimately occurs in the
+/// data — e.g. metrics (`0`), `BOOLEAN` (`false`), or `TIMESTAMP` (epoch 
`0`). Among opted-in columns, the handler
+/// targets those that:
+/// - have null handling enabled (this is the 
[NullValueVectorConfig#isEnabled] state, derived from column-based
+///   [org.apache.pinot.spi.data.FieldSpec#isNullable] or the table-level 
`nullHandlingEnabled` flag);
+/// - have not already been null-handled — i.e. neither the 
[ColumnMetadata#isNonNull] metadata flag is
+///   set nor a null value vector file exists (segments ingested before null 
handling was turned on);
+/// - have a forward index to scan and a supported scalar stored type.
+///
+/// The backfill mirrors how ingestion records nulls: a null single-value 
entry is stored as the default null value, and
+/// a null multi-value entry is stored as a single-element array holding the 
default null value. A doc is therefore
+/// marked null when its stored single value equals the default null value, or 
when its multi-value entry is a
+/// single-element array whose sole element equals the default null value.
+///
+/// A bitmap file is written only when the column has at least one null, 
exactly like segment creation. The no-null
+/// case is recorded solely via the 
[V1Constants.MetadataKeys.Column#IS_NON_NULL] metadata flag, so the
+/// column is not perpetually re-eligible for backfill and behaves 
symmetrically with segment creation.
+///
+/// This is a lossy reconstruction: a genuine value that happens to equal the 
default null value is also marked null.
+/// That trade-off is accepted by opting into the feature.
+@SuppressWarnings({"rawtypes"})
+public class NullValueVectorHandler extends BaseIndexHandler {
+  private static final Logger LOGGER = 
LoggerFactory.getLogger(NullValueVectorHandler.class);
+
+  private final Set<String> _backfillColumns;
+
+  public NullValueVectorHandler(SegmentDirectory segmentDirectory, Map<String, 
FieldIndexConfigs> fieldIndexConfigs,
+      TableConfig tableConfig, Schema schema) {
+    super(segmentDirectory, fieldIndexConfigs, tableConfig, schema);
+    // Collect the columns whose resolved null value vector config has both 
null handling enabled and backfill opted
+    // in. `enabled` is derived from null handling and `backfill` from the 
column's `indexes` config (see
+    // NullValueIndexType.createDeserializer).
+    _backfillColumns = new HashSet<>();
+    for (Map.Entry<String, FieldIndexConfigs> entry : 
_fieldIndexConfigs.entrySet()) {
+      NullValueVectorConfig config = 
entry.getValue().getConfig(StandardIndexes.nullValueVector());
+      if (config.isEnabled() && config.isBackfill()) {
+        _backfillColumns.add(entry.getKey());
+      }
+    }
+  }
+
+  @Override
+  public boolean needUpdateIndices(SegmentDirectory.Reader segmentReader) {
+    if (_backfillColumns.isEmpty()) {
+      return false;
+    }
+    return !getColumnsToBackfill(segmentReader).isEmpty();
+  }
+
+  @Override
+  public void updateIndices(SegmentDirectory.Writer segmentWriter)
+      throws Exception {
+    if (_backfillColumns.isEmpty()) {
+      return;
+    }
+    List<ColumnMetadata> columnsToBackfill = 
getColumnsToBackfill(segmentWriter);
+    if (columnsToBackfill.isEmpty()) {
+      return;
+    }
+    Map<String, String> metadataUpdates = new HashMap<>();
+    for (ColumnMetadata columnMetadata : columnsToBackfill) {
+      boolean isNonNull = backfillColumn(segmentWriter, columnMetadata);
+      if (isNonNull) {
+        // The column has no null values, so no bitmap file was written. 
Record the metadata flag so it is not
+        // re-scanned on subsequent reloads, keeping the behavior symmetric 
with segment creation (see
+        // BaseSegmentCreator). Columns that do have null values are 
identified by the bitmap file and need no flag.
+        metadataUpdates.put(getKeyFor(columnMetadata.getColumnName(), 
IS_NON_NULL), String.valueOf(true));
+      }
+    }
+    if (!metadataUpdates.isEmpty()) {
+      SegmentMetadataUtils.updateMetadataProperties(_segmentDirectory, 
metadataUpdates);
+    }
+  }
+
+  /// Returns the eligible columns among the opted-in set: the column has not 
already been null-handled (neither the
+  /// [ColumnMetadata#isNonNull] metadata flag is set nor a null value vector 
file exists) and has a forward index to
+  /// scan. Null handling being enabled and the stored type being 
backfill-supported are already guaranteed — the
+  /// former when building `_backfillColumns`, the latter by 
[NullValueIndexType#validate] at config time.
+  private List<ColumnMetadata> getColumnsToBackfill(SegmentDirectory.Reader 
segmentReader) {
+    List<ColumnMetadata> columns = new ArrayList<>();
+    for (String column : _backfillColumns) {
+      ColumnMetadata columnMetadata = 
_segmentDirectory.getSegmentMetadata().getColumnMetadataFor(column);
+      if (columnMetadata == null) {
+        continue;
+      }
+      if (columnMetadata.isNonNull() || segmentReader.hasIndexFor(column, 
StandardIndexes.nullValueVector())) {
+        // Null handling was already applied to this column: either the 
metadata flag is set (column-based signal,
+        // covers the no-null case), or a null value vector file exists 
(covers older segments without the flag).
+        continue;
+      }
+      if (!segmentReader.hasIndexFor(column, StandardIndexes.forward())) {
+        // No forward index to scan, so skip the column. Note this is not the 
same as "the forward index is disabled in
+        // the config": when a reload disables it, ForwardIndexHandler defers 
the deletion to postUpdateIndicesCleanup
+        // (after all handlers) so the index is still readable here and the 
column is backfilled normally. This branch
+        // covers a column whose forward index was already removed by an 
earlier reload.
+        continue;
+      }
+      columns.add(columnMetadata);
+    }
+    return columns;
+  }
+
+  /// Scans the column and writes its null value vector bitmap file when it 
has at least one null value. Returns `true`
+  /// when the column has no null values (bitmap skipped), in which case the 
caller records the metadata flag instead.
+  private boolean backfillColumn(SegmentDirectory.Writer segmentWriter, 
ColumnMetadata columnMetadata)
+      throws Exception {
+    String segmentName = _segmentDirectory.getSegmentMetadata().getName();
+    String columnName = columnMetadata.getColumnName();
+    File indexDir = _segmentDirectory.getSegmentMetadata().getIndexDir();
+    File inProgress = new File(indexDir, columnName + 
V1Constants.Indexes.NULLVALUE_VECTOR_FILE_EXTENSION
+        + ".inprogress");
+    File nullValueVectorFile = new File(indexDir, columnName + 
V1Constants.Indexes.NULLVALUE_VECTOR_FILE_EXTENSION);
+
+    if (!inProgress.exists()) {
+      // Marker file does not exist, which means last run ended normally.
+      FileUtils.touch(inProgress);
+    } else {
+      // Marker file exists, which means last run gets interrupted. Remove the 
leftover null value vector if any.
+      FileUtils.deleteQuietly(nullValueVectorFile);
+    }
+
+    LOGGER.info("Backfilling null value vector for segment: {}, column: {}", 
segmentName, columnName);
+    IndexReaderFactory<ForwardIndexReader> forwardReaderFactory = 
StandardIndexes.forward().getReaderFactory();
+    int numDocs = columnMetadata.getTotalDocs();
+    boolean isNonNull;
+    int numNulls;
+    // Reuse the segment-creation creator so the bitmap is written exactly as 
it is at creation time: skipped when
+    // empty, and run-length encoded by the writer.
+    try (ForwardIndexReader forwardIndexReader = 
forwardReaderFactory.createIndexReader(segmentWriter,
+        _fieldIndexConfigs.get(columnName), columnMetadata);
+        Dictionary dictionary =
+            columnMetadata.hasDictionary() ? 
DictionaryIndexType.read(segmentWriter, columnMetadata) : null;
+        PinotSegmentColumnReader columnReader = new 
PinotSegmentColumnReader(columnName, forwardIndexReader, dictionary,
+            null, columnMetadata.getMaxNumberOfMultiValues());
+        NullValueVectorCreator creator = new NullValueVectorCreator(indexDir, 
columnName)) {
+      Object defaultNullValue = 
columnMetadata.getFieldSpec().getDefaultNullValue();
+      if (columnMetadata.isSingleValue()) {
+        markNullSingleValue(columnReader, defaultNullValue, numDocs, creator);
+      } else {
+        markNullMultiValue(columnReader, defaultNullValue, numDocs, creator);
+      }
+      creator.seal();
+      isNonNull = creator.isNonNull();
+      numNulls = creator.getNumNulls();
+    }
+
+    // seal() writes the bitmap file only when the column has null values. The 
no-null case is recorded via the column
+    // metadata flag set in updateIndices instead, so there is no empty bitmap 
file to fold in.
+    if (!isNonNull && _segmentDirectory.getSegmentMetadata().getVersion() == 
SegmentVersion.v3) {
+      // For v3, fold the generated file into the single index file and remove 
it.
+      LoaderUtils.writeIndexToV3Format(segmentWriter, columnName, 
nullValueVectorFile,
+          StandardIndexes.nullValueVector());
+    }
+    LOGGER.info("Backfilled null value vector for segment: {}, column: {}, 
numNulls: {}", segmentName, columnName,
+        numNulls);
+
+    FileUtils.deleteQuietly(inProgress);
+    return isNonNull;
+  }
+
+  private static void markNullSingleValue(PinotSegmentColumnReader 
columnReader, Object defaultNullValue, int numDocs,
+      NullValueVectorCreator creator) {
+    switch (columnReader.getValueType()) {
+      case INT: {
+        int nullValue = (Integer) defaultNullValue;
+        for (int docId = 0; docId < numDocs; docId++) {
+          if (columnReader.getInt(docId) == nullValue) {
+            creator.setNull(docId);
+          }
+        }
+        break;
+      }
+      case LONG: {
+        long nullValue = (Long) defaultNullValue;
+        for (int docId = 0; docId < numDocs; docId++) {
+          if (columnReader.getLong(docId) == nullValue) {
+            creator.setNull(docId);
+          }
+        }
+        break;
+      }
+      case FLOAT: {
+        float nullValue = (Float) defaultNullValue;
+        for (int docId = 0; docId < numDocs; docId++) {
+          if (Float.compare(columnReader.getFloat(docId), nullValue) == 0) {
+            creator.setNull(docId);
+          }
+        }
+        break;
+      }
+      case DOUBLE: {
+        double nullValue = (Double) defaultNullValue;
+        for (int docId = 0; docId < numDocs; docId++) {
+          if (Double.compare(columnReader.getDouble(docId), nullValue) == 0) {
+            creator.setNull(docId);
+          }
+        }
+        break;
+      }
+      case BIG_DECIMAL: {
+        BigDecimal nullValue = (BigDecimal) defaultNullValue;
+        for (int docId = 0; docId < numDocs; docId++) {
+          if (columnReader.getBigDecimal(docId).compareTo(nullValue) == 0) {
+            creator.setNull(docId);
+          }
+        }
+        break;
+      }
+      case STRING: {
+        String nullValue = (String) defaultNullValue;
+        for (int docId = 0; docId < numDocs; docId++) {
+          if (nullValue.equals(columnReader.getString(docId))) {
+            creator.setNull(docId);
+          }
+        }
+        break;
+      }
+      case BYTES: {
+        byte[] nullValue = (byte[]) defaultNullValue;
+        for (int docId = 0; docId < numDocs; docId++) {
+          if (Arrays.equals(columnReader.getBytes(docId), nullValue)) {
+            creator.setNull(docId);
+          }
+        }
+        break;
+      }
+      default:
+        throw new IllegalStateException(
+            "Unsupported stored type for null value vector backfill: " + 
columnReader.getValueType());
+    }
+  }
+
+  private static void markNullMultiValue(PinotSegmentColumnReader 
columnReader, Object defaultNullValue, int numDocs,
+      NullValueVectorCreator creator) {
+    switch (columnReader.getValueType()) {
+      case INT: {
+        int nullValue = (Integer) defaultNullValue;
+        for (int docId = 0; docId < numDocs; docId++) {
+          int[] values = columnReader.getIntMV(docId);
+          if (values.length == 1 && values[0] == nullValue) {
+            creator.setNull(docId);
+          }
+        }
+        break;
+      }
+      case LONG: {
+        long nullValue = (Long) defaultNullValue;
+        for (int docId = 0; docId < numDocs; docId++) {
+          long[] values = columnReader.getLongMV(docId);
+          if (values.length == 1 && values[0] == nullValue) {
+            creator.setNull(docId);
+          }
+        }
+        break;
+      }
+      case FLOAT: {
+        float nullValue = (Float) defaultNullValue;
+        for (int docId = 0; docId < numDocs; docId++) {
+          float[] values = columnReader.getFloatMV(docId);
+          if (values.length == 1 && Float.compare(values[0], nullValue) == 0) {
+            creator.setNull(docId);
+          }
+        }
+        break;
+      }
+      case DOUBLE: {
+        double nullValue = (Double) defaultNullValue;
+        for (int docId = 0; docId < numDocs; docId++) {
+          double[] values = columnReader.getDoubleMV(docId);
+          if (values.length == 1 && Double.compare(values[0], nullValue) == 0) 
{
+            creator.setNull(docId);
+          }
+        }
+        break;
+      }
+      case BIG_DECIMAL: {
+        BigDecimal nullValue = (BigDecimal) defaultNullValue;
+        for (int docId = 0; docId < numDocs; docId++) {
+          BigDecimal[] values = columnReader.getBigDecimalMV(docId);
+          if (values.length == 1 && values[0].compareTo(nullValue) == 0) {
+            creator.setNull(docId);
+          }
+        }
+        break;
+      }
+      case STRING: {
+        String nullValue = (String) defaultNullValue;
+        for (int docId = 0; docId < numDocs; docId++) {
+          String[] values = columnReader.getStringMV(docId);
+          if (values.length == 1 && nullValue.equals(values[0])) {
+            creator.setNull(docId);
+          }
+        }
+        break;
+      }
+      case BYTES: {
+        byte[] nullValue = (byte[]) defaultNullValue;
+        for (int docId = 0; docId < numDocs; docId++) {
+          byte[][] values = columnReader.getBytesMV(docId);
+          if (values.length == 1 && Arrays.equals(values[0], nullValue)) {
+            creator.setNull(docId);
+          }
+        }
+        break;
+      }
+      default:
+        throw new IllegalStateException(
+            "Unsupported stored type for null value vector backfill: " + 
columnReader.getValueType());
+    }
+  }
+}
diff --git 
a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/utils/NullValueTransformerUtils.java
 
b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/utils/NullValueTransformerUtils.java
index 85d68f45a38..5acfff9c35b 100644
--- 
a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/utils/NullValueTransformerUtils.java
+++ 
b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/utils/NullValueTransformerUtils.java
@@ -87,28 +87,37 @@ public class NullValueTransformerUtils {
     Preconditions.checkState(timeColumnSpec != null, "Failed to find time 
field: %s from schema: %s", timeColumnName,
         schema.getSchemaName());
 
-    String defaultTimeString = timeColumnSpec.getDefaultNullValueString();
-    DateTimeFormatSpec dateTimeFormatSpec = timeColumnSpec.getFormatSpec();
-
-    // Try to use the default time from the field spec if it's valid
-    try {
-      long defaultTimeMs = 
dateTimeFormatSpec.fromFormatToMillis(defaultTimeString);
-      if (TimeUtils.timeValueInValidRange(defaultTimeMs)) {
-        return timeColumnSpec.getDefaultNullValue();
-      }
-    } catch (Exception e) {
-      // Ignore and fall through to use current time
+    // Use the default time from the field spec if it's valid
+    if (isDefaultTimeValueInValidRange(timeColumnSpec)) {
+      return timeColumnSpec.getDefaultNullValue();
     }
 
     // Use current time if default time is not valid
+    DateTimeFormatSpec dateTimeFormatSpec = timeColumnSpec.getFormatSpec();
     String currentTimeString = 
dateTimeFormatSpec.fromMillisToFormat(System.currentTimeMillis());
     Object currentTime = 
timeColumnSpec.getDataType().convert(currentTimeString);
     LOGGER.info(
         "Default time: {} does not comply with format: {}, using current time: 
{} as the default time for table: {}",
-        defaultTimeString, timeColumnSpec.getFormat(), currentTime, 
tableConfig.getTableName());
+        timeColumnSpec.getDefaultNullValueString(), 
timeColumnSpec.getFormat(), currentTime,
+        tableConfig.getTableName());
     return currentTime;
   }
 
+  /// Returns `true` when the time column's default null value is within the 
valid time range, i.e. ingestion stores it
+  /// as-is for null rows instead of substituting the current time.
+  ///
+  /// Callers that need to reason about what was actually written for a null 
time value must consult this: when it
+  /// returns `false`, the stored value is the ingestion-time current time, 
which does not match the default null value
+  /// recorded in the segment metadata.
+  public static boolean isDefaultTimeValueInValidRange(DateTimeFieldSpec 
timeColumnSpec) {
+    try {
+      return TimeUtils.timeValueInValidRange(
+          
timeColumnSpec.getFormatSpec().fromFormatToMillis(timeColumnSpec.getDefaultNullValueString()));
+    } catch (Exception e) {
+      return false;
+    }
+  }
+
   /**
    * Transforms a value by replacing null with the default null value.
    *
diff --git 
a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/utils/TableConfigUtils.java
 
b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/utils/TableConfigUtils.java
index 0c5fd6bf283..31fbf48dc6c 100644
--- 
a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/utils/TableConfigUtils.java
+++ 
b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/utils/TableConfigUtils.java
@@ -1692,6 +1692,10 @@ public final class TableConfigUtils {
       }
     }
 
+    // Null value vector backfill on the time column needs the whole table 
config (for the time column name), so it
+    // cannot be validated by NullValueIndexType.validate which only sees one 
field at a time.
+    validateNullValueVectorBackfillForTimeColumn(tableConfig, schema, 
indexConfigsMap);
+
     
validateMultiColumnTextIndex(indexingConfig.getMultiColumnTextIndexConfig());
 
     // OPEN_STRUCT materialized child columns use a reserved separator '$' in 
their name. When any
@@ -1758,6 +1762,32 @@ public final class TableConfigUtils {
     }
   }
 
+  /// Rejects a null value vector backfill opt-in on the time column when its 
default null value is outside the valid
+  /// time range.
+  ///
+  /// In that case ingestion substitutes the ingestion-time current time for 
null rows (see
+  /// [NullValueTransformerUtils#isDefaultTimeValueInValidRange]) while the 
segment metadata records the field spec's
+  /// default null value. A backfill scan compares against the recorded 
default, so it would never match: the column
+  /// would silently keep its nulls unmarked and be recorded as containing no 
nulls. A time column with an explicit
+  /// in-range default null value is stored as-is and is therefore allowed.
+  private static void validateNullValueVectorBackfillForTimeColumn(TableConfig 
tableConfig, Schema schema,
+      Map<String, FieldIndexConfigs> indexConfigsMap) {
+    String timeColumnName = 
tableConfig.getValidationConfig().getTimeColumnName();
+    if (StringUtils.isEmpty(timeColumnName)) {
+      return;
+    }
+    FieldIndexConfigs indexConfigs = indexConfigsMap.get(timeColumnName);
+    if (indexConfigs == null || 
!indexConfigs.getConfig(StandardIndexes.nullValueVector()).isBackfill()) {
+      return;
+    }
+    DateTimeFieldSpec timeColumnSpec = 
schema.getSpecForTimeColumn(timeColumnName);
+    Preconditions.checkState(timeColumnSpec != null, "Failed to find time 
column: %s in schema", timeColumnName);
+    
Preconditions.checkState(NullValueTransformerUtils.isDefaultTimeValueInValidRange(timeColumnSpec),
+        "Null value vector backfill is not supported for time column: %s with 
default null value: %s outside the valid "
+            + "time range, because ingestion stores the current time for null 
values instead of the default null value",
+        timeColumnName, timeColumnSpec.getDefaultNullValueString());
+  }
+
   private static void validateMultiColumnTextIndex(MultiColumnTextIndexConfig 
multiColTextIndex) {
     if (multiColTextIndex == null) {
       return;
diff --git 
a/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/segment/index/nullvalue/NullValueIndexTypeTest.java
 
b/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/segment/index/nullvalue/NullValueIndexTypeTest.java
index cb0df4d8164..c0ce48e6f2f 100644
--- 
a/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/segment/index/nullvalue/NullValueIndexTypeTest.java
+++ 
b/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/segment/index/nullvalue/NullValueIndexTypeTest.java
@@ -18,49 +18,122 @@
  */
 package org.apache.pinot.segment.local.segment.index.nullvalue;
 
+import java.util.List;
+import java.util.Map;
 import org.apache.pinot.segment.local.segment.index.AbstractSerdeIndexContract;
+import org.apache.pinot.segment.spi.index.FieldIndexConfigs;
+import org.apache.pinot.segment.spi.index.FieldIndexConfigsUtil;
 import org.apache.pinot.segment.spi.index.StandardIndexes;
-import org.apache.pinot.spi.config.table.IndexConfig;
+import org.apache.pinot.spi.config.table.FieldConfig;
+import org.apache.pinot.spi.config.table.NullValueVectorConfig;
+import org.apache.pinot.spi.config.table.TableConfig;
+import org.apache.pinot.spi.config.table.TableType;
+import org.apache.pinot.spi.data.ComplexFieldSpec;
+import org.apache.pinot.spi.data.DimensionFieldSpec;
 import org.apache.pinot.spi.data.FieldSpec;
-import org.testng.Assert;
+import org.apache.pinot.spi.data.FieldSpec.DataType;
+import org.apache.pinot.spi.data.Schema;
+import org.apache.pinot.spi.utils.JsonUtils;
+import org.apache.pinot.spi.utils.builder.TableConfigBuilder;
 import org.testng.annotations.DataProvider;
 import org.testng.annotations.Test;
 
+import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertFalse;
+import static org.testng.Assert.assertThrows;
+import static org.testng.Assert.assertTrue;
+
 
 public class NullValueIndexTypeTest {
+  private static final NullValueVectorConfig DISABLED = new 
NullValueVectorConfig(true, false);
+  private static final NullValueVectorConfig ENABLED = new 
NullValueVectorConfig(false, false);
 
   @DataProvider(name = "provideCases")
   public Object[][] provideCases() {
     return new Object[][]{
         // This is the semantic table, assuming a null bitmap buffer exists in 
the segment
         // enableColumnBasedNullHandling | table nullable | column nullable | 
expected index config
-        new Object[]{false, false, false, IndexConfig.DISABLED}, new Object[]{
-          false, false, true, IndexConfig.DISABLED
-        }, new Object[]{false, true, false, IndexConfig.ENABLED}, new Object[]{
-          false, true, true, IndexConfig.ENABLED
-        },
-
-        new Object[]{true, false, false, IndexConfig.DISABLED}, new 
Object[]{true, false, true, IndexConfig.ENABLED},
-        new Object[]{true, true, false, IndexConfig.DISABLED}, new 
Object[]{true, true, true, IndexConfig.ENABLED}
+        new Object[]{false, false, false, DISABLED},
+        new Object[]{false, false, true, DISABLED},
+        new Object[]{false, true, false, ENABLED},
+        new Object[]{false, true, true, ENABLED},
+        new Object[]{true, false, false, DISABLED},
+        new Object[]{true, false, true, ENABLED},
+        new Object[]{true, true, false, DISABLED},
+        new Object[]{true, true, true, ENABLED}
     };
   }
 
-  public static class ConfTest extends AbstractSerdeIndexContract {
+  @Test
+  public void testConfigSerde()
+      throws Exception {
+    // The config itself round-trips (it is persisted as part of the table 
config).
+    NullValueVectorConfig config = new NullValueVectorConfig(false, true);
+    NullValueVectorConfig deserialized =
+        JsonUtils.stringToObject(JsonUtils.objectToString(config), 
NullValueVectorConfig.class);
+    assertEquals(deserialized, config);
+    assertTrue(deserialized.isBackfill());
+    assertTrue(deserialized.isEnabled());
 
-    protected void assertEquals(IndexConfig expected) {
-      Assert.assertEquals(getActualConfig("dimStr", 
StandardIndexes.nullValueVector()), expected);
-    }
+    // The backfill opt-in survives a full table config round-trip and still 
resolves per column.
+    Schema schema = new Schema.SchemaBuilder().setSchemaName("testTable")
+        .addSingleValueDimension("intCol", DataType.INT)
+        .addSingleValueDimension("otherCol", DataType.INT)
+        .build();
+    TableConfig tableConfig = new 
TableConfigBuilder(TableType.OFFLINE).setTableName("testTable")
+        .setNullHandlingEnabled(true)
+        .setFieldConfigList(List.of(new FieldConfig.Builder("intCol")
+            .withIndexes(JsonUtils.stringToJsonNode("{\"null\": {\"backfill\": 
true}}"))
+            .build()))
+        .build();
+    TableConfig reloaded = 
JsonUtils.stringToObject(tableConfig.toJsonString(), TableConfig.class);
+
+    Map<String, FieldIndexConfigs> configsByCol =
+        FieldIndexConfigsUtil.createIndexConfigsByColName(reloaded, schema);
+    NullValueVectorConfig intColConfig = 
configsByCol.get("intCol").getConfig(StandardIndexes.nullValueVector());
+    assertTrue(intColConfig.isEnabled());
+    assertTrue(intColConfig.isBackfill());
+    // A column without the opt-in is still enabled by table-level null 
handling, but not backfilled.
+    NullValueVectorConfig otherColConfig = 
configsByCol.get("otherCol").getConfig(StandardIndexes.nullValueVector());
+    assertTrue(otherColConfig.isEnabled());
+    assertFalse(otherColConfig.isBackfill());
+  }
+
+  @Test
+  public void testBackfillValidation() {
+    TableConfig tableConfig = new 
TableConfigBuilder(TableType.OFFLINE).setTableName("testTable").build();
+    FieldIndexConfigs backfillOn = new FieldIndexConfigs.Builder()
+        .add(StandardIndexes.nullValueVector(), new 
NullValueVectorConfig(false, true))
+        .build();
+
+    // Scalar column opted into backfill is allowed.
+    StandardIndexes.nullValueVector()
+        .validate(backfillOn, new DimensionFieldSpec("scalar", DataType.INT, 
true), tableConfig);
+
+    // MAP column opted into backfill is rejected at config time.
+    FieldSpec mapFieldSpec = new ComplexFieldSpec("map", DataType.MAP, true, 
Map.of());
+    assertThrows(IllegalStateException.class,
+        () -> StandardIndexes.nullValueVector().validate(backfillOn, 
mapFieldSpec, tableConfig));
+
+    // MAP column without the backfill opt-in is fine (validation is a no-op).
+    FieldIndexConfigs backfillOff = new FieldIndexConfigs.Builder()
+        .add(StandardIndexes.nullValueVector(), new 
NullValueVectorConfig(false, false))
+        .build();
+    StandardIndexes.nullValueVector().validate(backfillOff, mapFieldSpec, 
tableConfig);
+  }
+
+  public static class ConfTest extends AbstractSerdeIndexContract {
 
     @Test(dataProvider = "provideCases", dataProviderClass = 
NullValueIndexTypeTest.class)
     public void isEnabledWhenNullable(boolean enableColumnBasedNullHandling, 
boolean tableNullable,
-        boolean fieldNullable, IndexConfig expected) {
+        boolean fieldNullable, NullValueVectorConfig expected) {
       _schema.setEnableColumnBasedNullHandling(enableColumnBasedNullHandling);
       _tableConfig.getIndexingConfig().setNullHandlingEnabled(tableNullable);
 
       FieldSpec fieldSpec = _schema.getFieldSpecFor("dimStr");
       fieldSpec.setNullable(fieldNullable);
 
-      assertEquals(expected);
+      assertEquals(getActualConfig("dimStr", 
StandardIndexes.nullValueVector()), expected);
     }
   }
 }
diff --git 
a/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/segment/index/nullvalue/NullValueVectorHandlerTest.java
 
b/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/segment/index/nullvalue/NullValueVectorHandlerTest.java
new file mode 100644
index 00000000000..34b31600277
--- /dev/null
+++ 
b/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/segment/index/nullvalue/NullValueVectorHandlerTest.java
@@ -0,0 +1,511 @@
+/**
+ * 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.nullvalue;
+
+import java.io.File;
+import java.math.BigDecimal;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import org.apache.commons.io.FileUtils;
+import 
org.apache.pinot.segment.local.indexsegment.immutable.ImmutableSegmentLoader;
+import 
org.apache.pinot.segment.local.segment.creator.impl.SegmentIndexCreationDriverImpl;
+import org.apache.pinot.segment.local.segment.index.loader.IndexLoadingConfig;
+import org.apache.pinot.segment.local.segment.readers.GenericRowRecordReader;
+import org.apache.pinot.segment.local.segment.readers.PinotSegmentColumnReader;
+import org.apache.pinot.segment.local.segment.store.SegmentLocalFSDirectory;
+import org.apache.pinot.segment.spi.ImmutableSegment;
+import org.apache.pinot.segment.spi.creator.SegmentGeneratorConfig;
+import org.apache.pinot.segment.spi.store.SegmentDirectory;
+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.data.FieldSpec.DataType;
+import org.apache.pinot.spi.data.Schema;
+import org.apache.pinot.spi.data.readers.GenericRow;
+import org.apache.pinot.spi.utils.JsonUtils;
+import org.apache.pinot.spi.utils.ReadMode;
+import org.apache.pinot.spi.utils.builder.TableConfigBuilder;
+import org.testng.annotations.AfterMethod;
+import org.testng.annotations.BeforeMethod;
+import org.testng.annotations.Test;
+
+import static org.testng.Assert.*;
+
+
+public class NullValueVectorHandlerTest {
+  private static final File TEMP_DIR = new File(FileUtils.getTempDirectory(), 
"NullValueVectorHandlerTest");
+  private static final String RAW_TABLE_NAME = "testTable";
+  private static final String SEGMENT_NAME = "testSegment";
+
+  private static final String SV_INT_COLUMN = "svInt";
+  private static final String SV_STRING_COLUMN = "svString";
+  private static final String MV_INT_COLUMN = "mvInt";
+  private static final String SV_LONG_COLUMN = "svLong";
+  // Opted out of backfill even though it also holds the sentinel default 
value, to verify selectivity.
+  private static final String SV_INT_NO_OPT_IN_COLUMN = "svIntNoOptIn";
+
+  private static final Schema SCHEMA = new 
Schema.SchemaBuilder().setSchemaName(RAW_TABLE_NAME)
+      .addSingleValueDimension(SV_INT_COLUMN, DataType.INT)
+      .addSingleValueDimension(SV_STRING_COLUMN, DataType.STRING)
+      .addMultiValueDimension(MV_INT_COLUMN, DataType.INT)
+      .addSingleValueDimension(SV_LONG_COLUMN, DataType.LONG)
+      .addSingleValueDimension(SV_INT_NO_OPT_IN_COLUMN, DataType.INT)
+      .build();
+
+  // Rows carry the default null values inline (as a pre-null-handling segment 
would), so the segment is built without a
+  // null value vector. Dimension defaults: INT -> Integer.MIN_VALUE, STRING 
-> "null". A null multi-value entry is
+  // stored as a single-element array holding the default null value. svLong 
holds no default null value.
+  private static final List<GenericRow> ROWS = List.of(
+      createRow(1, "a", new Object[]{10, 20}, 100L, 7),
+      createRow(Integer.MIN_VALUE, "null", new Object[]{Integer.MIN_VALUE}, 
200L, Integer.MIN_VALUE),
+      createRow(3, "null", new Object[]{Integer.MIN_VALUE, 5}, 300L, 9)
+  );
+
+  private static GenericRow createRow(int svInt, String svString, Object[] 
mvInt, long svLong, int svIntNoOptIn) {
+    GenericRow row = new GenericRow();
+    row.putValue(SV_INT_COLUMN, svInt);
+    row.putValue(SV_STRING_COLUMN, svString);
+    row.putValue(MV_INT_COLUMN, mvInt);
+    row.putValue(SV_LONG_COLUMN, svLong);
+    row.putValue(SV_INT_NO_OPT_IN_COLUMN, svIntNoOptIn);
+    return row;
+  }
+
+  /// Builds a row for the creation-path test. A `null` svInt marks that cell 
as null so ingestion records it in the
+  /// null value vector; all other columns are non-null.
+  private static GenericRow creationRow(Integer svInt, long svLong) {
+    GenericRow row = new GenericRow();
+    row.putValue(SV_INT_COLUMN, svInt);
+    row.putValue(SV_STRING_COLUMN, "s");
+    row.putValue(MV_INT_COLUMN, new Object[]{1, 2});
+    row.putValue(SV_LONG_COLUMN, svLong);
+    row.putValue(SV_INT_NO_OPT_IN_COLUMN, 5);
+    return row;
+  }
+
+  @BeforeMethod
+  public void setUp()
+      throws Exception {
+    FileUtils.deleteQuietly(TEMP_DIR);
+    // Build the segment without null handling, so no null value vector is 
created.
+    TableConfig tableConfig = new 
TableConfigBuilder(TableType.OFFLINE).setTableName(RAW_TABLE_NAME).build();
+    SegmentGeneratorConfig config = new SegmentGeneratorConfig(tableConfig, 
SCHEMA);
+    config.setOutDir(TEMP_DIR.getAbsolutePath());
+    config.setSegmentName(SEGMENT_NAME);
+    SegmentIndexCreationDriverImpl driver = new 
SegmentIndexCreationDriverImpl();
+    driver.init(config, new GenericRowRecordReader(ROWS));
+    driver.build();
+  }
+
+  @AfterMethod
+  public void tearDown() {
+    FileUtils.deleteQuietly(TEMP_DIR);
+  }
+
+  private static FieldConfig backfillOptIn(String column)
+      throws Exception {
+    // Opt into backfill via the null value vector index config: 
indexes.null.backfill = true.
+    return new 
FieldConfig.Builder(column).withIndexes(JsonUtils.stringToJsonNode("{\"null\": 
{\"backfill\": true}}"))
+        .build();
+  }
+
+  @Test
+  public void testBackfillNullVectorForOptedInColumns()
+      throws Exception {
+    // Opt in every column except SV_INT_NO_OPT_IN_COLUMN.
+    TableConfig tableConfig = new 
TableConfigBuilder(TableType.OFFLINE).setTableName(RAW_TABLE_NAME)
+        .setNullHandlingEnabled(true)
+        .setFieldConfigList(
+            List.of(backfillOptIn(SV_INT_COLUMN), 
backfillOptIn(SV_STRING_COLUMN), backfillOptIn(MV_INT_COLUMN),
+                backfillOptIn(SV_LONG_COLUMN)))
+        .build();
+
+    ImmutableSegment segment =
+        ImmutableSegmentLoader.load(new File(TEMP_DIR, SEGMENT_NAME), new 
IndexLoadingConfig(tableConfig, SCHEMA));
+    try {
+      // svInt: only the Integer.MIN_VALUE row is null.
+      assertNullDocIds(segment, SV_INT_COLUMN, false, true, false);
+      // svString: both "null" rows are null.
+      assertNullDocIds(segment, SV_STRING_COLUMN, false, true, true);
+      // mvInt: only the single-element {MIN_VALUE} row is null; multi-element 
rows are not.
+      assertNullDocIds(segment, MV_INT_COLUMN, false, true, false);
+      // svLong: no value equals the default null value, so no bitmap file is 
written (matching segment creation).
+      assertNullDocIds(segment, SV_LONG_COLUMN, false, false, false);
+
+      // Columns with null values carry a bitmap file and are not flagged 
non-null; the null-free column is flagged.
+      for (String column : List.of(SV_INT_COLUMN, SV_STRING_COLUMN, 
MV_INT_COLUMN)) {
+        assertNotNull(segment.getDataSource(column).getNullValueVector(),
+            "Column with null values should have a bitmap file: " + column);
+        
assertFalse(segment.getSegmentMetadata().getColumnMetadataFor(column).isNonNull(),
+            "Column with null values should not be flagged non-null: " + 
column);
+      }
+      
assertTrue(segment.getSegmentMetadata().getColumnMetadataFor(SV_LONG_COLUMN).isNonNull(),
+          "Null-free column should be flagged non-null (idempotency signal)");
+
+      // The un-opted column holds the sentinel default value too, but must be 
left untouched.
+      assertNullDocIds(segment, SV_INT_NO_OPT_IN_COLUMN, false, false, false);
+      
assertNull(segment.getDataSource(SV_INT_NO_OPT_IN_COLUMN).getNullValueVector(),
+          "Column not opted into backfill must not get a null value vector");
+      
assertFalse(segment.getSegmentMetadata().getColumnMetadataFor(SV_INT_NO_OPT_IN_COLUMN).isNonNull(),
+          "Column not opted into backfill must not be flagged");
+    } finally {
+      segment.destroy();
+    }
+  }
+
+  /// A non-default value per data type, in the form ingestion accepts. Covers 
every backfill-supported stored type
+  /// (`INT`, `LONG`, `FLOAT`, `DOUBLE`, `BIG_DECIMAL`, `STRING`, `BYTES`) 
plus every logical type that maps onto one of
+  /// them (`BOOLEAN`->`INT`, `TIMESTAMP`->`LONG`, `JSON`->`STRING`, 
`UUID`->`BYTES`), so both the per-type comparison
+  /// branches and the default-null-value round-trip through segment metadata 
are exercised.
+  private static Map<DataType, Object> nonDefaultValues() {
+    Map<DataType, Object> values = new LinkedHashMap<>();
+    values.put(DataType.INT, 1);
+    values.put(DataType.LONG, 1L);
+    values.put(DataType.FLOAT, 1.5f);
+    values.put(DataType.DOUBLE, 2.5d);
+    values.put(DataType.BIG_DECIMAL, BigDecimal.ONE);
+    values.put(DataType.BOOLEAN, true);
+    values.put(DataType.TIMESTAMP, 1000L);
+    values.put(DataType.STRING, "a");
+    values.put(DataType.JSON, "{\"a\":1}");
+    values.put(DataType.BYTES, new byte[]{1, 2});
+    values.put(DataType.UUID, "11111111-2222-3333-4444-555555555555");
+    return values;
+  }
+
+  /// Data types allowed for a metric field. Their default null values differ 
from the dimension defaults for
+  /// `INT`/`LONG`/`FLOAT`/`DOUBLE` (`0`/`0.0` instead of 
`MIN_VALUE`/`-Infinity`), so they are covered separately.
+  private static final List<DataType> METRIC_TYPES =
+      List.of(DataType.INT, DataType.LONG, DataType.FLOAT, DataType.DOUBLE, 
DataType.BIG_DECIMAL, DataType.BYTES);
+
+  /// `JSON` is single-value only; 
`SchemaUtils.validateMultiValueCompatibility` rejects an MV JSON column.
+  private static final Set<DataType> MV_UNSUPPORTED_TYPES = 
Set.of(DataType.JSON);
+
+  private static String svColumn(DataType dataType) {
+    return "sv" + dataType.name();
+  }
+
+  private static String mvColumn(DataType dataType) {
+    return "mv" + dataType.name();
+  }
+
+  private static String metricColumn(DataType dataType) {
+    return "me" + dataType.name();
+  }
+
+  @Test
+  public void testBackfillAllSupportedStoredTypes()
+      throws Exception {
+    // One SV and one MV dimension column per data type, plus one metric 
column per metric-allowed type (metric
+    // defaults differ), so every per-type comparison branch is exercised 
against every default null value. Row 0 holds
+    // a non-default value; row 1 leaves every cell null so the ingestion 
pipeline substitutes the column's default null
+    // value (and, for MV, the single-element array holding it) exactly as it 
would for a pre-null-handling segment.
+    // Row 1's MV entries are therefore single-element, so the element 
comparison (not just the length check) is
+    // exercised in both directions.
+    Map<DataType, Object> nonDefaultValues = nonDefaultValues();
+    Schema.SchemaBuilder schemaBuilder = new 
Schema.SchemaBuilder().setSchemaName(RAW_TABLE_NAME);
+    for (DataType dataType : nonDefaultValues.keySet()) {
+      schemaBuilder.addSingleValueDimension(svColumn(dataType), dataType);
+      if (!MV_UNSUPPORTED_TYPES.contains(dataType)) {
+        schemaBuilder.addMultiValueDimension(mvColumn(dataType), dataType);
+      }
+    }
+    // Metric columns are single-value only, and carry different default null 
values than dimensions.
+    for (DataType dataType : METRIC_TYPES) {
+      schemaBuilder.addMetric(metricColumn(dataType), dataType);
+    }
+    Schema schema = schemaBuilder.build();
+
+    GenericRow nonNullRow = new GenericRow();
+    GenericRow nullRow = new GenericRow();
+    List<FieldConfig> fieldConfigs = new ArrayList<>();
+    for (Map.Entry<DataType, Object> entry : nonDefaultValues.entrySet()) {
+      DataType dataType = entry.getKey();
+      Object nonDefaultValue = entry.getValue();
+      String svColumn = svColumn(dataType);
+      nonNullRow.putValue(svColumn, nonDefaultValue);
+      nullRow.putValue(svColumn, null);
+      fieldConfigs.add(backfillOptIn(svColumn));
+      if (!MV_UNSUPPORTED_TYPES.contains(dataType)) {
+        String mvColumn = mvColumn(dataType);
+        nonNullRow.putValue(mvColumn, new Object[]{nonDefaultValue});
+        nullRow.putValue(mvColumn, null);
+        fieldConfigs.add(backfillOptIn(mvColumn));
+      }
+    }
+    for (DataType dataType : METRIC_TYPES) {
+      String metricColumn = metricColumn(dataType);
+      nonNullRow.putValue(metricColumn, nonDefaultValues.get(dataType));
+      nullRow.putValue(metricColumn, null);
+      fieldConfigs.add(backfillOptIn(metricColumn));
+    }
+
+    // Build without null handling, so no null value vector exists.
+    String segmentName = "allTypesSegment";
+    SegmentGeneratorConfig config = new SegmentGeneratorConfig(
+        new 
TableConfigBuilder(TableType.OFFLINE).setTableName(RAW_TABLE_NAME).build(), 
schema);
+    config.setOutDir(TEMP_DIR.getAbsolutePath());
+    config.setSegmentName(segmentName);
+    SegmentIndexCreationDriverImpl driver = new 
SegmentIndexCreationDriverImpl();
+    driver.init(config, new GenericRowRecordReader(List.of(nonNullRow, 
nullRow)));
+    driver.build();
+
+    TableConfig tableConfig = new 
TableConfigBuilder(TableType.OFFLINE).setTableName(RAW_TABLE_NAME)
+        .setNullHandlingEnabled(true)
+        .setFieldConfigList(fieldConfigs)
+        .build();
+    ImmutableSegment segment =
+        ImmutableSegmentLoader.load(new File(TEMP_DIR, segmentName), new 
IndexLoadingConfig(tableConfig, schema));
+    try {
+      for (DataType dataType : nonDefaultValues.keySet()) {
+        assertNullDocIds(segment, svColumn(dataType), false, true);
+        if (!MV_UNSUPPORTED_TYPES.contains(dataType)) {
+          assertNullDocIds(segment, mvColumn(dataType), false, true);
+        }
+      }
+      for (DataType dataType : METRIC_TYPES) {
+        assertNullDocIds(segment, metricColumn(dataType), false, true);
+      }
+    } finally {
+      segment.destroy();
+    }
+  }
+
+  @Test
+  public void testBackfillWithConfiguredDefaultNullValues()
+      throws Exception {
+    // Columns with an explicit (non-default) default null value in the schema 
— the recommended way to use backfill,
+    // since the sentinel can be chosen so it does not occur in the data. This 
takes a different resolution path than
+    // the built-in defaults: the configured value is persisted as the 
column's DEFAULT_NULL_VALUE and parsed back via
+    // DataType.convert when the segment metadata is read. One column per 
distinct comparison mechanism, plus MV.
+    Schema schema = new Schema.SchemaBuilder().setSchemaName(RAW_TABLE_NAME)
+        .addSingleValueDimension("cfgInt", DataType.INT, -1)
+        .addMultiValueDimension("cfgIntMv", DataType.INT, -1)
+        .addSingleValueDimension("cfgDouble", DataType.DOUBLE, -1.5d)
+        // The configured scale (-1.50) differs from the stored scale (-1.5, 
trailing zeros stripped at ingestion), so
+        // this only matches because the comparison uses BigDecimal.compareTo 
rather than equals.
+        .addSingleValueDimension("cfgBigDecimal", DataType.BIG_DECIMAL, new 
BigDecimal("-1.50"))
+        .addSingleValueDimension("cfgString", DataType.STRING, "N/A")
+        .addSingleValueDimension("cfgBytes", DataType.BYTES, new byte[]{9})
+        .addSingleValueDimension("cfgTimestamp", DataType.TIMESTAMP, 
1600000000000L)
+        .build();
+    List<String> columns =
+        List.of("cfgInt", "cfgIntMv", "cfgDouble", "cfgBigDecimal", 
"cfgString", "cfgBytes", "cfgTimestamp");
+
+    GenericRow nonNullRow = new GenericRow();
+    nonNullRow.putValue("cfgInt", 1);
+    nonNullRow.putValue("cfgIntMv", new Object[]{1});
+    nonNullRow.putValue("cfgDouble", 2.5d);
+    nonNullRow.putValue("cfgBigDecimal", BigDecimal.ONE);
+    nonNullRow.putValue("cfgString", "a");
+    nonNullRow.putValue("cfgBytes", new byte[]{1, 2});
+    nonNullRow.putValue("cfgTimestamp", 1700000000000L);
+    // Leave every cell null so ingestion substitutes each column's configured 
default null value.
+    GenericRow nullRow = new GenericRow();
+    for (String column : columns) {
+      nullRow.putValue(column, null);
+    }
+
+    String segmentName = "configuredDefaultsSegment";
+    SegmentGeneratorConfig config = new SegmentGeneratorConfig(
+        new 
TableConfigBuilder(TableType.OFFLINE).setTableName(RAW_TABLE_NAME).build(), 
schema);
+    config.setOutDir(TEMP_DIR.getAbsolutePath());
+    config.setSegmentName(segmentName);
+    SegmentIndexCreationDriverImpl driver = new 
SegmentIndexCreationDriverImpl();
+    driver.init(config, new GenericRowRecordReader(List.of(nonNullRow, 
nullRow)));
+    driver.build();
+
+    List<FieldConfig> fieldConfigs = new ArrayList<>();
+    for (String column : columns) {
+      fieldConfigs.add(backfillOptIn(column));
+    }
+    TableConfig tableConfig = new 
TableConfigBuilder(TableType.OFFLINE).setTableName(RAW_TABLE_NAME)
+        .setNullHandlingEnabled(true)
+        .setFieldConfigList(fieldConfigs)
+        .build();
+    ImmutableSegment segment =
+        ImmutableSegmentLoader.load(new File(TEMP_DIR, segmentName), new 
IndexLoadingConfig(tableConfig, schema));
+    try {
+      for (String column : columns) {
+        assertNullDocIds(segment, column, false, true);
+      }
+    } finally {
+      segment.destroy();
+    }
+  }
+
+  @Test
+  public void testBackfillOnTimeColumn()
+      throws Exception {
+    // The time column is the one column whose stored null value is not always 
its default: ingestion substitutes the
+    // current time when the configured default is outside the valid time 
range (see NullValueTransformerUtils), which
+    // is why TableConfigUtils rejects a backfill opt-in in that case. Here 
the default is deliberately in range, so
+    // ingestion stores it as-is and backfill can match it -- the only 
time-column configuration backfill supports.
+    String timeColumn = "ts";
+    long inRangeDefault = 1600000000000L;
+    Schema schema = new Schema.SchemaBuilder().setSchemaName(RAW_TABLE_NAME)
+        .addDateTime(timeColumn, DataType.LONG, "1:MILLISECONDS:EPOCH", 
"1:MILLISECONDS", inRangeDefault, null)
+        .build();
+
+    GenericRow nonNullRow = new GenericRow();
+    nonNullRow.putValue(timeColumn, 1700000000000L);
+    GenericRow nullRow = new GenericRow();
+    nullRow.putValue(timeColumn, null);
+
+    // Build without null handling, so no null value vector exists and the 
null row stores the configured default.
+    String segmentName = "timeColumnSegment";
+    SegmentGeneratorConfig config = new SegmentGeneratorConfig(
+        new TableConfigBuilder(TableType.OFFLINE).setTableName(RAW_TABLE_NAME)
+            .setTimeColumnName(timeColumn)
+            .build(), schema);
+    config.setOutDir(TEMP_DIR.getAbsolutePath());
+    config.setSegmentName(segmentName);
+    SegmentIndexCreationDriverImpl driver = new 
SegmentIndexCreationDriverImpl();
+    driver.init(config, new GenericRowRecordReader(List.of(nonNullRow, 
nullRow)));
+    driver.build();
+
+    TableConfig tableConfig = new 
TableConfigBuilder(TableType.OFFLINE).setTableName(RAW_TABLE_NAME)
+        .setTimeColumnName(timeColumn)
+        .setNullHandlingEnabled(true)
+        .setFieldConfigList(List.of(backfillOptIn(timeColumn)))
+        .build();
+    ImmutableSegment segment =
+        ImmutableSegmentLoader.load(new File(TEMP_DIR, segmentName), new 
IndexLoadingConfig(tableConfig, schema));
+    try {
+      assertNullDocIds(segment, timeColumn, false, true);
+    } finally {
+      segment.destroy();
+    }
+  }
+
+  @Test
+  public void testNonNullFlagWrittenAtCreation()
+      throws Exception {
+    // Build a fresh segment WITH null handling enabled (no backfill opt-in): 
svInt has a null value, the others do not.
+    String segmentName = "creationSegment";
+    TableConfig tableConfig =
+        new 
TableConfigBuilder(TableType.OFFLINE).setTableName(RAW_TABLE_NAME).setNullHandlingEnabled(true).build();
+    List<GenericRow> rows = List.of(creationRow(1, 100L), creationRow(null, 
200L), creationRow(3, 300L));
+    SegmentGeneratorConfig config = new SegmentGeneratorConfig(tableConfig, 
SCHEMA);
+    config.setOutDir(TEMP_DIR.getAbsolutePath());
+    config.setSegmentName(segmentName);
+    SegmentIndexCreationDriverImpl driver = new 
SegmentIndexCreationDriverImpl();
+    driver.init(config, new GenericRowRecordReader(rows));
+    driver.build();
+
+    ImmutableSegment segment =
+        ImmutableSegmentLoader.load(new File(TEMP_DIR, segmentName), new 
IndexLoadingConfig(tableConfig, SCHEMA));
+    try {
+      // svInt has a null -> a bitmap file is written at creation and the 
column is not flagged non-null.
+      assertNotNull(segment.getDataSource(SV_INT_COLUMN).getNullValueVector());
+      
assertTrue(segment.getDataSource(SV_INT_COLUMN).getNullValueVector().isNull(1));
+      
assertFalse(segment.getSegmentMetadata().getColumnMetadataFor(SV_INT_COLUMN).isNonNull());
+      // svLong has no nulls -> no bitmap file, and the column is flagged 
non-null at creation.
+      assertNull(segment.getDataSource(SV_LONG_COLUMN).getNullValueVector());
+      
assertTrue(segment.getSegmentMetadata().getColumnMetadataFor(SV_LONG_COLUMN).isNonNull());
+    } finally {
+      segment.destroy();
+    }
+  }
+
+  @Test
+  public void testBackfillIsIdempotent()
+      throws Exception {
+    TableConfig tableConfig = new 
TableConfigBuilder(TableType.OFFLINE).setTableName(RAW_TABLE_NAME)
+        .setNullHandlingEnabled(true)
+        .setFieldConfigList(List.of(backfillOptIn(SV_INT_COLUMN), 
backfillOptIn(SV_LONG_COLUMN)))
+        .build();
+    IndexLoadingConfig indexLoadingConfig = new 
IndexLoadingConfig(tableConfig, SCHEMA);
+    File segmentDir = new File(TEMP_DIR, SEGMENT_NAME);
+
+    // First reload backfills: svInt gets a bitmap file (has the sentinel 
value), svLong gets the non-null flag.
+    ImmutableSegmentLoader.load(segmentDir, indexLoadingConfig).destroy();
+
+    // A second reload must find nothing left to backfill for either column.
+    try (SegmentDirectory segmentDirectory = new 
SegmentLocalFSDirectory(segmentDir, ReadMode.mmap);
+        SegmentDirectory.Reader reader = segmentDirectory.createReader()) {
+      NullValueVectorHandler handler = new 
NullValueVectorHandler(segmentDirectory,
+          indexLoadingConfig.getFieldIndexConfigByColName(), tableConfig, 
SCHEMA);
+      assertFalse(handler.needUpdateIndices(reader));
+    }
+  }
+
+  @Test
+  public void testColumnMissingFromSegmentIsSkipped()
+      throws Exception {
+    // A column present in the schema but not yet in the segment (normally 
materialized by DefaultColumnHandler before
+    // the index handlers run) must be skipped rather than fail when the 
handler is invoked on its own.
+    String missingColumn = "svIntMissing";
+    Schema schema = new Schema.SchemaBuilder().setSchemaName(RAW_TABLE_NAME)
+        .addSingleValueDimension(SV_INT_COLUMN, DataType.INT)
+        .addSingleValueDimension(SV_STRING_COLUMN, DataType.STRING)
+        .addMultiValueDimension(MV_INT_COLUMN, DataType.INT)
+        .addSingleValueDimension(SV_LONG_COLUMN, DataType.LONG)
+        .addSingleValueDimension(SV_INT_NO_OPT_IN_COLUMN, DataType.INT)
+        .addSingleValueDimension(missingColumn, DataType.INT)
+        .build();
+    TableConfig tableConfig = new 
TableConfigBuilder(TableType.OFFLINE).setTableName(RAW_TABLE_NAME)
+        .setNullHandlingEnabled(true)
+        .setFieldConfigList(List.of(backfillOptIn(missingColumn)))
+        .build();
+    IndexLoadingConfig indexLoadingConfig = new 
IndexLoadingConfig(tableConfig, schema);
+
+    try (SegmentDirectory segmentDirectory = new SegmentLocalFSDirectory(new 
File(TEMP_DIR, SEGMENT_NAME),
+        ReadMode.mmap); SegmentDirectory.Reader reader = 
segmentDirectory.createReader()) {
+      NullValueVectorHandler handler = new 
NullValueVectorHandler(segmentDirectory,
+          indexLoadingConfig.getFieldIndexConfigByColName(), tableConfig, 
schema);
+      assertFalse(handler.needUpdateIndices(reader),
+          "A column missing from the segment must not be reported as needing a 
backfill");
+    }
+  }
+
+  @Test
+  public void testNoBackfillWithoutOptIn()
+      throws Exception {
+    // Null handling on, but no column opts into backfill: no null value 
vector should be generated.
+    TableConfig tableConfig =
+        new 
TableConfigBuilder(TableType.OFFLINE).setTableName(RAW_TABLE_NAME).setNullHandlingEnabled(true).build();
+
+    ImmutableSegment segment =
+        ImmutableSegmentLoader.load(new File(TEMP_DIR, SEGMENT_NAME), new 
IndexLoadingConfig(tableConfig, SCHEMA));
+    try {
+      assertNullDocIds(segment, SV_INT_COLUMN, false, false, false);
+      assertNullDocIds(segment, SV_STRING_COLUMN, false, false, false);
+      assertNullDocIds(segment, MV_INT_COLUMN, false, false, false);
+      assertNull(segment.getDataSource(SV_INT_COLUMN).getNullValueVector(),
+          "No null value vector should be generated without opt-in");
+    } finally {
+      segment.destroy();
+    }
+  }
+
+  private static void assertNullDocIds(ImmutableSegment segment, String 
column, boolean... expectedNull)
+      throws Exception {
+    try (PinotSegmentColumnReader columnReader = new 
PinotSegmentColumnReader(segment, column)) {
+      assertEquals(segment.getSegmentMetadata().getTotalDocs(), 
expectedNull.length);
+      for (int docId = 0; docId < expectedNull.length; docId++) {
+        assertEquals(columnReader.isNull(docId), expectedNull[docId],
+            "Unexpected null status for column: " + column + ", docId: " + 
docId);
+      }
+    }
+  }
+}
diff --git 
a/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/utils/TableConfigUtilsTest.java
 
b/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/utils/TableConfigUtilsTest.java
index 3bd12121812..178b7cb8a7d 100644
--- 
a/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/utils/TableConfigUtilsTest.java
+++ 
b/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/utils/TableConfigUtilsTest.java
@@ -64,6 +64,7 @@ import 
org.apache.pinot.spi.config.table.ingestion.IngestionConfig;
 import org.apache.pinot.spi.config.table.ingestion.SourceFieldConfig;
 import org.apache.pinot.spi.config.table.ingestion.StreamIngestionConfig;
 import org.apache.pinot.spi.config.table.ingestion.TransformConfig;
+import org.apache.pinot.spi.data.ComplexFieldSpec;
 import org.apache.pinot.spi.data.DimensionFieldSpec;
 import org.apache.pinot.spi.data.FieldSpec.DataType;
 import org.apache.pinot.spi.data.MetricFieldSpec;
@@ -1840,6 +1841,70 @@ public class TableConfigUtilsTest {
     }
   }
 
+  @Test
+  public void testValidateNullValueVectorBackfill()
+      throws Exception {
+    // Valid: opting a scalar column into null value vector backfill 
(indexes.null.backfill) passes validation.
+    Schema scalarSchema = new Schema.SchemaBuilder().setSchemaName(TABLE_NAME)
+        .addSingleValueDimension("intCol", DataType.INT)
+        .build();
+    FieldConfig scalarFieldConfig = new FieldConfig.Builder("intCol")
+        .withIndexes(JsonUtils.stringToJsonNode("{\"null\": {\"backfill\": 
true}}"))
+        .build();
+    TableConfig scalarTableConfig = new 
TableConfigBuilder(TableType.OFFLINE).setTableName(TABLE_NAME)
+        .setNullHandlingEnabled(true)
+        .setFieldConfigList(List.of(scalarFieldConfig))
+        .build();
+    TableConfigUtils.validate(scalarTableConfig, scalarSchema);
+
+    // Invalid: opting a MAP column into backfill is rejected with a clear 
message.
+    Schema mapSchema = new 
Schema.SchemaBuilder().setSchemaName(TABLE_NAME).build();
+    mapSchema.addField(new ComplexFieldSpec("mapCol", DataType.MAP, true, 
Map.of(
+        "key", new DimensionFieldSpec("key", DataType.STRING, true),
+        "value", new DimensionFieldSpec("value", DataType.INT, true)
+    )));
+    FieldConfig mapFieldConfig = new FieldConfig.Builder("mapCol")
+        .withIndexes(JsonUtils.stringToJsonNode("{\"null\": {\"backfill\": 
true}}"))
+        .build();
+    TableConfig mapTableConfig = new 
TableConfigBuilder(TableType.OFFLINE).setTableName(TABLE_NAME)
+        .setNullHandlingEnabled(true)
+        .setFieldConfigList(List.of(mapFieldConfig))
+        .build();
+    IllegalStateException e =
+        expectThrows(IllegalStateException.class, () -> 
TableConfigUtils.validate(mapTableConfig, mapSchema));
+    assertTrue(e.getMessage().contains("Null value vector backfill is not 
supported"),
+        "Unexpected validation failure: " + e.getMessage());
+  }
+
+  @Test
+  public void testValidateNullValueVectorBackfillOnTimeColumn()
+      throws Exception {
+    FieldConfig timeFieldConfig = new FieldConfig.Builder(TIME_COLUMN)
+        .withIndexes(JsonUtils.stringToJsonNode("{\"null\": {\"backfill\": 
true}}"))
+        .build();
+    TableConfig tableConfig = new 
TableConfigBuilder(TableType.OFFLINE).setTableName(TABLE_NAME)
+        .setTimeColumnName(TIME_COLUMN)
+        .setNullHandlingEnabled(true)
+        .setFieldConfigList(List.of(timeFieldConfig))
+        .build();
+
+    // Invalid: the default default null value (Long.MIN_VALUE) is outside the 
valid time range, so ingestion stores the
+    // current time for null values and a backfill scan could never match it.
+    Schema defaultDefaultSchema = new 
Schema.SchemaBuilder().setSchemaName(TABLE_NAME)
+        .addDateTime(TIME_COLUMN, DataType.LONG, "1:MILLISECONDS:EPOCH", 
"1:MILLISECONDS")
+        .build();
+    IllegalStateException e =
+        expectThrows(IllegalStateException.class, () -> 
TableConfigUtils.validate(tableConfig, defaultDefaultSchema));
+    assertTrue(e.getMessage().contains("Null value vector backfill is not 
supported for time column"),
+        "Unexpected validation failure: " + e.getMessage());
+
+    // Valid: an explicit in-range default null value is stored as-is for null 
values, so backfill can match it.
+    Schema inRangeDefaultSchema = new 
Schema.SchemaBuilder().setSchemaName(TABLE_NAME)
+        .addDateTime(TIME_COLUMN, DataType.LONG, "1:MILLISECONDS:EPOCH", 
"1:MILLISECONDS", 1600000000000L, null)
+        .build();
+    TableConfigUtils.validate(tableConfig, inRangeDefaultSchema);
+  }
+
   @Test
   public void testValidateBFOnBoolean() {
     Schema schema = new Schema.SchemaBuilder().setSchemaName(TABLE_NAME)
diff --git 
a/pinot-segment-spi/src/main/java/org/apache/pinot/segment/spi/ColumnMetadata.java
 
b/pinot-segment-spi/src/main/java/org/apache/pinot/segment/spi/ColumnMetadata.java
index d2d762a0856..8344cf33cb4 100644
--- 
a/pinot-segment-spi/src/main/java/org/apache/pinot/segment/spi/ColumnMetadata.java
+++ 
b/pinot-segment-spi/src/main/java/org/apache/pinot/segment/spi/ColumnMetadata.java
@@ -39,6 +39,15 @@ public interface ColumnMetadata extends ColumnShape {
   /// Returns the forward-index encoding for this column.
   EncodingType getForwardIndexEncoding();
 
+  /// Returns `true` when this column is known to contain no null values: null 
handling is enabled and no nulls were
+  /// found, so no null value vector bitmap file is written. This is the only 
signal that distinguishes such a column
+  /// from one that was never null-handled (both lack a bitmap file). A column 
that does have null values carries a
+  /// bitmap file instead and is not flagged. Defaults to `false` for segments 
created before this flag was introduced
+  /// (`false` therefore does not assert the presence of nulls).
+  default boolean isNonNull() {
+    return false;
+  }
+
   /// Returns `true` when both min and max value are invalid, and there is no 
need to regenerate them.
   boolean isMinMaxValueInvalid();
 
diff --git 
a/pinot-segment-spi/src/main/java/org/apache/pinot/segment/spi/V1Constants.java 
b/pinot-segment-spi/src/main/java/org/apache/pinot/segment/spi/V1Constants.java
index fb67e32b36f..53464d338d6 100644
--- 
a/pinot-segment-spi/src/main/java/org/apache/pinot/segment/spi/V1Constants.java
+++ 
b/pinot-segment-spi/src/main/java/org/apache/pinot/segment/spi/V1Constants.java
@@ -161,6 +161,12 @@ public class V1Constants {
       public static final String FORWARD_INDEX_ENCODING = 
"forwardIndexEncoding";
       // Mandatory, treated as `false` when missing for backward compatibility
       public static final String IS_SORTED = "isSorted";
+      // Optional, treated as `false` when missing for backward compatibility. 
Set to `true` only when null handling is
+      // enabled for the column but it has no null values, so the null value 
vector bitmap file is skipped. This
+      // distinguishes "null handling applied, no nulls" (flag set, no bitmap 
file) from "null handling never applied"
+      // (no flag, no bitmap file) — which absence of the bitmap file alone 
cannot. Columns that do have null values
+      // are identified by the presence of the bitmap file and are not flagged.
+      public static final String IS_NON_NULL = "isNonNull";
       // Optional
       public static final String MIN_VALUE = "minValue";
       // Optional
diff --git 
a/pinot-segment-spi/src/main/java/org/apache/pinot/segment/spi/index/StandardIndexes.java
 
b/pinot-segment-spi/src/main/java/org/apache/pinot/segment/spi/index/StandardIndexes.java
index 8df197c4bb2..8a63762623e 100644
--- 
a/pinot-segment-spi/src/main/java/org/apache/pinot/segment/spi/index/StandardIndexes.java
+++ 
b/pinot-segment-spi/src/main/java/org/apache/pinot/segment/spi/index/StandardIndexes.java
@@ -45,6 +45,7 @@ import 
org.apache.pinot.segment.spi.index.reader.VectorIndexReader;
 import org.apache.pinot.spi.config.table.BloomFilterConfig;
 import org.apache.pinot.spi.config.table.IndexConfig;
 import org.apache.pinot.spi.config.table.JsonIndexConfig;
+import org.apache.pinot.spi.config.table.NullValueVectorConfig;
 import org.apache.pinot.spi.config.table.OpenStructIndexConfig;
 
 
@@ -97,8 +98,8 @@ public class StandardIndexes {
         IndexService.getInstance().get(DICTIONARY_ID);
   }
 
-  public static IndexType<IndexConfig, NullValueVectorReader, ?> 
nullValueVector() {
-    return (IndexType<IndexConfig, NullValueVectorReader, ?>)
+  public static IndexType<NullValueVectorConfig, NullValueVectorReader, ?> 
nullValueVector() {
+    return (IndexType<NullValueVectorConfig, NullValueVectorReader, ?>)
         IndexService.getInstance().get(NULL_VALUE_VECTOR_ID);
   }
 
diff --git 
a/pinot-segment-spi/src/main/java/org/apache/pinot/segment/spi/index/metadata/ColumnMetadataImpl.java
 
b/pinot-segment-spi/src/main/java/org/apache/pinot/segment/spi/index/metadata/ColumnMetadataImpl.java
index 0755f9a9b6c..af2c3f5f8df 100644
--- 
a/pinot-segment-spi/src/main/java/org/apache/pinot/segment/spi/index/metadata/ColumnMetadataImpl.java
+++ 
b/pinot-segment-spi/src/main/java/org/apache/pinot/segment/spi/index/metadata/ColumnMetadataImpl.java
@@ -64,6 +64,7 @@ public class ColumnMetadataImpl implements ColumnMetadata {
   private final boolean _hasDictionary;
   private final EncodingType _forwardIndexEncoding;
   private final boolean _sorted;
+  private final boolean _nonNull;
   private final Comparable _minValue;
   private final Comparable _maxValue;
   private final boolean _minMaxValueInvalid;
@@ -90,7 +91,7 @@ public class ColumnMetadataImpl implements ColumnMetadata {
   private final LongArrayList _indexTypeSizeList = new LongArrayList(2);
 
   private ColumnMetadataImpl(FieldSpec fieldSpec, int totalDocs, int 
cardinality, boolean hasDictionary,
-      @Nullable EncodingType forwardIndexEncoding, boolean sorted, @Nullable 
Comparable minValue,
+      @Nullable EncodingType forwardIndexEncoding, boolean sorted, boolean 
nonNull, @Nullable Comparable minValue,
       @Nullable Comparable maxValue,
       boolean minMaxValueInvalid, int lengthOfShortestElement, int 
lengthOfLongestElement, boolean isAscii,
       int totalNumberOfEntries, int maxNumberOfMultiValues, int 
maxRowLengthInBytes, int bitsPerElement,
@@ -102,6 +103,7 @@ public class ColumnMetadataImpl implements ColumnMetadata {
     _hasDictionary = hasDictionary;
     _forwardIndexEncoding = forwardIndexEncoding;
     _sorted = sorted;
+    _nonNull = nonNull;
     _minValue = minValue;
     _maxValue = maxValue;
     _minMaxValueInvalid = minMaxValueInvalid;
@@ -149,6 +151,11 @@ public class ColumnMetadataImpl implements ColumnMetadata {
     return _sorted;
   }
 
+  @Override
+  public boolean isNonNull() {
+    return _nonNull;
+  }
+
   @Nullable
   @Override
   public Comparable<?> getMinValue() {
@@ -302,7 +309,7 @@ public class ColumnMetadataImpl implements ColumnMetadata {
         && _cardinality == that._cardinality
         && _hasDictionary == that._hasDictionary
         && _forwardIndexEncoding == that._forwardIndexEncoding
-        && _sorted == that._sorted
+        && _sorted == that._sorted && _nonNull == that._nonNull
         && _minMaxValueInvalid == that._minMaxValueInvalid
         && _lengthOfShortestElement == that._lengthOfShortestElement
         && _lengthOfLongestElement == that._lengthOfLongestElement
@@ -324,7 +331,7 @@ public class ColumnMetadataImpl implements ColumnMetadata {
 
   @Override
   public int hashCode() {
-    return Objects.hash(_fieldSpec, _totalDocs, _cardinality, _hasDictionary, 
_forwardIndexEncoding, _sorted,
+    return Objects.hash(_fieldSpec, _totalDocs, _cardinality, _hasDictionary, 
_forwardIndexEncoding, _sorted, _nonNull,
         _minValue, _maxValue, _minMaxValueInvalid, _lengthOfShortestElement, 
_lengthOfLongestElement, _isAscii,
         _totalNumberOfEntries, _maxNumberOfMultiValues, _maxRowLengthInBytes, 
_bitsPerElement, _partitionFunction,
         _partitions, _autoGenerated, _parentColumn, _compressionMetadata, 
_indexTypeSizeList);
@@ -338,7 +345,7 @@ public class ColumnMetadataImpl implements ColumnMetadata {
         + ", _cardinality=" + _cardinality
         + ", _hasDictionary=" + _hasDictionary
         + ", _forwardIndexEncoding=" + _forwardIndexEncoding
-        + ", _sorted=" + _sorted
+        + ", _sorted=" + _sorted + ", _nonNull=" + _nonNull
         + ", _minValue=" + _minValue
         + ", _maxValue=" + _maxValue
         + ", _minMaxValueInvalid=" + _minMaxValueInvalid
@@ -369,6 +376,7 @@ public class ColumnMetadataImpl implements ColumnMetadata {
         .setForwardIndexEncoding(
             config.getEnum(Column.getKeyFor(column, 
Column.FORWARD_INDEX_ENCODING), EncodingType.class, null))
         .setSorted(config.getBoolean(Column.getKeyFor(column, 
Column.IS_SORTED), false))
+        .setNonNull(config.getBoolean(Column.getKeyFor(column, 
Column.IS_NON_NULL), false))
         .setLengthOfShortestElement(
             config.getInt(Column.getKeyFor(column, 
Column.LENGTH_OF_SHORTEST_ELEMENT), UNAVAILABLE))
         .setLengthOfLongestElement(
@@ -378,8 +386,7 @@ public class ColumnMetadataImpl implements ColumnMetadata {
         .setTotalNumberOfEntries(config.getInt(Column.getKeyFor(column, 
Column.TOTAL_NUMBER_OF_ENTRIES), UNAVAILABLE))
         .setMaxNumberOfMultiValues(
             config.getInt(Column.getKeyFor(column, 
Column.MAX_MULTI_VALUE_ELEMENTS), UNAVAILABLE))
-        .setMaxRowLengthInBytes(
-            config.getInt(Column.getKeyFor(column, 
Column.MAX_ROW_LENGTH_IN_BYTES), UNAVAILABLE))
+        .setMaxRowLengthInBytes(config.getInt(Column.getKeyFor(column, 
Column.MAX_ROW_LENGTH_IN_BYTES), UNAVAILABLE))
         .setBitsPerElement(config.getInt(Column.getKeyFor(column, 
Column.BITS_PER_ELEMENT), UNAVAILABLE))
         .setAutoGenerated(config.getBoolean(Column.getKeyFor(column, 
Column.IS_AUTO_GENERATED), false))
         .setParentColumn(config.getString(Column.getKeyFor(column, 
Column.PARENT_COLUMN), null));
@@ -613,6 +620,7 @@ public class ColumnMetadataImpl implements ColumnMetadata {
     private boolean _hasDictionary;
     private EncodingType _forwardIndexEncoding;
     private boolean _sorted;
+    private boolean _nonNull;
     private Comparable<?> _minValue;
     private Comparable<?> _maxValue;
     private boolean _minMaxValueInvalid;
@@ -662,6 +670,11 @@ public class ColumnMetadataImpl implements ColumnMetadata {
       return this;
     }
 
+    public Builder setNonNull(boolean nonNull) {
+      _nonNull = nonNull;
+      return this;
+    }
+
     public Builder setMinValue(Comparable<?> minValue) {
       _minValue = minValue;
       return this;
@@ -793,9 +806,9 @@ public class ColumnMetadataImpl implements ColumnMetadata {
       }
 
       return new ColumnMetadataImpl(_fieldSpec, _totalDocs, _cardinality, 
_hasDictionary, _forwardIndexEncoding,
-          _sorted, _minValue, _maxValue, _minMaxValueInvalid, 
_lengthOfShortestElement, _lengthOfLongestElement,
-          _isAscii, _totalNumberOfEntries, _maxNumberOfMultiValues, 
_maxRowLengthInBytes, _bitsPerElement,
-          _partitionFunction, _partitions, _autoGenerated, _parentColumn,
+          _sorted, _nonNull, _minValue, _maxValue, _minMaxValueInvalid, 
_lengthOfShortestElement,
+          _lengthOfLongestElement, _isAscii, _totalNumberOfEntries, 
_maxNumberOfMultiValues, _maxRowLengthInBytes,
+          _bitsPerElement, _partitionFunction, _partitions, _autoGenerated, 
_parentColumn,
           CompressionMetadata.create(_uncompressedValueSizeInBytes, 
_forwardIndexChunkCompressionType,
               _dictionaryUncompressedValueSizeInBytes));
     }
diff --git 
a/pinot-spi/src/main/java/org/apache/pinot/spi/config/table/NullValueVectorConfig.java
 
b/pinot-spi/src/main/java/org/apache/pinot/spi/config/table/NullValueVectorConfig.java
new file mode 100644
index 00000000000..8b6a6d796f2
--- /dev/null
+++ 
b/pinot-spi/src/main/java/org/apache/pinot/spi/config/table/NullValueVectorConfig.java
@@ -0,0 +1,66 @@
+/**
+ * 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.spi.config.table;
+
+import com.fasterxml.jackson.annotation.JsonCreator;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import java.util.Objects;
+
+
+/// Configuration for the null value vector index of a column.
+///
+/// The `enabled` state is derived from null handling (schema column-based 
nullability or the table-level
+/// `nullHandlingEnabled` flag) rather than set directly, so users typically 
only configure `backfill`.
+///
+/// When `backfill` is `true`, segment reload generates a null value vector 
for the column if it has null handling
+/// enabled but no null value vector yet, by treating every value equal to the 
column's default null value as null. This
+/// is a lossy reconstruction (a genuine value equal to the default is also 
marked null), so it is per-column opt-in —
+/// enable it only for columns whose default null value is a sentinel that 
does not occur in the data (e.g. a
+/// dimension's `MIN_VALUE`), not for columns whose default is a value that 
legitimately occurs in the data — e.g.
+/// metrics (`0`), `BOOLEAN` (`false`), or `TIMESTAMP` (epoch `0`).
+public class NullValueVectorConfig extends IndexConfig {
+  private final boolean _backfill;
+
+  @JsonCreator
+  public NullValueVectorConfig(@JsonProperty("disabled") Boolean disabled,
+      @JsonProperty("backfill") boolean backfill) {
+    super(disabled);
+    _backfill = backfill;
+  }
+
+  public boolean isBackfill() {
+    return _backfill;
+  }
+
+  @Override
+  public boolean equals(Object o) {
+    if (this == o) {
+      return true;
+    }
+    if (!super.equals(o)) {
+      return false;
+    }
+    return _backfill == ((NullValueVectorConfig) o)._backfill;
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(super.hashCode(), _backfill);
+  }
+}


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


Reply via email to