You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@pinot.apache.org by xi...@apache.org on 2026/08/10 08:09:04 UTC

(pinot) branch master updated: Expose common segment metadata as built-in virtual columns (#19179)

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

xiangfu0 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 a523b7beae2 Expose common segment metadata as built-in virtual columns (#19179)
a523b7beae2 is described below

commit a523b7beae27a25c4e18f43ccac38921b3e894ce
Author: Xiang Fu <xi...@apache.org>
AuthorDate: Mon Aug 10 01:08:58 2026 -0700

    Expose common segment metadata as built-in virtual columns (#19179)
    
    * Expose common segment metadata as built-in virtual columns
    
    Adds five built-in virtual columns that surface segment metadata to
    queries, alongside the existing $docId / $hostName / $segmentName /
    $partitionId:
    
    | Column          | Type   | Value                                        |
    |-----------------|--------|----------------------------------------------|
    | $creationTime   | LONG   | segment creation time, epoch millis          |
    | $startTimeMs    | LONG   | segment time range start, epoch millis       |
    | $endTimeMs      | LONG   | segment time range end, epoch millis         |
    | $totalDocs      | INT    | documents stored in the segment              |
    | $segmentCrc     | STRING | segment CRC                                  |
    
    Example:
    
      SELECT $segmentName, $segmentCrc, $totalDocs, $creationTime
      FROM myTable GROUP BY 1, 2, 3, 4
    
    Each column is a constant single-value column within a segment. Values
    are read from SegmentMetadata every time the column is built rather than
    baked into the field spec, so mutable segments - which rebuild their
    virtual data sources on every access - always observe current metadata.
    
    Metadata that genuinely does not exist yet reads as SQL NULL rather than
    a sentinel: a CONSUMING segment has no time range and no CRC until it is
    committed. This required teaching the virtual column path to carry a null
    value vector - VirtualColumnIndexContainer now serves
    StandardIndexes.nullValueVector() and VirtualColumnProvider gained a
    buildNullValueVector hook (defaulting to no nulls, so existing providers
    are unaffected). Without it the placeholder stored in the forward index
    would be indistinguishable from a real value once null handling is on,
    e.g. MIN($startTimeMs) returning Long.MIN_VALUE on a consuming segment.
    
    $totalDocs counts the documents physically stored in the segment, so for
    an upsert table it also includes documents that have been replaced and
    are no longer returned by queries.
    
    Naming note: only the time range columns carry the "Ms" suffix.
    SegmentMetadata#getStartTime()/#getEndTime() return values in the time
    column's own unit, so unsuffixed names would be ambiguous; a creation
    time is always epoch millis throughout Pinot.
    
    Supporting changes:
    
    - New BuiltInVirtualColumns in pinot-spi is the single source of each
      column's name, data type and single-value/multi-value shape. The broker
      side (TableCache#addBuiltInVirtualColumns) and the server side
      (VirtualColumnProviderFactory#addBuiltInVirtualColumnsToSegmentSchema)
      now both build their field specs from it, so the two can no longer
      disagree on a type. Previously these specs were declared twice by hand.
    
    - DefaultNullValueVirtualColumnProvider grew an overridable
      getValue(context) so it can back any per-segment constant column, plus
      a type check that names the offending column and provider instead of
      throwing a bare ClassCastException from inside segment loading.
      Behavior is unchanged for existing callers.
    
    - Fixes a pre-existing bug in SchemaInfo, which computed
      getDimensionFieldSpecs().size() - 3 with a comment naming three virtual
      columns. That has been off by one since $partitionId was added, and
      these five would have made GET /schemas/info over-report user dimension
      counts by six in the controller UI. It now excludes built-in virtual
      columns by name.
    
    * Use TIMESTAMP for the segment time columns, and address review feedback
    
    Follow-up to the segment metadata virtual columns, covering the review
    comments and the CI failures on the first revision.
    
    Column types and names
    ----------------------
    The three time columns are now TIMESTAMP rather than LONG, so the unit is
    carried by the type instead of by the column name and query results render
    them as readable timestamps instead of raw millis. That removes the reason
    the "Ms" suffix existed, so the columns are back to their natural names:
    
      $creationTime  TIMESTAMP
      $startTime     TIMESTAMP
      $endTime       TIMESTAMP
      $totalDocs     INT
      $crc           STRING
    
    $segmentCrc is renamed to $crc so that the five names are consistent: none
    of them repeats the "segment" scope, which every one of them shares.
    
    Correctness
    -----------
    The segment metadata used to be read three separate times per build - once
    for the dictionary, once for the column metadata, and once for the null
    value vector - with nothing tying the three reads together. On a mutable
    segment, whose metadata is live, they could disagree and the column would
    then serve its placeholder as if it were a real value. The value is now
    resolved exactly once per build and shared by all three.
    
    VirtualColumnIndexContainer#close now also closes the null value vector.
    
    Structure
    ---------
    - The constant-value machinery moves out of DefaultNullValueVirtualColumnProvider
      into a new BaseConstantValueVirtualColumnProvider in the virtualcolumn
      package. DefaultNullValueVirtualColumnProvider becomes a thin subclass; its
      fully-qualified name is stored in field specs and resolved reflectively, so
      it keeps its name and package.
    - VirtualColumnProviderFactory resolves providers through a map whose key set
      is checked against the column definitions in a static initializer. A column
      added without a provider now fails at class load instead of aborting every
      segment load on every server.
    - BuiltInVirtualColumns is renamed to BuiltInVirtualColumnDefinitions: the old
      name differed from the pre-existing BuiltInVirtualColumn by a single trailing
      letter, and VirtualColumnProviderFactory imports both.
    
    Test fixes
    ----------
    Calcite plans reference fields positionally, and all $-prefixed columns sort
    first in a table's row type, so five new virtual columns shift every ordinal
    in an EXPLAIN assertion by five. Updated in NullHandlingIntegrationTest,
    MultiStageEngineExplainIntegrationTest and OfflineClusterIntegrationTest.
    MultiNodesOfflineClusterIntegrationTest inherits the latter's fixes.
    
    The aggregate metadata API reports one entry per column of a loaded segment,
    which includes the virtual columns, so its expected count grows from 83 to 88.
    
    Test coverage
    -------------
    - BuiltInVirtualColumnDefinitionsTest pins the definitions against the
      declared names, and pins that field specs are never shared between schemas.
    - A negative test covers the value type check.
    - The CONSUMING-segment queries in BaseClusterIntegrationTestSet now assert
      their results instead of only checking that nothing throws. On a hybrid
      table the time boundary can hide rows that are physically present in a
      segment, so the assertion is that a segment's $totalDocs is at least the
      number of rows the query returns from it.
    - OfflineClusterIntegrationTest additionally checks the millisecond
      normalization per segment, filtering by $crc, and that null handling sees
      no nulls on a table where all the metadata is available.
    
    * Address review: suppress min/max for NULL columns, expose $crc as LONG
    
    Fixes found by review on top of the segment metadata virtual columns.
    
    Do not publish min/max for a column that reads as NULL
    ------------------------------------------------------
    When the segment metadata is unavailable the column stores a placeholder
    and reports every document as null, but it was still publishing that
    placeholder as the column's min/max. Segment pruners read min/max without
    consulting the null value vector, so:
    
      SET enableNullHandling=true;
      SELECT $segmentName, $creationTime FROM myTable ORDER BY $creationTime ASC LIMIT 10
    
    let a CONSUMING segment - whose $creationTime placeholder is the epoch -
    sort first in SelectionQuerySegmentPruner, consume the whole LIMIT, and
    prune away every committed segment whose min was greater. The query then
    returned rows that are all SQL NULL, which under NULLS-LAST ordering should
    have sorted last. $crc had the mirror problem for ORDER BY ... DESC.
    
    The min/max are now left unset whenever the value is a placeholder. Both
    SelectionQuerySegmentPruner and ColumnValueSegmentPruner already keep a
    segment that reports no min/max, which is the correct conservative
    behavior for an all-null column.
    
    Resolve the metadata once per data source, not once per index
    -------------------------------------------------------------
    buildDataSource calls buildMetadata and buildColumnIndexContainer
    separately, so the column metadata came from a different read of
    SegmentMetadata than the dictionary and the null value vector. Only the
    container was internally consistent. BaseSegmentMetadataVirtualColumnProvider
    now overrides buildDataSource and resolves the value once for all three.
    
    Apply the value type check on the path that is actually taken
    ------------------------------------------------------------
    The check was only reachable through the no-argument buildDictionary /
    buildMetadata. Every one of the five new providers goes through the
    value-taking overloads, which skipped it, so the diagnostic it exists to
    give never applied to them. The check now runs on every path.
    
    $crc is a LONG
    --------------
    A CRC is a long everywhere else in Pinot - SegmentZKMetadata#getCrc returns
    one, and SegmentMetadata#getCrc merely renders that long as a String. Typing
    the column STRING to match the latter left users unable to write
    WHERE $crc = 12345, and contradicted the reasoning used for the time
    columns, which are TIMESTAMP precisely so the type carries the semantics.
    
    Other review fixes
    ------------------
    - AllNullValueVector was a byte-for-byte duplicate of a nested class in
      OpenStructNullDataSource. Both now share
      AllNullValueVectorReader in the readers package.
    - SchemaUtils.validate rejects a user column named after a built-in virtual
      column. Such a column is filtered out of the segment's physical columns
      when the metadata is read, and the virtual provider then takes over the
      name, so queries would silently return segment metadata instead of the
      user's data.
    - The built-in providers are stateless, so they are now shared instances
      rather than reflectively constructed once per virtual column per segment.
      Segment load did that 9 times per segment after this feature, up from 4.
    - Removed the static initializer asserting provider coverage. The factory is
      only ever loaded during segment load, so it did not fail any earlier than
      the per-column check, and it turned every subsequent load into a bare
      NoClassDefFoundError. The invariant is now asserted in a test.
    - Corrected the AllNullValueVectorReader publication comment:
      toImmutableRoaringBitmap() returns `this`, so safety rests on the volatile
      write alone and the returned bitmap must be treated as read-only.
    - Imported SegmentMetadata rather than naming it inline in Javadoc.
    
    Naming note: the reviewers proposed prefixing all five columns with
    "segment" ($segmentCreationTime, ...) to match $segmentName. The terse forms
    are a deliberate choice, recorded here so it is not re-litigated as an
    oversight.
---
 .../pinot/common/config/provider/TableCache.java   |  17 +-
 .../PinotQueryResourceStaticValidationTest.java    |   5 +-
 .../pinot/controller/helix/TableCacheTest.java     |  12 +-
 .../tests/BaseClusterIntegrationTestSet.java       |  40 ++
 .../MultiStageEngineExplainIntegrationTest.java    |   6 +-
 .../tests/NullHandlingIntegrationTest.java         |   8 +-
 .../tests/OfflineClusterIntegrationTest.java       |  82 ++++-
 .../DefaultNullValueVirtualColumnProvider.java     | 118 +-----
 .../index/openstruct/OpenStructNullDataSource.java |  29 +-
 .../index/readers/AllNullValueVectorReader.java    |  60 +++
 .../BaseConstantValueVirtualColumnProvider.java    | 220 +++++++++++
 .../BaseSegmentMetadataVirtualColumnProvider.java  |  97 +++++
 .../SegmentCrcVirtualColumnProvider.java           |  49 +++
 .../SegmentCreationTimeVirtualColumnProvider.java  |  38 ++
 .../SegmentEndTimeVirtualColumnProvider.java       |  38 ++
 .../SegmentStartTimeVirtualColumnProvider.java     |  38 ++
 .../SegmentTotalDocsVirtualColumnProvider.java     |  35 ++
 .../virtualcolumn/VirtualColumnIndexContainer.java |  18 +
 .../virtualcolumn/VirtualColumnProvider.java       |  14 +-
 .../VirtualColumnProviderFactory.java              |  86 ++++-
 .../pinot/segment/local/utils/SchemaUtils.java     |   7 +
 .../mutable/MutableSegmentImplRawMVTest.java       |  13 +-
 .../mutable/MutableSegmentImplTest.java            |  37 +-
 .../local/segment/index/loader/LoaderTest.java     |  38 +-
 .../SegmentMetadataVirtualColumnProviderTest.java  | 405 +++++++++++++++++++++
 .../spi/data/BuiltInVirtualColumnDefinitions.java  | 107 ++++++
 .../java/org/apache/pinot/spi/data/SchemaInfo.java |  13 +-
 .../apache/pinot/spi/utils/CommonConstants.java    |  31 +-
 .../data/BuiltInVirtualColumnDefinitionsTest.java  |  99 +++++
 .../org/apache/pinot/spi/data/SchemaInfoTest.java  |  24 ++
 30 files changed, 1590 insertions(+), 194 deletions(-)

diff --git a/pinot-common/src/main/java/org/apache/pinot/common/config/provider/TableCache.java b/pinot-common/src/main/java/org/apache/pinot/common/config/provider/TableCache.java
index 02c5e7cf313..2e62e80909d 100644
--- a/pinot-common/src/main/java/org/apache/pinot/common/config/provider/TableCache.java
+++ b/pinot-common/src/main/java/org/apache/pinot/common/config/provider/TableCache.java
@@ -31,11 +31,9 @@ import org.apache.pinot.spi.config.provider.SchemaChangeListener;
 import org.apache.pinot.spi.config.provider.TableConfigChangeListener;
 import org.apache.pinot.spi.config.table.QueryConfig;
 import org.apache.pinot.spi.config.table.TableConfig;
-import org.apache.pinot.spi.data.DimensionFieldSpec;
-import org.apache.pinot.spi.data.FieldSpec;
+import org.apache.pinot.spi.data.BuiltInVirtualColumnDefinitions;
 import org.apache.pinot.spi.data.LogicalTableConfig;
 import org.apache.pinot.spi.data.Schema;
-import org.apache.pinot.spi.utils.CommonConstants.Segment.BuiltInVirtualColumn;
 import org.apache.pinot.spi.utils.TimestampIndexUtils;
 import org.apache.pinot.sql.parsers.CalciteSqlParser;
 import org.slf4j.Logger;
@@ -119,18 +117,7 @@ public interface TableCache extends PinotConfigProvider {
   /// Adds the built-in virtual columns to the schema.
   /// NOTE: The virtual column provider class is not added.
   default void addBuiltInVirtualColumns(Schema schema) {
-    if (!schema.hasColumn(BuiltInVirtualColumn.DOCID)) {
-      schema.addField(new DimensionFieldSpec(BuiltInVirtualColumn.DOCID, FieldSpec.DataType.INT, true));
-    }
-    if (!schema.hasColumn(BuiltInVirtualColumn.HOSTNAME)) {
-      schema.addField(new DimensionFieldSpec(BuiltInVirtualColumn.HOSTNAME, FieldSpec.DataType.STRING, true));
-    }
-    if (!schema.hasColumn(BuiltInVirtualColumn.SEGMENTNAME)) {
-      schema.addField(new DimensionFieldSpec(BuiltInVirtualColumn.SEGMENTNAME, FieldSpec.DataType.STRING, true));
-    }
-    if (!schema.hasColumn(BuiltInVirtualColumn.PARTITIONID)) {
-      schema.addField(new DimensionFieldSpec(BuiltInVirtualColumn.PARTITIONID, FieldSpec.DataType.STRING, false));
-    }
+    BuiltInVirtualColumnDefinitions.addToSchema(schema);
   }
 
   static Map<Expression, Expression> createExpressionOverrideMap(String physicalOrLogicalTableName,
diff --git a/pinot-controller/src/test/java/org/apache/pinot/controller/api/resources/PinotQueryResourceStaticValidationTest.java b/pinot-controller/src/test/java/org/apache/pinot/controller/api/resources/PinotQueryResourceStaticValidationTest.java
index 45dde807c67..0bd7222cb1c 100644
--- a/pinot-controller/src/test/java/org/apache/pinot/controller/api/resources/PinotQueryResourceStaticValidationTest.java
+++ b/pinot-controller/src/test/java/org/apache/pinot/controller/api/resources/PinotQueryResourceStaticValidationTest.java
@@ -26,6 +26,7 @@ import org.apache.pinot.spi.config.table.TableConfig;
 import org.apache.pinot.spi.config.table.TableType;
 import org.apache.pinot.spi.data.FieldSpec;
 import org.apache.pinot.spi.data.Schema;
+import org.apache.pinot.spi.utils.CommonConstants;
 import org.apache.pinot.spi.utils.builder.TableConfigBuilder;
 import org.mockito.MockitoAnnotations;
 import org.testng.Assert;
@@ -62,7 +63,9 @@ public class PinotQueryResourceStaticValidationTest {
     Assert.assertNotNull(provider.getTableConfig("testTable_OFFLINE"));
     Assert.assertNotNull(provider.getSchema("testTable"));
     Assert.assertNotNull(provider.getColumnNameMap("testTable"));
-    Assert.assertEquals(provider.getColumnNameMap("testTable").size(), 6); // 2 columns + 4 built-in virtual columns
+    // 2 columns + all built-in virtual columns
+    Assert.assertEquals(provider.getColumnNameMap("testTable").size(),
+        2 + CommonConstants.Segment.BuiltInVirtualColumn.BUILT_IN_VIRTUAL_COLUMNS.size());
 
     Assert.assertTrue(provider.getTableNameMap().containsKey("testTable_OFFLINE"));
     Assert.assertTrue(provider.getTableNameMap().containsKey("testTable"));
diff --git a/pinot-controller/src/test/java/org/apache/pinot/controller/helix/TableCacheTest.java b/pinot-controller/src/test/java/org/apache/pinot/controller/helix/TableCacheTest.java
index 9eeff07c69f..ca1e4861512 100644
--- a/pinot-controller/src/test/java/org/apache/pinot/controller/helix/TableCacheTest.java
+++ b/pinot-controller/src/test/java/org/apache/pinot/controller/helix/TableCacheTest.java
@@ -303,6 +303,11 @@ public class TableCacheTest {
     expectedColumnMap.put(isCaseInsensitive ? "$hostname" : "$hostName", "$hostName");
     expectedColumnMap.put(isCaseInsensitive ? "$segmentname" : "$segmentName", "$segmentName");
     expectedColumnMap.put(isCaseInsensitive ? "$partitionid" : "$partitionId", "$partitionId");
+    expectedColumnMap.put(isCaseInsensitive ? "$creationtime" : "$creationTime", "$creationTime");
+    expectedColumnMap.put(isCaseInsensitive ? "$starttime" : "$startTime", "$startTime");
+    expectedColumnMap.put(isCaseInsensitive ? "$endtime" : "$endTime", "$endTime");
+    expectedColumnMap.put(isCaseInsensitive ? "$totaldocs" : "$totalDocs", "$totalDocs");
+    expectedColumnMap.put("$crc", "$crc");
     return expectedColumnMap;
   }
 
@@ -311,7 +316,12 @@ public class TableCacheTest {
         .addSingleValueDimension(BuiltInVirtualColumn.DOCID, DataType.INT)
         .addSingleValueDimension(BuiltInVirtualColumn.HOSTNAME, DataType.STRING)
         .addSingleValueDimension(BuiltInVirtualColumn.SEGMENTNAME, DataType.STRING)
-        .addMultiValueDimension(BuiltInVirtualColumn.PARTITIONID, DataType.STRING).build();
+        .addMultiValueDimension(BuiltInVirtualColumn.PARTITIONID, DataType.STRING)
+        .addSingleValueDimension(BuiltInVirtualColumn.CREATIONTIME, DataType.TIMESTAMP)
+        .addSingleValueDimension(BuiltInVirtualColumn.STARTTIME, DataType.TIMESTAMP)
+        .addSingleValueDimension(BuiltInVirtualColumn.ENDTIME, DataType.TIMESTAMP)
+        .addSingleValueDimension(BuiltInVirtualColumn.TOTALDOCS, DataType.INT)
+        .addSingleValueDimension(BuiltInVirtualColumn.CRC, DataType.LONG).build();
   }
 
   @DataProvider(name = "testTableCacheDataProvider")
diff --git a/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/BaseClusterIntegrationTestSet.java b/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/BaseClusterIntegrationTestSet.java
index 8a52029a2ab..d13a9723214 100644
--- a/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/BaseClusterIntegrationTestSet.java
+++ b/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/BaseClusterIntegrationTestSet.java
@@ -22,6 +22,7 @@ import java.io.BufferedReader;
 import java.io.IOException;
 import java.io.InputStream;
 import java.io.InputStreamReader;
+import java.sql.Timestamp;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.List;
@@ -465,6 +466,45 @@ public abstract class BaseClusterIntegrationTestSet extends BaseClusterIntegrati
         "select $docId, $segmentName, $hostName, $partitionId from mytable where $docId = 5 limit 50");
     getPinotConnection().execute(
         "select $docId, $segmentName, $hostName, $partitionId from mytable where $docId > 19998 limit 50");
+
+    // Segment metadata virtual columns. This method is the only place they are exercised against a table that can
+    // have CONSUMING segments, so assert the results rather than just checking that nothing throws.
+    long numTotalDocs = getCountStarResult();
+
+    // $totalDocs is the number of documents stored in the segment. On a hybrid table the broker's time boundary can
+    // hide some of them, so a segment's $totalDocs is at least the number of rows the query sees from it, never less.
+    ResultSet perSegment = getPinotConnection().execute(
+            "select $segmentName, max($totalDocs), count(*) from mytable group by $segmentName limit 10000")
+        .getResultSet(0);
+    assertTrue(perSegment.getRowCount() > 0);
+    long visibleRows = 0;
+    for (int i = 0; i < perSegment.getRowCount(); i++) {
+      String segmentName = perSegment.getString(i, 0);
+      // MAX()/COUNT() render as floating point values in the single-stage engine, so read them as doubles in both
+      long totalDocsInSegment = (long) Double.parseDouble(perSegment.getString(i, 1));
+      long rowsFromSegment = (long) Double.parseDouble(perSegment.getString(i, 2));
+      assertTrue(totalDocsInSegment > 0, "Unexpected $totalDocs: " + totalDocsInSegment + " for: " + segmentName);
+      assertTrue(totalDocsInSegment >= rowsFromSegment,
+          "$totalDocs: " + totalDocsInSegment + " is below the " + rowsFromSegment + " rows returned by: "
+              + segmentName);
+      visibleRows += rowsFromSegment;
+    }
+    assertEquals(visibleRows, numTotalDocs, "Grouping by $segmentName should cover every row exactly once");
+
+    // Every segment - CONSUMING included - is created with a creation time, so none of them falls back to the epoch
+    // placeholder used when the metadata is unavailable
+    ResultSet creationTimes = getPinotConnection()
+        .execute("select $segmentName, $creationTime from mytable group by $segmentName, $creationTime limit 10000")
+        .getResultSet(0);
+    assertTrue(creationTimes.getRowCount() > 0);
+    for (int i = 0; i < creationTimes.getRowCount(); i++) {
+      String creationTime = creationTimes.getString(i, 1);
+      assertTrue(Timestamp.valueOf(creationTime).getTime() > 0,
+          "Unexpected $creationTime: " + creationTime + " for segment: " + creationTimes.getString(i, 0));
+    }
+
+    // Selecting them must not fail on any segment type
+    getPinotConnection().execute("select $creationTime, $startTime, $endTime, $totalDocs, $crc from mytable limit 50");
   }
 
   /// Test queries from the query file.
diff --git a/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/MultiStageEngineExplainIntegrationTest.java b/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/MultiStageEngineExplainIntegrationTest.java
index 69d9861a5b4..1873fe8d1db 100644
--- a/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/MultiStageEngineExplainIntegrationTest.java
+++ b/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/MultiStageEngineExplainIntegrationTest.java
@@ -178,7 +178,7 @@ public class MultiStageEngineExplainIntegrationTest extends BaseClusterIntegrati
         "Execution Plan\n"
             + "PinotLogicalAggregate(group=[{}], agg#0=[SUM($0)], aggType=[FINAL])\n"
             + "  PinotLogicalExchange(distribution=[hash])\n"
-            + "    PinotLogicalAggregate(group=[{}], agg#0=[SUM($6)], aggType=[LEAF])\n"
+            + "    PinotLogicalAggregate(group=[{}], agg#0=[SUM($11)], aggType=[LEAF])\n"
             + "      PinotLogicalTableScan(table=[[default, mytable]])\n");
 
     // Enable PinotAggregateFunctionRewriteRule through query option, ensure it overrides the broker config
@@ -186,7 +186,7 @@ public class MultiStageEngineExplainIntegrationTest extends BaseClusterIntegrati
         "Execution Plan\n"
             + "PinotLogicalAggregate(group=[{}], agg#0=[SUMLONG($0)], aggType=[FINAL])\n"
             + "  PinotLogicalExchange(distribution=[hash])\n"
-            + "    PinotLogicalAggregate(group=[{}], agg#0=[SUMLONG($6)], aggType=[LEAF])\n"
+            + "    PinotLogicalAggregate(group=[{}], agg#0=[SUMLONG($11)], aggType=[LEAF])\n"
             + "      PinotLogicalTableScan(table=[[default, mytable]])\n",
         Map.of("usePlannerRules", CommonConstants.Broker.PlannerRuleNames.AGGREGATE_FUNCTION_REWRITE));
 
@@ -197,7 +197,7 @@ public class MultiStageEngineExplainIntegrationTest extends BaseClusterIntegrati
             + "LogicalProject(EXPR$0=[CASE(=($1, 0), null:BIGINT, $0)])\n"
             + "  PinotLogicalAggregate(group=[{}], agg#0=[SUMLONG($0)], agg#1=[COUNT($1)], aggType=[FINAL])\n"
             + "    PinotLogicalExchange(distribution=[hash])\n"
-            + "      PinotLogicalAggregate(group=[{}], agg#0=[SUMLONG($6)], agg#1=[COUNT()], aggType=[LEAF])\n"
+            + "      PinotLogicalAggregate(group=[{}], agg#0=[SUMLONG($11)], agg#1=[COUNT()], aggType=[LEAF])\n"
             + "        PinotLogicalTableScan(table=[[default, mytable]])\n",
         Map.of("usePlannerRules", CommonConstants.Broker.PlannerRuleNames.AGGREGATE_FUNCTION_REWRITE + ","
             + CommonConstants.Broker.PlannerRuleNames.AGGREGATE_REDUCE_FUNCTIONS));
diff --git a/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/NullHandlingIntegrationTest.java b/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/NullHandlingIntegrationTest.java
index 8388fc46722..c158d6913cf 100644
--- a/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/NullHandlingIntegrationTest.java
+++ b/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/NullHandlingIntegrationTest.java
@@ -434,7 +434,7 @@ public class NullHandlingIntegrationTest extends BaseClusterIntegrationTestSet
     explainLogical(query,
         "Execution Plan\n"
             + "LogicalProject(EXPR$0=[1])\n"
-            + "  LogicalFilter(condition=[AND(IS NOT NULL($8), <>($8, 0))])\n"
+            + "  LogicalFilter(condition=[AND(IS NOT NULL($13), <>($13, 0))])\n"
             + "    PinotLogicalTableScan(table=[[default, mytable]])\n",
         Map.of(CommonConstants.Broker.Request.QueryOptionKey.ENABLE_NULL_HANDLING, "false"));
   }
@@ -452,7 +452,7 @@ public class NullHandlingIntegrationTest extends BaseClusterIntegrationTestSet
     explainLogical(query,
         "Execution Plan\n"
             + "LogicalProject(EXPR$0=[1])\n"
-            + "  LogicalFilter(condition=[<>($8, 0)])\n"
+            + "  LogicalFilter(condition=[<>($13, 0)])\n"
             + "    PinotLogicalTableScan(table=[[default, mytable]])\n",
         Map.of(CommonConstants.Broker.Request.QueryOptionKey.ENABLE_NULL_HANDLING, "true"));
   }
@@ -470,7 +470,7 @@ public class NullHandlingIntegrationTest extends BaseClusterIntegrationTestSet
     explainLogical(query,
         "Execution Plan\n"
             + "LogicalProject(EXPR$0=[1])\n"
-            + "  LogicalFilter(condition=[AND(IS NULL($8), <>($8, 0))])\n"
+            + "  LogicalFilter(condition=[AND(IS NULL($13), <>($13, 0))])\n"
             + "    PinotLogicalTableScan(table=[[default, mytable]])\n",
         Map.of(CommonConstants.Broker.Request.QueryOptionKey.ENABLE_NULL_HANDLING, "false"));
   }
@@ -537,7 +537,7 @@ public class NullHandlingIntegrationTest extends BaseClusterIntegrationTestSet
               + "PinotLogicalAggregate(group=[{0}], agg#0=[COUNT($1)], agg#1=[COUNT($2)], aggType=[FINAL])\n"
               + "  PinotLogicalExchange(distribution=[hash[0]])\n"
               + "    PinotLogicalAggregate(group=[{0}], agg#0=[COUNT()], agg#1=[COUNT() FILTER $1], aggType=[LEAF])\n"
-              + "      LogicalProject(city=[$5], $f1=[IS TRUE(=($7, _UTF-8'unknown'))])\n"
+              + "      LogicalProject(city=[$10], $f1=[IS TRUE(=($12, _UTF-8'unknown'))])\n"
               + "        PinotLogicalTableScan(table=[[default, mytable]])\n");
       // IS_TRUE should be trimmed off, then the filter becomes always false in the server execution plan
       explainAskingServers(query,
diff --git a/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/OfflineClusterIntegrationTest.java b/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/OfflineClusterIntegrationTest.java
index 8984ca7027f..6872360a874 100644
--- a/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/OfflineClusterIntegrationTest.java
+++ b/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/OfflineClusterIntegrationTest.java
@@ -2449,6 +2449,78 @@ public class OfflineClusterIntegrationTest extends BaseClusterIntegrationTestSet
     assertEquals(response.get("numSegmentsMatched").asInt(), numSegmentsToQuery);
   }
 
+  @Test(dataProvider = "useBothQueryEngines")
+  public void testSegmentMetadataVirtualColumns(boolean useMultiStageQueryEngine)
+      throws Exception {
+    setUseMultiStageQueryEngine(useMultiStageQueryEngine);
+
+    String query = "SELECT $creationTime, $startTime, $endTime, $totalDocs, $crc FROM mytable LIMIT 10";
+    JsonNode response = postQuery(query);
+    JsonNode resultTable = response.get("resultTable");
+    JsonNode dataSchema = resultTable.get("dataSchema");
+    assertEquals(dataSchema.get("columnNames").toString(),
+        "[\"$creationTime\",\"$startTime\",\"$endTime\",\"$totalDocs\",\"$crc\"]");
+    // The three time columns are TIMESTAMP, so they render as formatted timestamps rather than raw millis
+    assertEquals(dataSchema.get("columnDataTypes").toString(),
+        "[\"TIMESTAMP\",\"TIMESTAMP\",\"TIMESTAMP\",\"INT\",\"LONG\"]");
+    JsonNode rows = resultTable.get("rows");
+    assertEquals(rows.size(), 10);
+    for (int i = 0; i < 10; i++) {
+      JsonNode row = rows.get(i);
+      long creationTime = Timestamp.valueOf(row.get(0).asText()).getTime();
+      assertTrue(creationTime > 0, "Unexpected segment creation time: " + row.get(0).asText());
+      long startTime = Timestamp.valueOf(row.get(1).asText()).getTime();
+      long endTime = Timestamp.valueOf(row.get(2).asText()).getTime();
+      assertTrue(startTime > 0, "Unexpected segment start time: " + row.get(1).asText());
+      assertTrue(startTime <= endTime, "Segment start time: " + startTime + " is after end time: " + endTime);
+      assertTrue(row.get(3).asInt() > 0, "Unexpected total docs: " + row.get(3).asInt());
+      // A real CRC, not the placeholder the column falls back to when the metadata is unavailable
+      long crc = row.get(4).asLong();
+      assertNotEquals(crc, (long) FieldSpec.DEFAULT_DIMENSION_NULL_VALUE_OF_LONG, "Segment CRC should be a real CRC");
+    }
+
+    // $totalDocs is constant within a segment, so the per-segment values must add up to the total number of rows
+    query = "SELECT $segmentName, MAX($totalDocs) FROM mytable GROUP BY $segmentName LIMIT 10000";
+    rows = postQuery(query).get("resultTable").get("rows");
+    long totalDocs = 0;
+    for (JsonNode row : rows) {
+      totalDocs += row.get(1).asLong();
+    }
+    assertEquals(totalDocs, getCountStarResult());
+
+    // $startTime/$endTime are normalized from the time column's own unit. mytable's time column is DaysSinceEpoch in
+    // DAYS, so each segment's values must be its own day boundaries expressed as timestamps - this is what
+    // distinguishes a correct implementation from one leaking the raw DAYS value.
+    query = "SELECT $segmentName, $startTime, $endTime, MIN(DaysSinceEpoch), MAX(DaysSinceEpoch) FROM mytable "
+        + "GROUP BY $segmentName, $startTime, $endTime LIMIT 10000";
+    rows = postQuery(query).get("resultTable").get("rows");
+    assertFalse(rows.isEmpty());
+    long daysToMillis = TimeUnit.DAYS.toMillis(1);
+    for (JsonNode row : rows) {
+      assertEquals(Timestamp.valueOf(row.get(1).asText()).getTime(), row.get(3).asLong() * daysToMillis,
+          "Unexpected $startTime for segment: " + row.get(0).asText());
+      assertEquals(Timestamp.valueOf(row.get(2).asText()).getTime(), row.get(4).asLong() * daysToMillis,
+          "Unexpected $endTime for segment: " + row.get(0).asText());
+    }
+
+    // Filtering on a single segment's CRC must return exactly the documents grouped under that CRC
+    query = "SELECT $crc, COUNT(*) FROM mytable GROUP BY $crc LIMIT 10000";
+    JsonNode crcRow = postQuery(query).get("resultTable").get("rows").get(0);
+    long segmentCrc = crcRow.get(0).asLong();
+    long docsForCrc = crcRow.get(1).asLong();
+    assertEquals(postQuery("SELECT COUNT(*) FROM mytable WHERE $crc = " + segmentCrc).get("resultTable")
+        .get("rows").get(0).get(0).asLong(), docsForCrc);
+
+    // With null handling on, the engine must consult the null value vector. Every segment of this table has a time
+    // range, a CRC and a creation time, so none of these columns may report a null.
+    for (String column : List.of("$creationTime", "$startTime", "$endTime", "$totalDocs", "$crc")) {
+      assertEquals(postQuery("SET enableNullHandling=true; SELECT COUNT(*) FROM mytable WHERE " + column
+          + " IS NOT NULL").get("resultTable").get("rows").get(0).get(0).asLong(), getCountStarResult(), column);
+      assertEquals(postQuery("SET enableNullHandling=true; SELECT COUNT(*) FROM mytable WHERE " + column
+          + " IS NULL").get("resultTable").get("rows").get(0).get(0).asLong(), 0, column);
+    }
+  }
+
   @Test(dataProvider = "useBothQueryEngines")
   public void testGroupByUDF(boolean useMultiStageQueryEngine)
       throws Exception {
@@ -3581,7 +3653,7 @@ public class OfflineClusterIntegrationTest extends BaseClusterIntegrationTestSet
         + "    LogicalProject(count=[$1], name=[$0])\n"
         + "      PinotLogicalAggregate(group=[{0}], agg#0=[COUNT($1)], aggType=[FINAL])\n"
         + "        PinotLogicalExchange(distribution=[hash[0]])\n"
-        + "          PinotLogicalAggregate(group=[{18}], agg#0=[COUNT()], aggType=[LEAF])\n"
+        + "          PinotLogicalAggregate(group=[{23}], agg#0=[COUNT()], aggType=[LEAF])\n"
         + "            PinotLogicalTableScan(table=[[default, mytable]])\n");
     assertEquals(response1Json.get("rows").get(0).get(2).asText(), "Rule Execution Times\n"
         + "Rule: SortRemove -> Time:*\n"
@@ -3870,22 +3942,22 @@ public class OfflineClusterIntegrationTest extends BaseClusterIntegrationTestSet
     JsonNode starColumnResponse = JsonUtils.objectToJsonNode(
         getOrCreateAdminClient().getTableClient().getAggregateMetadata(
             TableNameBuilder.OFFLINE.tableNameWithType(getTableName()), "*"));
-    validateMetadataResponse(starColumnResponse, 83, 10);
+    validateMetadataResponse(starColumnResponse, 88, 10);
 
     JsonNode starEncodedColumnResponse = JsonUtils.objectToJsonNode(
         getOrCreateAdminClient().getTableClient().getAggregateMetadata(
             TableNameBuilder.OFFLINE.tableNameWithType(getTableName()), "*"));
-    validateMetadataResponse(starEncodedColumnResponse, 83, 10);
+    validateMetadataResponse(starEncodedColumnResponse, 88, 10);
 
     JsonNode starWithExtraColumnResponse = JsonUtils.objectToJsonNode(
         getOrCreateAdminClient().getTableClient().getAggregateMetadata(
             TableNameBuilder.OFFLINE.tableNameWithType(getTableName()), "CRSElapsedTime,*,OriginStateName"));
-    validateMetadataResponse(starWithExtraColumnResponse, 83, 10);
+    validateMetadataResponse(starWithExtraColumnResponse, 88, 10);
 
     JsonNode starWithExtraEncodedColumnResponse = JsonUtils.objectToJsonNode(
         getOrCreateAdminClient().getTableClient().getAggregateMetadata(
             TableNameBuilder.OFFLINE.tableNameWithType(getTableName()), "CRSElapsedTime,*,OriginStateName"));
-    validateMetadataResponse(starWithExtraEncodedColumnResponse, 83, 10);
+    validateMetadataResponse(starWithExtraEncodedColumnResponse, 88, 10);
   }
 
   private void validateMetadataResponse(JsonNode response, int numTotalColumn, int numMVColumn) {
diff --git a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/index/column/DefaultNullValueVirtualColumnProvider.java b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/index/column/DefaultNullValueVirtualColumnProvider.java
index f8db37712bc..05ee8c774d7 100644
--- a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/index/column/DefaultNullValueVirtualColumnProvider.java
+++ b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/index/column/DefaultNullValueVirtualColumnProvider.java
@@ -18,116 +18,22 @@
  */
 package org.apache.pinot.segment.local.segment.index.column;
 
-import java.math.BigDecimal;
-import org.apache.pinot.segment.local.segment.index.readers.ConstantValueBigDecimalDictionary;
-import org.apache.pinot.segment.local.segment.index.readers.ConstantValueBytesDictionary;
-import org.apache.pinot.segment.local.segment.index.readers.ConstantValueDoubleDictionary;
-import org.apache.pinot.segment.local.segment.index.readers.ConstantValueFloatDictionary;
-import org.apache.pinot.segment.local.segment.index.readers.ConstantValueIntDictionary;
-import org.apache.pinot.segment.local.segment.index.readers.ConstantValueLongDictionary;
-import org.apache.pinot.segment.local.segment.index.readers.ConstantValueStringDictionary;
-import org.apache.pinot.segment.local.segment.index.readers.constant.ConstantMVForwardIndexReader;
-import org.apache.pinot.segment.local.segment.index.readers.constant.ConstantMVInvertedIndexReader;
-import org.apache.pinot.segment.local.segment.index.readers.constant.ConstantSortedIndexReader;
+import org.apache.pinot.segment.local.segment.virtualcolumn.BaseConstantValueVirtualColumnProvider;
 import org.apache.pinot.segment.local.segment.virtualcolumn.VirtualColumnContext;
-import org.apache.pinot.segment.local.segment.virtualcolumn.VirtualColumnProvider;
-import org.apache.pinot.segment.spi.index.metadata.ColumnMetadataImpl;
-import org.apache.pinot.segment.spi.index.reader.Dictionary;
-import org.apache.pinot.segment.spi.index.reader.ForwardIndexReader;
-import org.apache.pinot.segment.spi.index.reader.InvertedIndexReader;
-import org.apache.pinot.spi.data.FieldSpec;
-import org.apache.pinot.spi.utils.ByteArray;
 
 
-/// Provide the default null value.
-public class DefaultNullValueVirtualColumnProvider implements VirtualColumnProvider {
+/// Provides the column's default null value for every document.
+///
+/// This is also how `$hostName` and `$segmentName` are served: their value is constant for the segment and is carried
+/// as the field's default null value.
+///
+/// NOTE: The fully-qualified name of this class is stored in field specs (see
+/// `DimensionFieldSpec#DimensionFieldSpec(String, DataType, boolean, Class)`) and resolved reflectively, so it must
+/// not be moved or renamed.
+public class DefaultNullValueVirtualColumnProvider extends BaseConstantValueVirtualColumnProvider {
 
   @Override
-  public ForwardIndexReader<?> buildForwardIndex(VirtualColumnContext context) {
-    if (context.getFieldSpec().isSingleValueField()) {
-      return new ConstantSortedIndexReader(context.getTotalDocCount());
-    } else {
-      return new ConstantMVForwardIndexReader();
-    }
-  }
-
-  @Override
-  public Dictionary buildDictionary(VirtualColumnContext context) {
-    FieldSpec fieldSpec = context.getFieldSpec();
-    switch (fieldSpec.getDataType().getStoredType()) {
-      case INT:
-        return new ConstantValueIntDictionary((int) fieldSpec.getDefaultNullValue());
-      case LONG:
-        return new ConstantValueLongDictionary((long) fieldSpec.getDefaultNullValue());
-      case FLOAT:
-        return new ConstantValueFloatDictionary((float) fieldSpec.getDefaultNullValue());
-      case DOUBLE:
-        return new ConstantValueDoubleDictionary((double) fieldSpec.getDefaultNullValue());
-      case BIG_DECIMAL:
-        return new ConstantValueBigDecimalDictionary((BigDecimal) fieldSpec.getDefaultNullValue());
-      case STRING:
-        return new ConstantValueStringDictionary((String) fieldSpec.getDefaultNullValue());
-      case BYTES:
-      case UUID:
-        return new ConstantValueBytesDictionary((byte[]) fieldSpec.getDefaultNullValue());
-      default:
-        throw new IllegalStateException();
-    }
-  }
-
-  @Override
-  public InvertedIndexReader<?> buildInvertedIndex(VirtualColumnContext context) {
-    if (context.getFieldSpec().isSingleValueField()) {
-      return new ConstantSortedIndexReader(context.getTotalDocCount());
-    } else {
-      return new ConstantMVInvertedIndexReader(context.getTotalDocCount());
-    }
-  }
-
-  @Override
-  public ColumnMetadataImpl buildMetadata(VirtualColumnContext context) {
-    FieldSpec fieldSpec = context.getFieldSpec();
-    ColumnMetadataImpl.Builder builder = new ColumnMetadataImpl.Builder().setFieldSpec(fieldSpec)
-        .setTotalDocs(context.getTotalDocCount())
-        .setCardinality(1)
-        .setHasDictionary(true);
-    if (fieldSpec.isSingleValueField()) {
-      builder.setSorted(true);
-    } else {
-      // When there is no value for a multi-value column, the maxNumberOfMultiValues and cardinality should be
-      // set as 1 because the MV column bitmap uses 1 to delimit the rows for a MV column. Each MV column will have a
-      // default null value based on column's data type
-      builder.setMaxNumberOfMultiValues(1);
-    }
-
-    Object defaultNullValue = fieldSpec.getDefaultNullValue();
-    switch (fieldSpec.getDataType().getStoredType()) {
-      case INT:
-        builder.setMinValue((int) defaultNullValue).setMaxValue((int) defaultNullValue);
-        break;
-      case LONG:
-        builder.setMinValue((long) defaultNullValue).setMaxValue((long) defaultNullValue);
-        break;
-      case FLOAT:
-        builder.setMinValue((float) defaultNullValue).setMaxValue((float) defaultNullValue);
-        break;
-      case DOUBLE:
-        builder.setMinValue((double) defaultNullValue).setMaxValue((double) defaultNullValue);
-        break;
-      case BIG_DECIMAL:
-        builder.setMinValue((BigDecimal) defaultNullValue).setMaxValue((BigDecimal) defaultNullValue);
-        break;
-      case STRING:
-        builder.setMinValue((String) defaultNullValue).setMaxValue((String) defaultNullValue);
-        break;
-      case BYTES:
-        builder.setMinValue(new ByteArray((byte[]) defaultNullValue))
-            .setMaxValue(new ByteArray((byte[]) defaultNullValue));
-        break;
-      default:
-        throw new IllegalStateException();
-    }
-
-    return builder.build();
+  protected Object getValue(VirtualColumnContext context) {
+    return context.getFieldSpec().getDefaultNullValue();
   }
 }
diff --git a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/index/openstruct/OpenStructNullDataSource.java b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/index/openstruct/OpenStructNullDataSource.java
index be4ad585650..1b36dceff4d 100644
--- a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/index/openstruct/OpenStructNullDataSource.java
+++ b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/index/openstruct/OpenStructNullDataSource.java
@@ -23,19 +23,17 @@ import java.util.Map;
 import java.util.Set;
 import javax.annotation.Nullable;
 import org.apache.pinot.segment.local.segment.index.datasource.BaseDataSource;
+import org.apache.pinot.segment.local.segment.index.readers.AllNullValueVectorReader;
 import org.apache.pinot.segment.spi.datasource.DataSourceMetadata;
 import org.apache.pinot.segment.spi.datasource.OpenStructDataSource;
 import org.apache.pinot.segment.spi.index.StandardIndexes;
 import org.apache.pinot.segment.spi.index.column.ColumnIndexContainer;
 import org.apache.pinot.segment.spi.index.reader.ForwardIndexReader;
 import org.apache.pinot.segment.spi.index.reader.ForwardIndexReaderContext;
-import org.apache.pinot.segment.spi.index.reader.NullValueVectorReader;
 import org.apache.pinot.segment.spi.partition.PartitionFunction;
 import org.apache.pinot.spi.data.DimensionFieldSpec;
 import org.apache.pinot.spi.data.FieldSpec;
 import org.apache.pinot.spi.data.FieldSpec.DataType;
-import org.roaringbitmap.buffer.ImmutableRoaringBitmap;
-import org.roaringbitmap.buffer.MutableRoaringBitmap;
 
 
 /// Typed all-null {@link org.apache.pinot.segment.spi.datasource.DataSource} for an OPEN_STRUCT key
@@ -54,7 +52,7 @@ public class OpenStructNullDataSource extends BaseDataSource {
   public OpenStructNullDataSource(FieldSpec fieldSpec, int numDocs) {
     super(new AllNullMetadata(fieldSpec, numDocs), new ColumnIndexContainer.FromMap(Map.of(
         StandardIndexes.forward(), new TypedNullForwardIndex(fieldSpec.getDataType().getStoredType()),
-        StandardIndexes.nullValueVector(), new AllNullValueVector(numDocs))));
+        StandardIndexes.nullValueVector(), new AllNullValueVectorReader(numDocs))));
   }
 
   /// Creates an all-null DataSource for a key absent from this OPEN_STRUCT segment.
@@ -131,29 +129,6 @@ public class OpenStructNullDataSource extends BaseDataSource {
     }
   }
 
-  /// {@link NullValueVectorReader} where every document is null.
-  static class AllNullValueVector implements NullValueVectorReader {
-    private final ImmutableRoaringBitmap _nullBitmap;
-
-    AllNullValueVector(int numDocs) {
-      MutableRoaringBitmap bm = new MutableRoaringBitmap();
-      if (numDocs > 0) {
-        bm.add(0L, numDocs);
-      }
-      _nullBitmap = bm.toImmutableRoaringBitmap();
-    }
-
-    @Override
-    public boolean isNull(int docId) {
-      return true;
-    }
-
-    @Override
-    public ImmutableRoaringBitmap getNullBitmap() {
-      return _nullBitmap;
-    }
-  }
-
   private static class AllNullMetadata implements DataSourceMetadata {
     private final FieldSpec _fieldSpec;
     private final int _numDocs;
diff --git a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/index/readers/AllNullValueVectorReader.java b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/index/readers/AllNullValueVectorReader.java
new file mode 100644
index 00000000000..5c8d6a50e06
--- /dev/null
+++ b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/index/readers/AllNullValueVectorReader.java
@@ -0,0 +1,60 @@
+/**
+ * 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.readers;
+
+import org.apache.pinot.segment.spi.index.reader.NullValueVectorReader;
+import org.roaringbitmap.buffer.ImmutableRoaringBitmap;
+import org.roaringbitmap.buffer.MutableRoaringBitmap;
+
+
+/// [NullValueVectorReader] where every document of the column is null.
+///
+/// Used by columns that carry no value at all: a materialized-but-absent OPEN_STRUCT child, and the segment metadata
+/// virtual columns whose metadata is not available yet (e.g. the time range of a CONSUMING segment).
+///
+/// The bitmap is materialized lazily, because null value vectors are only consulted when null handling is enabled
+/// while the reader itself is created on every data source build for a mutable segment.
+public class AllNullValueVectorReader implements NullValueVectorReader {
+  private final int _numDocs;
+  private volatile ImmutableRoaringBitmap _nullBitmap;
+
+  public AllNullValueVectorReader(int numDocs) {
+    _numDocs = numDocs;
+  }
+
+  @Override
+  public boolean isNull(int docId) {
+    return true;
+  }
+
+  @Override
+  public ImmutableRoaringBitmap getNullBitmap() {
+    ImmutableRoaringBitmap nullBitmap = _nullBitmap;
+    if (nullBitmap == null) {
+      // Benign race: concurrent callers may each build an equivalent bitmap, and publication is safe because the
+      // volatile write happens-after the bitmap is fully built. NOTE: toImmutableRoaringBitmap() returns `this`, so
+      // the returned instance is shared and must be treated as read-only by callers.
+      MutableRoaringBitmap bitmap = _numDocs > 0 ? MutableRoaringBitmap.bitmapOfRange(0, _numDocs)
+          : new MutableRoaringBitmap();
+      nullBitmap = bitmap.toImmutableRoaringBitmap();
+      _nullBitmap = nullBitmap;
+    }
+    return nullBitmap;
+  }
+}
diff --git a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/virtualcolumn/BaseConstantValueVirtualColumnProvider.java b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/virtualcolumn/BaseConstantValueVirtualColumnProvider.java
new file mode 100644
index 00000000000..d4d7c9fcc10
--- /dev/null
+++ b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/virtualcolumn/BaseConstantValueVirtualColumnProvider.java
@@ -0,0 +1,220 @@
+/**
+ * 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.virtualcolumn;
+
+import com.google.common.base.Preconditions;
+import java.math.BigDecimal;
+import javax.annotation.Nullable;
+import org.apache.pinot.segment.local.segment.index.readers.ConstantValueBigDecimalDictionary;
+import org.apache.pinot.segment.local.segment.index.readers.ConstantValueBytesDictionary;
+import org.apache.pinot.segment.local.segment.index.readers.ConstantValueDoubleDictionary;
+import org.apache.pinot.segment.local.segment.index.readers.ConstantValueFloatDictionary;
+import org.apache.pinot.segment.local.segment.index.readers.ConstantValueIntDictionary;
+import org.apache.pinot.segment.local.segment.index.readers.ConstantValueLongDictionary;
+import org.apache.pinot.segment.local.segment.index.readers.ConstantValueStringDictionary;
+import org.apache.pinot.segment.local.segment.index.readers.constant.ConstantMVForwardIndexReader;
+import org.apache.pinot.segment.local.segment.index.readers.constant.ConstantMVInvertedIndexReader;
+import org.apache.pinot.segment.local.segment.index.readers.constant.ConstantSortedIndexReader;
+import org.apache.pinot.segment.spi.index.column.ColumnIndexContainer;
+import org.apache.pinot.segment.spi.index.metadata.ColumnMetadataImpl;
+import org.apache.pinot.segment.spi.index.reader.Dictionary;
+import org.apache.pinot.segment.spi.index.reader.ForwardIndexReader;
+import org.apache.pinot.segment.spi.index.reader.InvertedIndexReader;
+import org.apache.pinot.segment.spi.index.reader.NullValueVectorReader;
+import org.apache.pinot.spi.data.FieldSpec;
+import org.apache.pinot.spi.utils.ByteArray;
+
+
+/// Base for virtual columns holding a single constant value for every document of a segment.
+///
+/// Subclasses supply the value by overriding [#getValue(VirtualColumnContext)]; everything else - a constant sorted
+/// forward index, a single-entry dictionary and matching [ColumnMetadataImpl] - is derived from it here.
+public abstract class BaseConstantValueVirtualColumnProvider implements VirtualColumnProvider {
+
+  /// Returns the constant value to be stored for every document of the column. The returned object must match the
+  /// stored type of the field spec (e.g. a `Long` for a LONG or TIMESTAMP column).
+  protected abstract Object getValue(VirtualColumnContext context);
+
+  /// Reads the value via [#getValue(VirtualColumnContext)] and checks it against the field's stored type, so that a
+  /// provider returning the wrong box type fails with the offending column and provider named rather than with a bare
+  /// `ClassCastException` from deep inside segment loading.
+  private Object getCheckedValue(VirtualColumnContext context) {
+    return checkValue(context, getValue(context));
+  }
+
+  /// Checks an already-resolved value against the field's stored type. Every path that turns a value into an index
+  /// goes through here, including the one taken by subclasses that resolve the value themselves.
+  private Object checkValue(VirtualColumnContext context, Object value) {
+    FieldSpec fieldSpec = context.getFieldSpec();
+    Class<?> expectedClass = getValueClass(fieldSpec);
+    Preconditions.checkState(expectedClass.isInstance(value),
+        "Virtual column provider: %s returned value of type: %s for column: %s, expecting: %s", getClass().getName(),
+        value != null ? value.getClass().getName() : "null", fieldSpec.getName(), expectedClass.getName());
+    return value;
+  }
+
+  private static Class<?> getValueClass(FieldSpec fieldSpec) {
+    switch (fieldSpec.getDataType().getStoredType()) {
+      case INT:
+        return Integer.class;
+      case LONG:
+        return Long.class;
+      case FLOAT:
+        return Float.class;
+      case DOUBLE:
+        return Double.class;
+      case BIG_DECIMAL:
+        return BigDecimal.class;
+      case STRING:
+        return String.class;
+      case BYTES:
+      case UUID:
+        return byte[].class;
+      default:
+        throw new IllegalStateException(unsupportedStoredType(fieldSpec));
+    }
+  }
+
+  private static String unsupportedStoredType(FieldSpec fieldSpec) {
+    return "Unsupported stored type: " + fieldSpec.getDataType().getStoredType() + " for virtual column: "
+        + fieldSpec.getName();
+  }
+
+  @Override
+  public ForwardIndexReader<?> buildForwardIndex(VirtualColumnContext context) {
+    if (context.getFieldSpec().isSingleValueField()) {
+      return new ConstantSortedIndexReader(context.getTotalDocCount());
+    } else {
+      return new ConstantMVForwardIndexReader();
+    }
+  }
+
+  @Override
+  public Dictionary buildDictionary(VirtualColumnContext context) {
+    return buildDictionary(context, getCheckedValue(context));
+  }
+
+  /// Builds the dictionary from an already-resolved value, so that callers which need the value more than once can
+  /// resolve it a single time.
+  private Dictionary buildDictionary(VirtualColumnContext context, Object value) {
+    FieldSpec fieldSpec = context.getFieldSpec();
+    checkValue(context, value);
+    switch (fieldSpec.getDataType().getStoredType()) {
+      case INT:
+        return new ConstantValueIntDictionary((int) value);
+      case LONG:
+        return new ConstantValueLongDictionary((long) value);
+      case FLOAT:
+        return new ConstantValueFloatDictionary((float) value);
+      case DOUBLE:
+        return new ConstantValueDoubleDictionary((double) value);
+      case BIG_DECIMAL:
+        return new ConstantValueBigDecimalDictionary((BigDecimal) value);
+      case STRING:
+        return new ConstantValueStringDictionary((String) value);
+      case BYTES:
+      case UUID:
+        return new ConstantValueBytesDictionary((byte[]) value);
+      default:
+        throw new IllegalStateException(unsupportedStoredType(fieldSpec));
+    }
+  }
+
+  @Override
+  public InvertedIndexReader<?> buildInvertedIndex(VirtualColumnContext context) {
+    if (context.getFieldSpec().isSingleValueField()) {
+      return new ConstantSortedIndexReader(context.getTotalDocCount());
+    } else {
+      return new ConstantMVInvertedIndexReader(context.getTotalDocCount());
+    }
+  }
+
+  @Override
+  public ColumnMetadataImpl buildMetadata(VirtualColumnContext context) {
+    return buildMetadata(context, getCheckedValue(context));
+  }
+
+  /// Builds the column metadata from an already-resolved value.
+  protected final ColumnMetadataImpl buildMetadata(VirtualColumnContext context, Object value) {
+    return buildMetadata(context, value, true);
+  }
+
+  /// Builds the column metadata from an already-resolved value.
+  ///
+  /// @param hasValue `false` when `value` is only a placeholder standing in for a value that is not available, in
+  ///                 which case the min/max are left unset. Segment pruners read min/max without consulting the null
+  ///                 value vector, so publishing the placeholder there would let an all-null column prune or reorder
+  ///                 segments as if it held a real extreme value.
+  protected final ColumnMetadataImpl buildMetadata(VirtualColumnContext context, Object value, boolean hasValue) {
+    FieldSpec fieldSpec = context.getFieldSpec();
+    checkValue(context, value);
+    ColumnMetadataImpl.Builder builder = new ColumnMetadataImpl.Builder().setFieldSpec(fieldSpec)
+        .setTotalDocs(context.getTotalDocCount())
+        .setCardinality(1)
+        .setHasDictionary(true);
+    if (fieldSpec.isSingleValueField()) {
+      builder.setSorted(true);
+    } else {
+      // When there is no value for a multi-value column, the maxNumberOfMultiValues and cardinality should be
+      // set as 1 because the MV column bitmap uses 1 to delimit the rows for a MV column. Each MV column will have a
+      // default null value based on column's data type
+      builder.setMaxNumberOfMultiValues(1);
+    }
+
+    if (!hasValue) {
+      return builder.build();
+    }
+
+    switch (fieldSpec.getDataType().getStoredType()) {
+      case INT:
+        builder.setMinValue((int) value).setMaxValue((int) value);
+        break;
+      case LONG:
+        builder.setMinValue((long) value).setMaxValue((long) value);
+        break;
+      case FLOAT:
+        builder.setMinValue((float) value).setMaxValue((float) value);
+        break;
+      case DOUBLE:
+        builder.setMinValue((double) value).setMaxValue((double) value);
+        break;
+      case BIG_DECIMAL:
+        builder.setMinValue((BigDecimal) value).setMaxValue((BigDecimal) value);
+        break;
+      case STRING:
+        builder.setMinValue((String) value).setMaxValue((String) value);
+        break;
+      case BYTES:
+        builder.setMinValue(new ByteArray((byte[]) value)).setMaxValue(new ByteArray((byte[]) value));
+        break;
+      default:
+        throw new IllegalStateException(unsupportedStoredType(fieldSpec));
+    }
+
+    return builder.build();
+  }
+
+  /// Builds the column index container from an already-resolved value, so that a subclass whose value comes from
+  /// mutable state can resolve it a single time and keep every component consistent.
+  protected final ColumnIndexContainer buildColumnIndexContainer(VirtualColumnContext context, Object value,
+      @Nullable NullValueVectorReader nullValueVector) {
+    return new VirtualColumnIndexContainer(buildForwardIndex(context), buildInvertedIndex(context),
+        buildDictionary(context, value), nullValueVector);
+  }
+}
diff --git a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/virtualcolumn/BaseSegmentMetadataVirtualColumnProvider.java b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/virtualcolumn/BaseSegmentMetadataVirtualColumnProvider.java
new file mode 100644
index 00000000000..9e28ba5c836
--- /dev/null
+++ b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/virtualcolumn/BaseSegmentMetadataVirtualColumnProvider.java
@@ -0,0 +1,97 @@
+/**
+ * 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.virtualcolumn;
+
+import javax.annotation.Nullable;
+import org.apache.pinot.segment.local.segment.index.datasource.ImmutableDataSource;
+import org.apache.pinot.segment.local.segment.index.readers.AllNullValueVectorReader;
+import org.apache.pinot.segment.spi.SegmentMetadata;
+import org.apache.pinot.segment.spi.datasource.DataSource;
+import org.apache.pinot.segment.spi.index.column.ColumnIndexContainer;
+import org.apache.pinot.segment.spi.index.metadata.ColumnMetadataImpl;
+import org.apache.pinot.segment.spi.index.reader.NullValueVectorReader;
+
+
+/// Base class for the built-in virtual columns that expose a piece of the segment metadata (creation time, time range,
+/// CRC, etc.) as a constant single-value column.
+///
+/// The value is read from [SegmentMetadata] when the column is built rather than baked into the field spec, so that a
+/// mutable segment - which rebuilds its virtual data sources on every access - picks up metadata that was not yet
+/// available when the segment was created. Within a single build the value is resolved exactly once (see
+/// [BaseConstantValueVirtualColumnProvider#buildColumnIndexContainer]), so the dictionary and the null value vector
+/// can never disagree about whether the column has a value.
+///
+/// When the metadata is not available at all, or the specific piece of metadata is not set (e.g. the time range of a
+/// CONSUMING segment), the column stores the default null value of its data type *and* reports every document as null,
+/// so that the placeholder is not mistaken for a real value once null handling is enabled.
+public abstract class BaseSegmentMetadataVirtualColumnProvider extends BaseConstantValueVirtualColumnProvider {
+
+  @Override
+  protected Object getValue(VirtualColumnContext context) {
+    return valueOrPlaceholder(context, extractValueOrNull(context));
+  }
+
+  @Nullable
+  @Override
+  public NullValueVectorReader buildNullValueVector(VirtualColumnContext context) {
+    return extractValueOrNull(context) == null ? new AllNullValueVectorReader(context.getTotalDocCount()) : null;
+  }
+
+  @Override
+  public ColumnIndexContainer buildColumnIndexContainer(VirtualColumnContext context) {
+    return buildColumnIndexContainer(context, extractValueOrNull(context));
+  }
+
+  @Override
+  public ColumnMetadataImpl buildMetadata(VirtualColumnContext context) {
+    Object extracted = extractValueOrNull(context);
+    return buildMetadata(context, valueOrPlaceholder(context, extracted), extracted != null);
+  }
+
+  @Override
+  public DataSource buildDataSource(VirtualColumnContext context) {
+    // Resolve the metadata exactly once for the whole data source, so the dictionary, the column metadata's min/max
+    // and the null value vector all agree about whether this column has a value. buildDataSource is the path a
+    // mutable segment takes, and it rebuilds its virtual data sources on every access.
+    Object extracted = extractValueOrNull(context);
+    return new ImmutableDataSource(buildMetadata(context, valueOrPlaceholder(context, extracted), extracted != null),
+        buildColumnIndexContainer(context, extracted));
+  }
+
+  private ColumnIndexContainer buildColumnIndexContainer(VirtualColumnContext context, @Nullable Object extracted) {
+    return buildColumnIndexContainer(context, valueOrPlaceholder(context, extracted),
+        extracted == null ? new AllNullValueVectorReader(context.getTotalDocCount()) : null);
+  }
+
+  /// Falls back to the column's default null value, which is only ever a placeholder: whenever it is used, the column
+  /// also reports every document as null.
+  private static Object valueOrPlaceholder(VirtualColumnContext context, @Nullable Object extracted) {
+    return extracted != null ? extracted : context.getFieldSpec().getDefaultNullValue();
+  }
+
+  @Nullable
+  private Object extractValueOrNull(VirtualColumnContext context) {
+    SegmentMetadata segmentMetadata = context.getSegmentMetadata();
+    return segmentMetadata != null ? extractValue(segmentMetadata) : null;
+  }
+
+  /// Extracts the value for this column from the given segment metadata, or `null` when it is not available.
+  @Nullable
+  protected abstract Object extractValue(SegmentMetadata segmentMetadata);
+}
diff --git a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/virtualcolumn/SegmentCrcVirtualColumnProvider.java b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/virtualcolumn/SegmentCrcVirtualColumnProvider.java
new file mode 100644
index 00000000000..d64d0683a61
--- /dev/null
+++ b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/virtualcolumn/SegmentCrcVirtualColumnProvider.java
@@ -0,0 +1,49 @@
+/**
+ * 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.virtualcolumn;
+
+import javax.annotation.Nullable;
+import org.apache.pinot.segment.spi.SegmentMetadata;
+
+
+/// Virtual column provider for `$crc`, the CRC of the segment.
+///
+/// The CRC is exposed as a LONG, matching how it is stored everywhere else in Pinot ([SegmentMetadata#getCrc()]
+/// renders the same `long` as a String). It reads as NULL for CONSUMING segments, which have no CRC until they are
+/// committed. Grouping by `$segmentName` and `$crc` is a convenient way to detect replicas of a segment that have
+/// diverged.
+public class SegmentCrcVirtualColumnProvider extends BaseSegmentMetadataVirtualColumnProvider {
+  @Nullable
+  @Override
+  protected Object extractValue(SegmentMetadata segmentMetadata) {
+    String crc = segmentMetadata.getCrc();
+    if (crc == null) {
+      return null;
+    }
+    long crcValue;
+    try {
+      crcValue = Long.parseLong(crc);
+    } catch (NumberFormatException e) {
+      // The CRC is always rendered from a long, but never fail a segment load over an unreadable one
+      return null;
+    }
+    // SegmentMetadataImpl renders an unset CRC as Long.MIN_VALUE
+    return crcValue != Long.MIN_VALUE ? crcValue : null;
+  }
+}
diff --git a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/virtualcolumn/SegmentCreationTimeVirtualColumnProvider.java b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/virtualcolumn/SegmentCreationTimeVirtualColumnProvider.java
new file mode 100644
index 00000000000..3d564876ab3
--- /dev/null
+++ b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/virtualcolumn/SegmentCreationTimeVirtualColumnProvider.java
@@ -0,0 +1,38 @@
+/**
+ * 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.virtualcolumn;
+
+import javax.annotation.Nullable;
+import org.apache.pinot.segment.spi.SegmentMetadata;
+
+
+/// Virtual column provider for `$creationTime`, the time the segment was created.
+///
+/// For a CONSUMING segment this is the time the consuming segment was created, not the time it was committed.
+public class SegmentCreationTimeVirtualColumnProvider extends BaseSegmentMetadataVirtualColumnProvider {
+
+  @Nullable
+  @Override
+  protected Object extractValue(SegmentMetadata segmentMetadata) {
+    // An unset creation time is represented as Long.MIN_VALUE in the segment metadata and as -1 in the segment ZK
+    // metadata a CONSUMING segment is created from, so treat any non-positive value as unavailable.
+    long creationTime = segmentMetadata.getIndexCreationTime();
+    return creationTime > 0 ? creationTime : null;
+  }
+}
diff --git a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/virtualcolumn/SegmentEndTimeVirtualColumnProvider.java b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/virtualcolumn/SegmentEndTimeVirtualColumnProvider.java
new file mode 100644
index 00000000000..ee9d79fe671
--- /dev/null
+++ b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/virtualcolumn/SegmentEndTimeVirtualColumnProvider.java
@@ -0,0 +1,38 @@
+/**
+ * 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.virtualcolumn;
+
+import javax.annotation.Nullable;
+import org.apache.pinot.segment.spi.SegmentMetadata;
+import org.joda.time.Interval;
+
+
+/// Virtual column provider for `$endTime`, the end of the segment time range.
+///
+/// The value is normalized from the time unit of the segment's time column, and exposed as a TIMESTAMP. It reads as
+/// NULL for segments without a time range, such as CONSUMING segments and segments of tables without a time column.
+public class SegmentEndTimeVirtualColumnProvider extends BaseSegmentMetadataVirtualColumnProvider {
+
+  @Nullable
+  @Override
+  protected Object extractValue(SegmentMetadata segmentMetadata) {
+    Interval timeInterval = segmentMetadata.getTimeInterval();
+    return timeInterval != null ? timeInterval.getEndMillis() : null;
+  }
+}
diff --git a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/virtualcolumn/SegmentStartTimeVirtualColumnProvider.java b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/virtualcolumn/SegmentStartTimeVirtualColumnProvider.java
new file mode 100644
index 00000000000..50600c974a7
--- /dev/null
+++ b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/virtualcolumn/SegmentStartTimeVirtualColumnProvider.java
@@ -0,0 +1,38 @@
+/**
+ * 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.virtualcolumn;
+
+import javax.annotation.Nullable;
+import org.apache.pinot.segment.spi.SegmentMetadata;
+import org.joda.time.Interval;
+
+
+/// Virtual column provider for `$startTime`, the start of the segment time range.
+///
+/// The value is normalized from the time unit of the segment's time column, and exposed as a TIMESTAMP. It reads as
+/// NULL for segments without a time range, such as CONSUMING segments and segments of tables without a time column.
+public class SegmentStartTimeVirtualColumnProvider extends BaseSegmentMetadataVirtualColumnProvider {
+
+  @Nullable
+  @Override
+  protected Object extractValue(SegmentMetadata segmentMetadata) {
+    Interval timeInterval = segmentMetadata.getTimeInterval();
+    return timeInterval != null ? timeInterval.getStartMillis() : null;
+  }
+}
diff --git a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/virtualcolumn/SegmentTotalDocsVirtualColumnProvider.java b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/virtualcolumn/SegmentTotalDocsVirtualColumnProvider.java
new file mode 100644
index 00000000000..2b44503a2ba
--- /dev/null
+++ b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/virtualcolumn/SegmentTotalDocsVirtualColumnProvider.java
@@ -0,0 +1,35 @@
+/**
+ * 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.virtualcolumn;
+
+
+/// Virtual column provider for `$totalDocs`, the number of documents in the segment.
+///
+/// The count is taken from the virtual column context rather than from `SegmentMetadata`,
+/// so that a CONSUMING segment reports the number of documents indexed so far instead of `0`.
+///
+/// This is the number of documents physically stored in the segment, so for an upsert table it also includes the
+/// documents that have been replaced and are no longer returned by queries.
+public class SegmentTotalDocsVirtualColumnProvider extends BaseConstantValueVirtualColumnProvider {
+
+  @Override
+  protected Object getValue(VirtualColumnContext context) {
+    return context.getTotalDocCount();
+  }
+}
diff --git a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/virtualcolumn/VirtualColumnIndexContainer.java b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/virtualcolumn/VirtualColumnIndexContainer.java
index d64f7291269..fc6d40beff0 100644
--- a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/virtualcolumn/VirtualColumnIndexContainer.java
+++ b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/virtualcolumn/VirtualColumnIndexContainer.java
@@ -27,6 +27,7 @@ import org.apache.pinot.segment.spi.index.column.ColumnIndexContainer;
 import org.apache.pinot.segment.spi.index.reader.Dictionary;
 import org.apache.pinot.segment.spi.index.reader.ForwardIndexReader;
 import org.apache.pinot.segment.spi.index.reader.InvertedIndexReader;
+import org.apache.pinot.segment.spi.index.reader.NullValueVectorReader;
 
 
 /// Column index container for virtual columns.
@@ -34,12 +35,23 @@ public class VirtualColumnIndexContainer implements ColumnIndexContainer {
   private final ForwardIndexReader<?> _forwardIndex;
   private final InvertedIndexReader<?> _invertedIndex;
   private final Dictionary _dictionary;
+  private final NullValueVectorReader _nullValueVector;
 
   public VirtualColumnIndexContainer(ForwardIndexReader<?> forwardIndex, InvertedIndexReader<?> invertedIndex,
       Dictionary dictionary) {
+    this(forwardIndex, invertedIndex, dictionary, null);
+  }
+
+  /// @param nullValueVector marks which documents of the virtual column are null, or `null` when the column has no
+  ///                        null value. A virtual column whose value is genuinely unavailable (e.g. the time range of
+  ///                        a CONSUMING segment) must supply one, otherwise the engine treats the placeholder value
+  ///                        stored in the forward index as a real value.
+  public VirtualColumnIndexContainer(ForwardIndexReader<?> forwardIndex, InvertedIndexReader<?> invertedIndex,
+      Dictionary dictionary, @Nullable NullValueVectorReader nullValueVector) {
     _forwardIndex = forwardIndex;
     _invertedIndex = invertedIndex;
     _dictionary = dictionary;
+    _nullValueVector = nullValueVector;
   }
 
   @Nullable
@@ -54,6 +66,9 @@ public class VirtualColumnIndexContainer implements ColumnIndexContainer {
     if (indexType.equals(StandardIndexes.dictionary())) {
       return (I) _dictionary;
     }
+    if (indexType.equals(StandardIndexes.nullValueVector())) {
+      return (I) _nullValueVector;
+    }
     return null;
   }
 
@@ -67,5 +82,8 @@ public class VirtualColumnIndexContainer implements ColumnIndexContainer {
     if (_dictionary != null) {
       _dictionary.close();
     }
+    if (_nullValueVector != null) {
+      _nullValueVector.close();
+    }
   }
 }
diff --git a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/virtualcolumn/VirtualColumnProvider.java b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/virtualcolumn/VirtualColumnProvider.java
index 194773d0332..0079cdaa2b0 100644
--- a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/virtualcolumn/VirtualColumnProvider.java
+++ b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/virtualcolumn/VirtualColumnProvider.java
@@ -18,6 +18,7 @@
  */
 package org.apache.pinot.segment.local.segment.virtualcolumn;
 
+import javax.annotation.Nullable;
 import org.apache.pinot.segment.local.segment.index.datasource.ImmutableDataSource;
 import org.apache.pinot.segment.spi.ColumnMetadata;
 import org.apache.pinot.segment.spi.datasource.DataSource;
@@ -25,6 +26,7 @@ import org.apache.pinot.segment.spi.index.column.ColumnIndexContainer;
 import org.apache.pinot.segment.spi.index.reader.Dictionary;
 import org.apache.pinot.segment.spi.index.reader.ForwardIndexReader;
 import org.apache.pinot.segment.spi.index.reader.InvertedIndexReader;
+import org.apache.pinot.segment.spi.index.reader.NullValueVectorReader;
 
 
 /// Virtual column provider interface, which is used to instantiate the various components (dictionary, reader, etc)
@@ -39,9 +41,19 @@ public interface VirtualColumnProvider {
 
   ColumnMetadata buildMetadata(VirtualColumnContext context);
 
+  /// Returns the null value vector of the virtual column, or `null` when the column has no null value.
+  ///
+  /// Virtual columns almost always carry a real value for every document, hence the default. A provider whose value
+  /// can be genuinely unavailable must override this, otherwise the placeholder stored in the forward index is
+  /// indistinguishable from a real value once null handling is enabled.
+  @Nullable
+  default NullValueVectorReader buildNullValueVector(VirtualColumnContext context) {
+    return null;
+  }
+
   default ColumnIndexContainer buildColumnIndexContainer(VirtualColumnContext context) {
     return new VirtualColumnIndexContainer(buildForwardIndex(context), buildInvertedIndex(context),
-        buildDictionary(context));
+        buildDictionary(context), buildNullValueVector(context));
   }
 
   default DataSource buildDataSource(VirtualColumnContext context) {
diff --git a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/virtualcolumn/VirtualColumnProviderFactory.java b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/virtualcolumn/VirtualColumnProviderFactory.java
index b60d9a3cf00..4d9f04908c3 100644
--- a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/virtualcolumn/VirtualColumnProviderFactory.java
+++ b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/virtualcolumn/VirtualColumnProviderFactory.java
@@ -18,9 +18,12 @@
  */
 package org.apache.pinot.segment.local.segment.virtualcolumn;
 
+import com.google.common.base.Preconditions;
+import java.util.Map;
+import java.util.stream.Collectors;
 import org.apache.pinot.segment.local.segment.index.column.DefaultNullValueVirtualColumnProvider;
+import org.apache.pinot.spi.data.BuiltInVirtualColumnDefinitions;
 import org.apache.pinot.spi.data.DimensionFieldSpec;
-import org.apache.pinot.spi.data.FieldSpec;
 import org.apache.pinot.spi.data.Schema;
 import org.apache.pinot.spi.plugin.PluginManager;
 import org.apache.pinot.spi.utils.CommonConstants.Segment.BuiltInVirtualColumn;
@@ -29,11 +32,46 @@ import org.apache.pinot.spi.utils.NetUtils;
 
 /// Factory for virtual column providers.
 public class VirtualColumnProviderFactory {
+  /// Provider for each built-in virtual column. Its key set is asserted to cover
+  /// [BuiltInVirtualColumnDefinitions#DEFINITIONS] by `SegmentMetadataVirtualColumnProviderTest`, so a column added
+  /// there without a provider here is caught at build time; at runtime `getProviderClass` names the offending column.
+  private static final Map<String, Class<? extends VirtualColumnProvider>> PROVIDER_CLASSES =
+      Map.of(BuiltInVirtualColumn.DOCID, DocIdVirtualColumnProvider.class,
+          BuiltInVirtualColumn.HOSTNAME, DefaultNullValueVirtualColumnProvider.class,
+          BuiltInVirtualColumn.SEGMENTNAME, DefaultNullValueVirtualColumnProvider.class,
+          BuiltInVirtualColumn.PARTITIONID, PartitionIdVirtualColumnProvider.class,
+          BuiltInVirtualColumn.CREATIONTIME, SegmentCreationTimeVirtualColumnProvider.class,
+          BuiltInVirtualColumn.STARTTIME, SegmentStartTimeVirtualColumnProvider.class,
+          BuiltInVirtualColumn.ENDTIME, SegmentEndTimeVirtualColumnProvider.class,
+          BuiltInVirtualColumn.TOTALDOCS, SegmentTotalDocsVirtualColumnProvider.class,
+          BuiltInVirtualColumn.CRC, SegmentCrcVirtualColumnProvider.class);
+
+  /// Shared instances of the built-in providers, keyed by the class name stored in the field spec.
+  private static final Map<String, VirtualColumnProvider> BUILT_IN_PROVIDERS = PROVIDER_CLASSES.values()
+      .stream()
+      .distinct()
+      .collect(Collectors.toUnmodifiableMap(Class::getName, VirtualColumnProviderFactory::newInstance));
+
   private VirtualColumnProviderFactory() {
   }
 
+  private static VirtualColumnProvider newInstance(Class<? extends VirtualColumnProvider> providerClass) {
+    try {
+      return providerClass.getDeclaredConstructor().newInstance();
+    } catch (Exception e) {
+      throw new IllegalStateException("Caught exception while creating instance of: " + providerClass.getName(), e);
+    }
+  }
+
   public static VirtualColumnProvider buildProvider(VirtualColumnContext virtualColumnContext) {
     String virtualColumnProvider = virtualColumnContext.getFieldSpec().getVirtualColumnProvider();
+    // The built-in providers are stateless - everything they need comes from the VirtualColumnContext - so a single
+    // shared instance serves every column of every segment. This matters at segment load, which resolves a provider
+    // per virtual column per segment, and on the mutable path, which resolves one per query.
+    VirtualColumnProvider builtInProvider = BUILT_IN_PROVIDERS.get(virtualColumnProvider);
+    if (builtInProvider != null) {
+      return builtInProvider;
+    }
     try {
       return PluginManager.get().createInstance(virtualColumnProvider);
     } catch (Exception e) {
@@ -41,25 +79,37 @@ public class VirtualColumnProviderFactory {
     }
   }
 
+  /// Adds the built-in virtual columns to the schema of a segment, together with the provider that produces their
+  /// values.
+  ///
+  /// The shape of each column (name, data type, single-value vs multi-value) comes from
+  /// [BuiltInVirtualColumnDefinitions#DEFINITIONS], which the broker side uses as well, so the two can never
+  /// disagree on a type.
+  /// This method only layers on the provider class, and the constant value for the columns whose value is already
+  /// known here.
   public static void addBuiltInVirtualColumnsToSegmentSchema(Schema schema, String segmentName) {
-    if (!schema.hasColumn(BuiltInVirtualColumn.DOCID)) {
-      schema.addField(new DimensionFieldSpec(BuiltInVirtualColumn.DOCID, FieldSpec.DataType.INT, true,
-          DocIdVirtualColumnProvider.class));
-    }
-
-    if (!schema.hasColumn(BuiltInVirtualColumn.HOSTNAME)) {
-      schema.addField(new DimensionFieldSpec(BuiltInVirtualColumn.HOSTNAME, FieldSpec.DataType.STRING, true,
-          DefaultNullValueVirtualColumnProvider.class, NetUtils.getHostnameOrAddress()));
-    }
-
-    if (!schema.hasColumn(BuiltInVirtualColumn.SEGMENTNAME)) {
-      schema.addField(new DimensionFieldSpec(BuiltInVirtualColumn.SEGMENTNAME, FieldSpec.DataType.STRING, true,
-          DefaultNullValueVirtualColumnProvider.class, segmentName));
+    for (BuiltInVirtualColumnDefinitions.Definition definition : BuiltInVirtualColumnDefinitions.DEFINITIONS) {
+      String column = definition.getName();
+      if (schema.hasColumn(column)) {
+        continue;
+      }
+      DimensionFieldSpec fieldSpec = definition.createFieldSpec();
+      fieldSpec.setVirtualColumnProvider(getProviderClass(column).getName());
+      // $hostName and $segmentName are constants known at schema construction time, and are carried as the field's
+      // default null value, which DefaultNullValueVirtualColumnProvider reads back.
+      if (BuiltInVirtualColumn.HOSTNAME.equals(column)) {
+        fieldSpec.setDefaultNullValue(NetUtils.getHostnameOrAddress());
+      } else if (BuiltInVirtualColumn.SEGMENTNAME.equals(column)) {
+        fieldSpec.setDefaultNullValue(segmentName);
+      }
+      schema.addField(fieldSpec);
     }
+  }
 
-    if (!schema.hasColumn(BuiltInVirtualColumn.PARTITIONID)) {
-      schema.addField(new DimensionFieldSpec(BuiltInVirtualColumn.PARTITIONID, FieldSpec.DataType.STRING, false,
-          PartitionIdVirtualColumnProvider.class));
-    }
+  private static Class<? extends VirtualColumnProvider> getProviderClass(String column) {
+    Class<? extends VirtualColumnProvider> providerClass = PROVIDER_CLASSES.get(column);
+    Preconditions.checkState(providerClass != null, "No virtual column provider registered for built-in column: %s",
+        column);
+    return providerClass;
   }
 }
diff --git a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/utils/SchemaUtils.java b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/utils/SchemaUtils.java
index 9c257b06eee..1481a5b26d8 100644
--- a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/utils/SchemaUtils.java
+++ b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/utils/SchemaUtils.java
@@ -31,6 +31,7 @@ import javax.annotation.Nullable;
 import org.apache.commons.lang3.StringUtils;
 import org.apache.pinot.common.evaluator.FunctionEvaluatorFactory;
 import org.apache.pinot.spi.config.table.TableConfig;
+import org.apache.pinot.spi.data.BuiltInVirtualColumnDefinitions;
 import org.apache.pinot.spi.data.DateTimeFieldSpec;
 import org.apache.pinot.spi.data.DateTimeFormatSpec;
 import org.apache.pinot.spi.data.DateTimeGranularitySpec;
@@ -162,6 +163,12 @@ public class SchemaUtils {
       String column = fieldSpec.getName();
       Preconditions.checkState(!StringUtils.containsWhitespace(column),
           "The column name \"%s\" should not contain blank space.", column);
+      // A user column of the same name would be shadowed at query time: the built-in virtual column is filtered out
+      // of the segment's physical columns when the segment metadata is read, and the virtual provider then takes over
+      // the name, so queries would silently return segment metadata instead of the user's data.
+      Preconditions.checkState(
+          fieldSpec.isVirtualColumn() || !BuiltInVirtualColumnDefinitions.NAMES.contains(column),
+          "The column name \"%s\" is reserved for a built-in virtual column.", column);
       if (!fieldSpec.isVirtualColumn()) {
         primaryKeyColumnCandidates.add(column);
       }
diff --git a/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/indexsegment/mutable/MutableSegmentImplRawMVTest.java b/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/indexsegment/mutable/MutableSegmentImplRawMVTest.java
index 448bfd242b9..82153f600e8 100644
--- a/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/indexsegment/mutable/MutableSegmentImplRawMVTest.java
+++ b/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/indexsegment/mutable/MutableSegmentImplRawMVTest.java
@@ -67,6 +67,14 @@ import static org.testng.Assert.assertNull;
 public class MutableSegmentImplRawMVTest implements PinotBuffersAfterClassCheckRule {
   private static final String AVRO_FILE = "data/test_data-mv.avro";
   private static final File TEMP_DIR = new File(FileUtils.getTempDirectory(), "MutableSegmentImplRawMVTest");
+  /// Virtual columns describing the segment itself, which are expected to differ between a mutable segment and an
+  /// immutable segment built from the same records.
+  private static final Set<String> SEGMENT_LEVEL_VIRTUAL_COLUMNS =
+      Set.of(CommonConstants.Segment.BuiltInVirtualColumn.SEGMENTNAME,
+          CommonConstants.Segment.BuiltInVirtualColumn.CREATIONTIME,
+          CommonConstants.Segment.BuiltInVirtualColumn.STARTTIME,
+          CommonConstants.Segment.BuiltInVirtualColumn.ENDTIME,
+          CommonConstants.Segment.BuiltInVirtualColumn.CRC);
 
   private Schema _schema;
   private MutableSegmentImpl _mutableSegmentImpl;
@@ -166,8 +174,9 @@ public class MutableSegmentImplRawMVTest implements PinotBuffersAfterClassCheckR
         Dictionary expectedDictionary = expectedDataSource.getDictionary();
         assertEquals(actualDictionary.length(), expectedDictionary.length());
 
-        // Allow the segment name to be different
-        if (column.equals(CommonConstants.Segment.BuiltInVirtualColumn.SEGMENTNAME)) {
+        // Allow the segment level metadata to be different between the mutable segment and the immutable segment
+        // built from the same records
+        if (SEGMENT_LEVEL_VIRTUAL_COLUMNS.contains(column)) {
           continue;
         }
 
diff --git a/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/indexsegment/mutable/MutableSegmentImplTest.java b/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/indexsegment/mutable/MutableSegmentImplTest.java
index c837922f05e..537704ed557 100644
--- a/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/indexsegment/mutable/MutableSegmentImplTest.java
+++ b/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/indexsegment/mutable/MutableSegmentImplTest.java
@@ -38,6 +38,7 @@ import org.apache.pinot.segment.spi.index.creator.VectorIndexConfig;
 import org.apache.pinot.segment.spi.index.reader.Dictionary;
 import org.apache.pinot.segment.spi.index.reader.ForwardIndexReader;
 import org.apache.pinot.segment.spi.index.reader.ForwardIndexReaderContext;
+import org.apache.pinot.segment.spi.index.reader.NullValueVectorReader;
 import org.apache.pinot.spi.config.instance.InstanceType;
 import org.apache.pinot.spi.data.FieldSpec;
 import org.apache.pinot.spi.data.Schema;
@@ -62,6 +63,14 @@ import static org.testng.Assert.assertEquals;
 public class MutableSegmentImplTest {
   private static final String AVRO_FILE = "data/test_data-mv.avro";
   private static final File TEMP_DIR = new File(FileUtils.getTempDirectory(), "MutableSegmentImplTest");
+  /// Virtual columns describing the segment itself, which are expected to differ between a mutable segment and an
+  /// immutable segment built from the same records.
+  private static final Set<String> SEGMENT_LEVEL_VIRTUAL_COLUMNS =
+      Set.of(CommonConstants.Segment.BuiltInVirtualColumn.SEGMENTNAME,
+          CommonConstants.Segment.BuiltInVirtualColumn.CREATIONTIME,
+          CommonConstants.Segment.BuiltInVirtualColumn.STARTTIME,
+          CommonConstants.Segment.BuiltInVirtualColumn.ENDTIME,
+          CommonConstants.Segment.BuiltInVirtualColumn.CRC);
 
   private Schema _schema;
   private MutableSegmentImpl _mutableSegmentImpl;
@@ -132,6 +141,29 @@ public class MutableSegmentImplTest {
     }
   }
 
+  /// The segment metadata virtual columns are skipped in the mutable-vs-immutable comparisons above because they
+  /// legitimately differ, so their values on a real mutable segment are pinned here instead. This mutable segment is
+  /// built from ZK metadata without a creation time and never gets a time range or a CRC, which is exactly the shape
+  /// of a CONSUMING segment.
+  @Test
+  public void testSegmentMetadataVirtualColumnsOnMutableSegment() {
+    for (String column : Set.of(CommonConstants.Segment.BuiltInVirtualColumn.CREATIONTIME,
+        CommonConstants.Segment.BuiltInVirtualColumn.STARTTIME,
+        CommonConstants.Segment.BuiltInVirtualColumn.ENDTIME,
+        CommonConstants.Segment.BuiltInVirtualColumn.CRC)) {
+      DataSource dataSource = _mutableSegmentImpl.getDataSource(column);
+      NullValueVectorReader nullValueVector = dataSource.getNullValueVector();
+      Assert.assertNotNull(nullValueVector, "Expecting a null value vector for virtual column: " + column);
+      assertEquals(nullValueVector.getNullBitmap().getCardinality(), _mutableSegmentImpl.getNumDocsIndexed());
+    }
+
+    // $totalDocs tracks the documents indexed so far, and is never null
+    DataSource totalDocsDataSource =
+        _mutableSegmentImpl.getDataSource(CommonConstants.Segment.BuiltInVirtualColumn.TOTALDOCS);
+    assertEquals(totalDocsDataSource.getDictionary().getIntValue(0), _mutableSegmentImpl.getNumDocsIndexed());
+    Assert.assertNull(totalDocsDataSource.getNullValueVector());
+  }
+
   @Test
   public void testDataSourceForSVColumns() {
     for (FieldSpec fieldSpec : _schema.getAllFieldSpecs()) {
@@ -148,8 +180,9 @@ public class MutableSegmentImplTest {
         Dictionary expectedDictionary = expectedDataSource.getDictionary();
         assertEquals(actualDictionary.length(), expectedDictionary.length());
 
-        // Allow the segment name to be different
-        if (column.equals(CommonConstants.Segment.BuiltInVirtualColumn.SEGMENTNAME)) {
+        // Allow the segment level metadata to be different between the mutable segment and the immutable segment
+        // built from the same records
+        if (SEGMENT_LEVEL_VIRTUAL_COLUMNS.contains(column)) {
           continue;
         }
 
diff --git a/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/segment/index/loader/LoaderTest.java b/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/segment/index/loader/LoaderTest.java
index cd50d0de804..ac22a9a3e56 100644
--- a/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/segment/index/loader/LoaderTest.java
+++ b/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/segment/index/loader/LoaderTest.java
@@ -20,7 +20,6 @@ package org.apache.pinot.segment.local.segment.index.loader;
 
 import java.io.File;
 import java.net.URL;
-import java.util.Arrays;
 import java.util.List;
 import java.util.Map;
 import javax.annotation.Nullable;
@@ -34,11 +33,14 @@ import org.apache.pinot.segment.local.utils.SegmentOperationsThrottler;
 import org.apache.pinot.segment.local.utils.SegmentOperationsThrottlerSet;
 import org.apache.pinot.segment.spi.ImmutableSegment;
 import org.apache.pinot.segment.spi.IndexSegment;
+import org.apache.pinot.segment.spi.SegmentMetadata;
 import org.apache.pinot.segment.spi.V1Constants;
 import org.apache.pinot.segment.spi.creator.SegmentGeneratorConfig;
 import org.apache.pinot.segment.spi.creator.SegmentVersion;
+import org.apache.pinot.segment.spi.datasource.DataSource;
 import org.apache.pinot.segment.spi.index.StandardIndexes;
 import org.apache.pinot.segment.spi.index.metadata.SegmentMetadataImpl;
+import org.apache.pinot.segment.spi.index.reader.NullValueVectorReader;
 import org.apache.pinot.segment.spi.store.SegmentDirectory;
 import org.apache.pinot.segment.spi.store.SegmentDirectoryPaths;
 import org.apache.pinot.spi.config.table.FieldConfig;
@@ -217,11 +219,35 @@ public class LoaderTest {
   }
 
   private void testBuiltInVirtualColumns(IndexSegment indexSegment) {
-    assertTrue(indexSegment.getColumnNames().containsAll(
-        Arrays.asList(BuiltInVirtualColumn.DOCID, BuiltInVirtualColumn.HOSTNAME, BuiltInVirtualColumn.SEGMENTNAME)));
-    assertNotNull(indexSegment.getDataSource(BuiltInVirtualColumn.DOCID));
-    assertNotNull(indexSegment.getDataSource(BuiltInVirtualColumn.HOSTNAME));
-    assertNotNull(indexSegment.getDataSource(BuiltInVirtualColumn.SEGMENTNAME));
+    // Iterate the full set so that a newly added built-in virtual column is covered automatically
+    assertTrue(indexSegment.getColumnNames().containsAll(BuiltInVirtualColumn.BUILT_IN_VIRTUAL_COLUMNS));
+    for (String column : BuiltInVirtualColumn.BUILT_IN_VIRTUAL_COLUMNS) {
+      assertNotNull(indexSegment.getDataSource(column), "Missing data source for virtual column: " + column);
+    }
+
+    // Segment metadata that this segment carries is exposed as a real value, and is not marked null
+    SegmentMetadata segmentMetadata = indexSegment.getSegmentMetadata();
+    assertEquals(indexSegment.getDataSource(BuiltInVirtualColumn.TOTALDOCS).getDictionary().get(0),
+        segmentMetadata.getTotalDocs());
+    assertEquals(indexSegment.getDataSource(BuiltInVirtualColumn.CRC).getDictionary().get(0),
+        Long.parseLong(segmentMetadata.getCrc()));
+    assertEquals(indexSegment.getDataSource(BuiltInVirtualColumn.CREATIONTIME).getDictionary().get(0),
+        segmentMetadata.getIndexCreationTime());
+    for (String column : List.of(BuiltInVirtualColumn.TOTALDOCS, BuiltInVirtualColumn.CRC,
+        BuiltInVirtualColumn.CREATIONTIME)) {
+      assertNull(indexSegment.getDataSource(column).getNullValueVector(),
+          "Unexpected null value vector for virtual column: " + column);
+    }
+
+    // This table has no time column, so the segment has no time range and the two time columns read as NULL
+    assertNull(segmentMetadata.getTimeInterval());
+    for (String column : List.of(BuiltInVirtualColumn.STARTTIME, BuiltInVirtualColumn.ENDTIME)) {
+      DataSource dataSource = indexSegment.getDataSource(column);
+      assertEquals(dataSource.getDictionary().get(0), FieldSpec.DEFAULT_DIMENSION_NULL_VALUE_OF_TIMESTAMP);
+      NullValueVectorReader nullValueVector = dataSource.getNullValueVector();
+      assertNotNull(nullValueVector, "Missing null value vector for virtual column: " + column);
+      assertEquals(nullValueVector.getNullBitmap().getCardinality(), segmentMetadata.getTotalDocs());
+    }
   }
 
   /// Tests loading default string column with empty ("") default null value.
diff --git a/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/segment/virtualcolumn/SegmentMetadataVirtualColumnProviderTest.java b/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/segment/virtualcolumn/SegmentMetadataVirtualColumnProviderTest.java
new file mode 100644
index 00000000000..a336a2e831f
--- /dev/null
+++ b/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/segment/virtualcolumn/SegmentMetadataVirtualColumnProviderTest.java
@@ -0,0 +1,405 @@
+/**
+ * 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.virtualcolumn;
+
+import java.io.IOException;
+import java.math.BigDecimal;
+import java.util.List;
+import javax.annotation.Nullable;
+import org.apache.pinot.segment.spi.ColumnMetadata;
+import org.apache.pinot.segment.spi.SegmentMetadata;
+import org.apache.pinot.segment.spi.datasource.DataSourceMetadata;
+import org.apache.pinot.segment.spi.index.StandardIndexes;
+import org.apache.pinot.segment.spi.index.column.ColumnIndexContainer;
+import org.apache.pinot.segment.spi.index.metadata.SegmentMetadataImpl;
+import org.apache.pinot.segment.spi.index.reader.Dictionary;
+import org.apache.pinot.segment.spi.index.reader.NullValueVectorReader;
+import org.apache.pinot.spi.data.BuiltInVirtualColumnDefinitions;
+import org.apache.pinot.spi.data.DimensionFieldSpec;
+import org.apache.pinot.spi.data.FieldSpec;
+import org.apache.pinot.spi.data.Schema;
+import org.apache.pinot.spi.utils.CommonConstants.Segment.BuiltInVirtualColumn;
+import org.joda.time.DateTimeZone;
+import org.joda.time.Interval;
+import org.testng.annotations.DataProvider;
+import org.testng.annotations.Test;
+
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertNotNull;
+import static org.testng.Assert.assertNull;
+import static org.testng.Assert.assertTrue;
+import static org.testng.Assert.fail;
+
+
+/// Tests for the built-in virtual columns exposing segment metadata (`$creationTime`, `$startTime`, `$endTime`,
+/// `$totalDocs` and `$crc`).
+public class SegmentMetadataVirtualColumnProviderTest {
+  private static final int NUM_DOCS = 17;
+  private static final long CREATION_TIME_MS = 1_700_000_000_000L;
+  private static final long START_TIME_MS = 1_690_000_000_000L;
+  private static final long END_TIME_MS = 1_695_000_000_000L;
+  private static final long CRC = 1234567890L;
+
+  @DataProvider(name = "unsetCreationTimes")
+  public Object[][] unsetCreationTimes() {
+    return new Object[][]{{Long.MIN_VALUE}, {-1L}, {0L}};
+  }
+
+  /// Returns the schema a segment gets after the built-in virtual columns are added to it.
+  private static Schema buildSegmentSchema() {
+    Schema schema = new Schema();
+    VirtualColumnProviderFactory.addBuiltInVirtualColumnsToSegmentSchema(schema, "testSegment");
+    return schema;
+  }
+
+  private static SegmentMetadata mockSegmentMetadata() {
+    SegmentMetadata segmentMetadata = mock(SegmentMetadata.class);
+    when(segmentMetadata.getIndexCreationTime()).thenReturn(CREATION_TIME_MS);
+    when(segmentMetadata.getTimeInterval()).thenReturn(new Interval(START_TIME_MS, END_TIME_MS, DateTimeZone.UTC));
+    when(segmentMetadata.getCrc()).thenReturn(String.valueOf(CRC));
+    when(segmentMetadata.getTotalDocs()).thenReturn(NUM_DOCS);
+    return segmentMetadata;
+  }
+
+  /// Returns the metadata a real CONSUMING segment is created with: no time range and no CRC yet. Uses the real
+  /// `SegmentMetadataImpl` rather than a mock so that the unset-value contract stays pinned to what
+  /// `pinot-segment-spi` actually produces.
+  private static SegmentMetadata consumingSegmentMetadata(long creationTime) {
+    return new SegmentMetadataImpl("testTable", "testTable__0__0__20240101T0000Z", new Schema(), creationTime);
+  }
+
+  private static Dictionary buildDictionary(Schema schema, String column, SegmentMetadata segmentMetadata) {
+    FieldSpec fieldSpec = schema.getFieldSpecFor(column);
+    assertNotNull(fieldSpec, column + " should be added to the segment schema");
+    assertTrue(fieldSpec.isVirtualColumn(), column + " should be a virtual column");
+    VirtualColumnContext context = new VirtualColumnContext(fieldSpec, NUM_DOCS, segmentMetadata);
+    return VirtualColumnProviderFactory.buildProvider(context).buildDictionary(context);
+  }
+
+  @Nullable
+  private static NullValueVectorReader buildNullValueVector(Schema schema, String column,
+      SegmentMetadata segmentMetadata) {
+    FieldSpec fieldSpec = schema.getFieldSpecFor(column);
+    VirtualColumnContext context = new VirtualColumnContext(fieldSpec, NUM_DOCS, segmentMetadata);
+    return VirtualColumnProviderFactory.buildProvider(context).buildNullValueVector(context);
+  }
+
+  /// Asserts the column reads as SQL NULL for every document, and that the data source exposes the null vector (which
+  /// is what the query engine consults once null handling is enabled).
+  private static void assertColumnIsNull(Schema schema, String column, SegmentMetadata segmentMetadata) {
+    NullValueVectorReader nullValueVector = buildNullValueVector(schema, column, segmentMetadata);
+    assertNotNull(nullValueVector, column + " should report a null value vector when its metadata is unavailable");
+    assertEquals(nullValueVector.getNullBitmap().getCardinality(), NUM_DOCS);
+    for (int docId = 0; docId < NUM_DOCS; docId++) {
+      assertTrue(nullValueVector.isNull(docId));
+    }
+    FieldSpec fieldSpec = schema.getFieldSpecFor(column);
+    VirtualColumnContext context = new VirtualColumnContext(fieldSpec, NUM_DOCS, segmentMetadata);
+    assertNotNull(VirtualColumnProviderFactory.buildProvider(context).buildDataSource(context).getNullValueVector(),
+        column + " data source should expose the null value vector");
+  }
+
+  private static ColumnMetadata buildColumnMetadata(Schema schema, String column, SegmentMetadata segmentMetadata) {
+    FieldSpec fieldSpec = schema.getFieldSpecFor(column);
+    VirtualColumnContext context = new VirtualColumnContext(fieldSpec, NUM_DOCS, segmentMetadata);
+    return VirtualColumnProviderFactory.buildProvider(context).buildMetadata(context);
+  }
+
+  /// Replaces the class-load assertion that used to live in VirtualColumnProviderFactory: a definition added without
+  /// a provider must be caught here at build time rather than at segment load.
+  @Test
+  public void testEveryDefinitionHasAProvider() {
+    Schema schema = buildSegmentSchema();
+    for (BuiltInVirtualColumnDefinitions.Definition definition : BuiltInVirtualColumnDefinitions.DEFINITIONS) {
+      FieldSpec fieldSpec = schema.getFieldSpecFor(definition.getName());
+      assertNotNull(fieldSpec, "No field spec added for: " + definition.getName());
+      assertTrue(fieldSpec.isVirtualColumn(), "No provider configured for: " + definition.getName());
+      assertEquals(fieldSpec.getDataType(), definition.getDataType());
+      assertEquals(fieldSpec.isSingleValueField(), definition.isSingleValueField());
+    }
+  }
+
+  @Test
+  public void testAllBuiltInVirtualColumnsAreAddedToSegmentSchema() {
+    Schema schema = buildSegmentSchema();
+    for (String column : BuiltInVirtualColumn.BUILT_IN_VIRTUAL_COLUMNS) {
+      FieldSpec fieldSpec = schema.getFieldSpecFor(column);
+      assertNotNull(fieldSpec, column + " should be added to the segment schema");
+      assertTrue(fieldSpec.isVirtualColumn(), column + " should have a virtual column provider configured");
+      // Every configured provider must be resolvable
+      assertNotNull(VirtualColumnProviderFactory.buildProvider(
+          new VirtualColumnContext(fieldSpec, NUM_DOCS, mockSegmentMetadata())));
+    }
+  }
+
+  @Test
+  public void testSegmentMetadataColumnTypes() {
+    Schema schema = buildSegmentSchema();
+    assertEquals(schema.getFieldSpecFor(BuiltInVirtualColumn.CREATIONTIME).getDataType(),
+        FieldSpec.DataType.TIMESTAMP);
+    assertEquals(schema.getFieldSpecFor(BuiltInVirtualColumn.STARTTIME).getDataType(),
+        FieldSpec.DataType.TIMESTAMP);
+    assertEquals(schema.getFieldSpecFor(BuiltInVirtualColumn.ENDTIME).getDataType(),
+        FieldSpec.DataType.TIMESTAMP);
+    assertEquals(schema.getFieldSpecFor(BuiltInVirtualColumn.TOTALDOCS).getDataType(), FieldSpec.DataType.INT);
+    assertEquals(schema.getFieldSpecFor(BuiltInVirtualColumn.CRC).getDataType(), FieldSpec.DataType.LONG);
+    for (String column : List.of(BuiltInVirtualColumn.CREATIONTIME, BuiltInVirtualColumn.STARTTIME,
+        BuiltInVirtualColumn.ENDTIME, BuiltInVirtualColumn.TOTALDOCS, BuiltInVirtualColumn.CRC)) {
+      assertTrue(schema.getFieldSpecFor(column).isSingleValueField(), column + " should be single-value");
+    }
+  }
+
+  @Test
+  public void testValuesFromSegmentMetadata() {
+    Schema schema = buildSegmentSchema();
+    SegmentMetadata segmentMetadata = mockSegmentMetadata();
+
+    assertEquals(buildDictionary(schema, BuiltInVirtualColumn.CREATIONTIME, segmentMetadata).getLongValue(0),
+        CREATION_TIME_MS);
+    assertEquals(buildDictionary(schema, BuiltInVirtualColumn.STARTTIME, segmentMetadata).getLongValue(0),
+        START_TIME_MS);
+    assertEquals(buildDictionary(schema, BuiltInVirtualColumn.ENDTIME, segmentMetadata).getLongValue(0), END_TIME_MS);
+    assertEquals(buildDictionary(schema, BuiltInVirtualColumn.TOTALDOCS, segmentMetadata).getIntValue(0), NUM_DOCS);
+    assertEquals(buildDictionary(schema, BuiltInVirtualColumn.CRC, segmentMetadata).getLongValue(0), CRC);
+  }
+
+  @Test
+  public void testColumnMetadataMatchesValue() {
+    Schema schema = buildSegmentSchema();
+    SegmentMetadata segmentMetadata = mockSegmentMetadata();
+
+    ColumnMetadata creationTimeMetadata =
+        buildColumnMetadata(schema, BuiltInVirtualColumn.CREATIONTIME, segmentMetadata);
+    assertEquals(creationTimeMetadata.getTotalDocs(), NUM_DOCS);
+    assertEquals(creationTimeMetadata.getCardinality(), 1);
+    assertTrue(creationTimeMetadata.isSorted());
+    assertTrue(creationTimeMetadata.hasDictionary());
+    assertEquals(creationTimeMetadata.getMinValue(), CREATION_TIME_MS);
+    assertEquals(creationTimeMetadata.getMaxValue(), CREATION_TIME_MS);
+
+    ColumnMetadata crcMetadata = buildColumnMetadata(schema, BuiltInVirtualColumn.CRC, segmentMetadata);
+    assertEquals(crcMetadata.getMinValue(), CRC);
+    assertEquals(crcMetadata.getMaxValue(), CRC);
+
+    // When the metadata is available the column carries a real value, so there is no null vector
+    for (String column : List.of(BuiltInVirtualColumn.CREATIONTIME, BuiltInVirtualColumn.STARTTIME,
+        BuiltInVirtualColumn.ENDTIME, BuiltInVirtualColumn.TOTALDOCS, BuiltInVirtualColumn.CRC)) {
+      assertNull(buildNullValueVector(schema, column, segmentMetadata), column + " should not be null");
+    }
+  }
+
+  /// A segment without a time range and without a CRC must fall back to the default null value for those columns,
+  /// while `$creationTime` and `$totalDocs` remain meaningful. An unset creation time is `Long.MIN_VALUE` in the
+  /// segment metadata of an immutable segment and `-1` in the ZK metadata a CONSUMING segment is created from.
+  @Test(dataProvider = "unsetCreationTimes")
+  public void testUnavailableMetadataFallsBackToDefaultNullValue(long unsetCreationTime) {
+    Schema schema = buildSegmentSchema();
+    SegmentMetadata segmentMetadata = consumingSegmentMetadata(unsetCreationTime);
+
+    assertEquals(buildDictionary(schema, BuiltInVirtualColumn.CREATIONTIME, segmentMetadata).getLongValue(0),
+        (long) FieldSpec.DEFAULT_DIMENSION_NULL_VALUE_OF_TIMESTAMP);
+    assertEquals(buildDictionary(schema, BuiltInVirtualColumn.STARTTIME, segmentMetadata).getLongValue(0),
+        (long) FieldSpec.DEFAULT_DIMENSION_NULL_VALUE_OF_TIMESTAMP);
+    assertEquals(buildDictionary(schema, BuiltInVirtualColumn.ENDTIME, segmentMetadata).getLongValue(0),
+        (long) FieldSpec.DEFAULT_DIMENSION_NULL_VALUE_OF_TIMESTAMP);
+    assertEquals(buildDictionary(schema, BuiltInVirtualColumn.CRC, segmentMetadata).getLongValue(0),
+        (long) FieldSpec.DEFAULT_DIMENSION_NULL_VALUE_OF_LONG);
+    // The stored value is only a placeholder: the column must additionally report every document as null
+    assertColumnIsNull(schema, BuiltInVirtualColumn.CREATIONTIME, segmentMetadata);
+    assertColumnIsNull(schema, BuiltInVirtualColumn.STARTTIME, segmentMetadata);
+    assertColumnIsNull(schema, BuiltInVirtualColumn.ENDTIME, segmentMetadata);
+    assertColumnIsNull(schema, BuiltInVirtualColumn.CRC, segmentMetadata);
+
+    // $totalDocs comes from the context, so it is always available and never null
+    assertEquals(buildDictionary(schema, BuiltInVirtualColumn.TOTALDOCS, segmentMetadata).getIntValue(0), NUM_DOCS);
+    assertNull(buildNullValueVector(schema, BuiltInVirtualColumn.TOTALDOCS, segmentMetadata));
+  }
+
+  /// Segment pruners read `ColumnMetadata` min/max without consulting the null value vector, so a column whose value
+  /// is unavailable must not publish the placeholder as its min/max. Otherwise a CONSUMING segment reporting epoch 0
+  /// for `$creationTime` sorts first in `ORDER BY $creationTime ASC LIMIT n` and prunes away the committed segments
+  /// that actually hold the answer.
+  @Test
+  public void testUnavailableMetadataPublishesNoMinMax() {
+    Schema schema = buildSegmentSchema();
+    SegmentMetadata segmentMetadata = consumingSegmentMetadata(-1L);
+    for (String column : List.of(BuiltInVirtualColumn.CREATIONTIME, BuiltInVirtualColumn.STARTTIME,
+        BuiltInVirtualColumn.ENDTIME, BuiltInVirtualColumn.CRC)) {
+      ColumnMetadata columnMetadata = buildColumnMetadata(schema, column, segmentMetadata);
+      assertNull(columnMetadata.getMinValue(), "Unavailable " + column + " should not publish a min value");
+      assertNull(columnMetadata.getMaxValue(), "Unavailable " + column + " should not publish a max value");
+
+      // The same has to hold through buildDataSource, which is the path a mutable segment takes
+      FieldSpec fieldSpec = schema.getFieldSpecFor(column);
+      VirtualColumnContext context = new VirtualColumnContext(fieldSpec, NUM_DOCS, segmentMetadata);
+      DataSourceMetadata dataSourceMetadata =
+          VirtualColumnProviderFactory.buildProvider(context).buildDataSource(context).getDataSourceMetadata();
+      assertNull(dataSourceMetadata.getMinValue(), column);
+      assertNull(dataSourceMetadata.getMaxValue(), column);
+    }
+
+    // A column whose value IS available still publishes min/max, so pruning keeps working
+    ColumnMetadata available = buildColumnMetadata(schema, BuiltInVirtualColumn.CREATIONTIME, mockSegmentMetadata());
+    assertEquals(available.getMinValue(), CREATION_TIME_MS);
+    assertEquals(available.getMaxValue(), CREATION_TIME_MS);
+  }
+
+  /// A segment that has not indexed anything yet still has to produce a well-formed, empty null bitmap rather than
+  /// failing or reporting a stale document count.
+  @Test
+  public void testNullValueVectorOnAnEmptySegment() {
+    Schema schema = buildSegmentSchema();
+    FieldSpec fieldSpec = schema.getFieldSpecFor(BuiltInVirtualColumn.STARTTIME);
+    VirtualColumnContext context = new VirtualColumnContext(fieldSpec, 0, consumingSegmentMetadata(-1L));
+    NullValueVectorReader nullValueVector =
+        VirtualColumnProviderFactory.buildProvider(context).buildNullValueVector(context);
+    assertNotNull(nullValueVector);
+    assertTrue(nullValueVector.getNullBitmap().isEmpty());
+  }
+
+  /// The null value vector is built on every data source access for a mutable segment, so the bitmap is materialized
+  /// lazily. Repeated reads must return an equal - and stable - bitmap.
+  @Test
+  public void testNullValueVectorBitmapIsStableAcrossReads() {
+    Schema schema = buildSegmentSchema();
+    FieldSpec fieldSpec = schema.getFieldSpecFor(BuiltInVirtualColumn.ENDTIME);
+    VirtualColumnContext context = new VirtualColumnContext(fieldSpec, NUM_DOCS, consumingSegmentMetadata(-1L));
+    NullValueVectorReader nullValueVector =
+        VirtualColumnProviderFactory.buildProvider(context).buildNullValueVector(context);
+    assertNotNull(nullValueVector);
+    assertEquals(nullValueVector.getNullBitmap(), nullValueVector.getNullBitmap());
+    assertEquals(nullValueVector.getNullBitmap().getCardinality(), NUM_DOCS);
+  }
+
+  /// The index container must hand the null value vector to the engine, and must not hand one back for an index type
+  /// a virtual column does not have.
+  @Test
+  public void testIndexContainerExposesTheNullValueVector()
+      throws IOException {
+    Schema schema = buildSegmentSchema();
+    FieldSpec fieldSpec = schema.getFieldSpecFor(BuiltInVirtualColumn.CRC);
+    VirtualColumnContext context = new VirtualColumnContext(fieldSpec, NUM_DOCS, consumingSegmentMetadata(-1L));
+    try (ColumnIndexContainer container =
+        VirtualColumnProviderFactory.buildProvider(context).buildColumnIndexContainer(context)) {
+      assertNotNull(container.getIndex(StandardIndexes.nullValueVector()));
+      assertNotNull(container.getIndex(StandardIndexes.forward()));
+      assertNotNull(container.getIndex(StandardIndexes.dictionary()));
+      assertNull(container.getIndex(StandardIndexes.range()));
+    }
+
+    // With the metadata available there is no null value vector at all
+    VirtualColumnContext availableContext = new VirtualColumnContext(fieldSpec, NUM_DOCS, mockSegmentMetadata());
+    try (ColumnIndexContainer container =
+        VirtualColumnProviderFactory.buildProvider(availableContext).buildColumnIndexContainer(availableContext)) {
+      assertNull(container.getIndex(StandardIndexes.nullValueVector()));
+    }
+  }
+
+  /// Every stored type the constant-value base supports must round-trip through the type check, so that a new virtual
+  /// column of any type is rejected loudly rather than silently mis-cast.
+  @Test(dataProvider = "storedTypeValues")
+  public void testValueTypeCheckAcceptsEveryStoredType(FieldSpec.DataType dataType, Object value) {
+    FieldSpec fieldSpec = new DimensionFieldSpec("col", dataType, true);
+    VirtualColumnContext context = new VirtualColumnContext(fieldSpec, NUM_DOCS, null);
+    BaseConstantValueVirtualColumnProvider provider = new BaseConstantValueVirtualColumnProvider() {
+      @Override
+      protected Object getValue(VirtualColumnContext ctx) {
+        return value;
+      }
+    };
+    assertNotNull(provider.buildDictionary(context));
+    assertNotNull(provider.buildMetadata(context));
+
+    // ... and the same type check rejects a value of the wrong type
+    BaseConstantValueVirtualColumnProvider wrong = new BaseConstantValueVirtualColumnProvider() {
+      @Override
+      protected Object getValue(VirtualColumnContext ctx) {
+        return new Object();
+      }
+    };
+    try {
+      wrong.buildDictionary(context);
+      fail("Expecting an IllegalStateException for data type: " + dataType);
+    } catch (IllegalStateException e) {
+      assertTrue(e.getMessage().contains("col"), e.getMessage());
+    }
+  }
+
+  @DataProvider(name = "storedTypeValues")
+  public Object[][] storedTypeValues() {
+    return new Object[][]{
+        {FieldSpec.DataType.INT, 1},
+        {FieldSpec.DataType.LONG, 1L},
+        {FieldSpec.DataType.FLOAT, 1.0f},
+        {FieldSpec.DataType.DOUBLE, 1.0d},
+        {FieldSpec.DataType.BIG_DECIMAL, BigDecimal.ONE},
+        {FieldSpec.DataType.STRING, "value"},
+        {FieldSpec.DataType.BYTES, new byte[]{1, 2}},
+        {FieldSpec.DataType.TIMESTAMP, 1L}
+    };
+  }
+
+  /// The type check exists so that a provider returning the wrong box type names the column and the provider instead
+  /// of throwing a bare ClassCastException from deep inside segment loading.
+  @Test
+  public void testWrongValueTypeIsRejectedWithADiagnosticMessage() {
+    FieldSpec fieldSpec = buildSegmentSchema().getFieldSpecFor(BuiltInVirtualColumn.CREATIONTIME);
+    VirtualColumnContext context = new VirtualColumnContext(fieldSpec, NUM_DOCS, mockSegmentMetadata());
+    // A LONG-stored column handed an Integer
+    BaseConstantValueVirtualColumnProvider provider = new BaseConstantValueVirtualColumnProvider() {
+      @Override
+      protected Object getValue(VirtualColumnContext ctx) {
+        return 1;
+      }
+    };
+    try {
+      provider.buildDictionary(context);
+      fail("Expecting an IllegalStateException for a wrongly typed virtual column value");
+    } catch (IllegalStateException e) {
+      String message = e.getMessage();
+      assertTrue(message.contains(BuiltInVirtualColumn.CREATIONTIME), message);
+      assertTrue(message.contains("java.lang.Integer"), message);
+      assertTrue(message.contains("java.lang.Long"), message);
+    }
+  }
+
+  /// The segment metadata is not always available (e.g. when the virtual column is built for a column that is missing
+  /// from the segment). The providers must not fail in that case.
+  @Test
+  public void testMissingSegmentMetadataFallsBackToDefaultNullValue() {
+    Schema schema = buildSegmentSchema();
+
+    assertEquals(buildDictionary(schema, BuiltInVirtualColumn.CREATIONTIME, null).getLongValue(0),
+        (long) FieldSpec.DEFAULT_DIMENSION_NULL_VALUE_OF_TIMESTAMP);
+    assertEquals(buildDictionary(schema, BuiltInVirtualColumn.STARTTIME, null).getLongValue(0),
+        (long) FieldSpec.DEFAULT_DIMENSION_NULL_VALUE_OF_TIMESTAMP);
+    assertEquals(buildDictionary(schema, BuiltInVirtualColumn.ENDTIME, null).getLongValue(0),
+        (long) FieldSpec.DEFAULT_DIMENSION_NULL_VALUE_OF_TIMESTAMP);
+    assertEquals(buildDictionary(schema, BuiltInVirtualColumn.CRC, null).getLongValue(0),
+        (long) FieldSpec.DEFAULT_DIMENSION_NULL_VALUE_OF_LONG);
+    assertEquals(buildDictionary(schema, BuiltInVirtualColumn.TOTALDOCS, null).getIntValue(0), NUM_DOCS);
+
+    assertColumnIsNull(schema, BuiltInVirtualColumn.CREATIONTIME, null);
+    assertColumnIsNull(schema, BuiltInVirtualColumn.STARTTIME, null);
+    assertColumnIsNull(schema, BuiltInVirtualColumn.ENDTIME, null);
+    assertColumnIsNull(schema, BuiltInVirtualColumn.CRC, null);
+  }
+}
diff --git a/pinot-spi/src/main/java/org/apache/pinot/spi/data/BuiltInVirtualColumnDefinitions.java b/pinot-spi/src/main/java/org/apache/pinot/spi/data/BuiltInVirtualColumnDefinitions.java
new file mode 100644
index 00000000000..ab3903468bc
--- /dev/null
+++ b/pinot-spi/src/main/java/org/apache/pinot/spi/data/BuiltInVirtualColumnDefinitions.java
@@ -0,0 +1,107 @@
+/**
+ * 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.data;
+
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
+import org.apache.pinot.spi.data.FieldSpec.DataType;
+import org.apache.pinot.spi.utils.CommonConstants.Segment.BuiltInVirtualColumn;
+
+
+/// Single source of truth for the shape (name, data type, single-value vs multi-value) of the built-in virtual columns.
+///
+/// The columns are declared here once because they are materialized in two independent places that must agree:
+///
+/// - the broker/controller side adds them to the table schema used for query planning, without a provider class
+///   (see `TableCache#addBuiltInVirtualColumns`), and
+/// - the server side adds them to the segment schema together with the provider that produces the values
+///   (see `VirtualColumnProviderFactory#addBuiltInVirtualColumnsToSegmentSchema`).
+///
+/// If the two sides disagreed on a data type or on single-value vs multi-value, the broker would declare one type
+/// while the server produced another. Both sides therefore build their field specs from [#DEFINITIONS].
+///
+/// The column *names* additionally live in [BuiltInVirtualColumn], which is where code that only needs to recognize a
+/// built-in virtual column by name looks them up. [#NAMES] exposes the names derived from [#DEFINITIONS] so the two
+/// can be asserted equal in a test.
+public class BuiltInVirtualColumnDefinitions {
+  private BuiltInVirtualColumnDefinitions() {
+  }
+
+  /// Shape of a single built-in virtual column. The provider class is intentionally not part of this definition: it
+  /// lives in `pinot-segment-local` and is only known to the server side.
+  public static class Definition {
+    private final String _name;
+    private final DataType _dataType;
+    private final boolean _singleValueField;
+
+    private Definition(String name, DataType dataType, boolean singleValueField) {
+      _name = name;
+      _dataType = dataType;
+      _singleValueField = singleValueField;
+    }
+
+    public String getName() {
+      return _name;
+    }
+
+    public DataType getDataType() {
+      return _dataType;
+    }
+
+    public boolean isSingleValueField() {
+      return _singleValueField;
+    }
+
+    /// Creates a new field spec for this column, without a virtual column provider. Callers that can resolve a
+    /// provider should set it on the returned spec.
+    ///
+    /// NOTE: Returns a fresh instance on every call. Field specs are mutable and are stored by reference in the
+    /// schema they are added to, so they must never be shared across schemas.
+    public DimensionFieldSpec createFieldSpec() {
+      return new DimensionFieldSpec(_name, _dataType, _singleValueField);
+    }
+  }
+
+  public static final List<Definition> DEFINITIONS = List.of(
+      new Definition(BuiltInVirtualColumn.DOCID, DataType.INT, true),
+      new Definition(BuiltInVirtualColumn.HOSTNAME, DataType.STRING, true),
+      new Definition(BuiltInVirtualColumn.SEGMENTNAME, DataType.STRING, true),
+      new Definition(BuiltInVirtualColumn.PARTITIONID, DataType.STRING, false),
+      new Definition(BuiltInVirtualColumn.CREATIONTIME, DataType.TIMESTAMP, true),
+      new Definition(BuiltInVirtualColumn.STARTTIME, DataType.TIMESTAMP, true),
+      new Definition(BuiltInVirtualColumn.ENDTIME, DataType.TIMESTAMP, true),
+      new Definition(BuiltInVirtualColumn.TOTALDOCS, DataType.INT, true),
+      new Definition(BuiltInVirtualColumn.CRC, DataType.LONG, true));
+
+  /// Names of all the built-in virtual columns, derived from [#DEFINITIONS].
+  public static final Set<String> NAMES =
+      DEFINITIONS.stream().map(Definition::getName).collect(Collectors.toUnmodifiableSet());
+
+  /// Adds the built-in virtual columns to the given schema, without a virtual column provider.
+  ///
+  /// Existing columns are left untouched, so a user-defined column of the same name always wins.
+  public static void addToSchema(Schema schema) {
+    for (Definition definition : DEFINITIONS) {
+      if (!schema.hasColumn(definition.getName())) {
+        schema.addField(definition.createFieldSpec());
+      }
+    }
+  }
+}
diff --git a/pinot-spi/src/main/java/org/apache/pinot/spi/data/SchemaInfo.java b/pinot-spi/src/main/java/org/apache/pinot/spi/data/SchemaInfo.java
index a0364117b6a..fa7c2de0335 100644
--- a/pinot-spi/src/main/java/org/apache/pinot/spi/data/SchemaInfo.java
+++ b/pinot-spi/src/main/java/org/apache/pinot/spi/data/SchemaInfo.java
@@ -66,8 +66,17 @@ public class SchemaInfo {
   public SchemaInfo(Schema schema) {
     _schemaName = schema.getSchemaName();
 
-    //Removed virtual columns($docId, $hostName, $segmentName) from dimension fields count
-    _numDimensionFields = schema.getDimensionFieldSpecs().size() - 3;
+    // The schema handed in here has usually been through TableCache#addBuiltInVirtualColumns, which adds the built-in
+    // virtual columns as plain dimension fields (without a provider class, so isVirtualColumn() does not identify
+    // them). Exclude them by name so that the reported count only covers the user's own dimensions, and stays correct
+    // as built-in virtual columns are added.
+    int numDimensionFields = 0;
+    for (DimensionFieldSpec fieldSpec : schema.getDimensionFieldSpecs()) {
+      if (!BuiltInVirtualColumnDefinitions.NAMES.contains(fieldSpec.getName())) {
+        numDimensionFields++;
+      }
+    }
+    _numDimensionFields = numDimensionFields;
     _numDateTimeFields = schema.getDateTimeFieldSpecs().size();
     _numMetricFields = schema.getMetricFieldSpecs().size();
     _numComplexFields = schema.getComplexFieldSpecs().size();
diff --git a/pinot-spi/src/main/java/org/apache/pinot/spi/utils/CommonConstants.java b/pinot-spi/src/main/java/org/apache/pinot/spi/utils/CommonConstants.java
index e00598b582e..c117713b8c8 100644
--- a/pinot-spi/src/main/java/org/apache/pinot/spi/utils/CommonConstants.java
+++ b/pinot-spi/src/main/java/org/apache/pinot/spi/utils/CommonConstants.java
@@ -2246,7 +2246,36 @@ public class CommonConstants {
       public static final String HOSTNAME = "$hostName";
       public static final String SEGMENTNAME = "$segmentName";
       public static final String PARTITIONID = "$partitionId";
-      public static final Set<String> BUILT_IN_VIRTUAL_COLUMNS = Set.of(DOCID, HOSTNAME, SEGMENTNAME, PARTITIONID);
+
+      // Segment metadata virtual columns. Each of them is a constant single-value column within a segment, exposing a
+      // piece of the segment metadata to queries. When the underlying metadata is not available (e.g. on a CONSUMING
+      // segment, which has no time range and no CRC yet), the column reads as NULL.
+      //
+      // The three time columns are TIMESTAMP rather than LONG, so the unit is carried by the type instead of by the
+      // column name, and query results render them as readable timestamps.
+
+      /// Segment creation time (TIMESTAMP). This is the index creation time recorded in the segment's creation
+      /// metadata; for a CONSUMING segment it is the time the consuming segment was created.
+      public static final String CREATIONTIME = "$creationTime";
+      /// Start of the segment time range (TIMESTAMP), normalized from the time column's own unit.
+      /// NULL for segments without a time range, such as CONSUMING segments and tables without a time column.
+      public static final String STARTTIME = "$startTime";
+      /// End of the segment time range (TIMESTAMP), normalized from the time column's own unit.
+      /// NULL for segments without a time range, such as CONSUMING segments and tables without a time column.
+      public static final String ENDTIME = "$endTime";
+      /// Number of documents in the segment (INT). On a CONSUMING segment this is the number of documents indexed so
+      /// far. NOTE: This counts all the documents physically stored in the segment, so for an upsert table it also
+      /// includes the documents that have been replaced and are no longer returned by queries.
+      public static final String TOTALDOCS = "$totalDocs";
+      /// Segment CRC (LONG). NULL on CONSUMING segments, which have no CRC until they are committed.
+      /// NOTE: Do not confuse this constant with the enclosing [Segment#CRC], which is the `segment.crc` metadata key.
+      public static final String CRC = "$crc";
+
+      /// NOTE: Kept in sync with `BuiltInVirtualColumnDefinitions#DEFINITIONS`, which additionally carries the data
+      /// type and single-value/multi-value shape of each column. `BuiltInVirtualColumnDefinitionsTest` asserts the two
+      /// agree.
+      public static final Set<String> BUILT_IN_VIRTUAL_COLUMNS =
+          Set.of(DOCID, HOSTNAME, SEGMENTNAME, PARTITIONID, CREATIONTIME, STARTTIME, ENDTIME, TOTALDOCS, CRC);
     }
   }
 
diff --git a/pinot-spi/src/test/java/org/apache/pinot/spi/data/BuiltInVirtualColumnDefinitionsTest.java b/pinot-spi/src/test/java/org/apache/pinot/spi/data/BuiltInVirtualColumnDefinitionsTest.java
new file mode 100644
index 00000000000..c8cc253d808
--- /dev/null
+++ b/pinot-spi/src/test/java/org/apache/pinot/spi/data/BuiltInVirtualColumnDefinitionsTest.java
@@ -0,0 +1,99 @@
+/**
+ * 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.data;
+
+import java.util.HashSet;
+import java.util.Set;
+import org.apache.pinot.spi.utils.CommonConstants.Segment.BuiltInVirtualColumn;
+import org.testng.annotations.Test;
+
+import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertNotNull;
+import static org.testng.Assert.assertNotSame;
+import static org.testng.Assert.assertTrue;
+
+
+public class BuiltInVirtualColumnDefinitionsTest {
+
+  /// The names are declared twice - once as constants in [BuiltInVirtualColumn] and once implicitly by
+  /// [BuiltInVirtualColumnDefinitions#DEFINITIONS]. A definition missing from the name set would stop being filtered
+  /// out of a segment's physical columns and would be counted as a user dimension by [SchemaInfo].
+  @Test
+  public void testDefinitionsMatchTheDeclaredNames() {
+    assertEquals(BuiltInVirtualColumnDefinitions.NAMES, BuiltInVirtualColumn.BUILT_IN_VIRTUAL_COLUMNS);
+    assertEquals(BuiltInVirtualColumnDefinitions.DEFINITIONS.size(),
+        BuiltInVirtualColumn.BUILT_IN_VIRTUAL_COLUMNS.size(), "Duplicate name in DEFINITIONS");
+  }
+
+  /// Every built-in virtual column name must start with `$`: that prefix is what excludes them from `SELECT *` in
+  /// both query engines.
+  @Test
+  public void testNamesAreDollarPrefixed() {
+    for (String name : BuiltInVirtualColumnDefinitions.NAMES) {
+      assertTrue(name.startsWith("$"), "Built-in virtual column must be $-prefixed: " + name);
+    }
+  }
+
+  /// Field specs are mutable and are stored by reference in the schema they are added to, so each call must return a
+  /// fresh instance - the server side mutates them to attach the provider class and, for `$segmentName`, a per-segment
+  /// value.
+  @Test
+  public void testCreateFieldSpecReturnsFreshInstances() {
+    for (BuiltInVirtualColumnDefinitions.Definition definition : BuiltInVirtualColumnDefinitions.DEFINITIONS) {
+      DimensionFieldSpec first = definition.createFieldSpec();
+      DimensionFieldSpec second = definition.createFieldSpec();
+      assertNotSame(first, second, "Field spec must not be shared for: " + definition.getName());
+      assertEquals(first, second);
+      assertEquals(first.getName(), definition.getName());
+      assertEquals(first.getDataType(), definition.getDataType());
+      assertEquals(first.isSingleValueField(), definition.isSingleValueField());
+    }
+  }
+
+  @Test
+  public void testAddToSchemaIsIdempotentAndNeverOverwrites() {
+    Schema schema = new Schema.SchemaBuilder().setSchemaName("test")
+        .addSingleValueDimension("dim", FieldSpec.DataType.STRING)
+        .build();
+    BuiltInVirtualColumnDefinitions.addToSchema(schema);
+    Set<String> afterFirst = new HashSet<>(schema.getColumnNames());
+    assertTrue(afterFirst.containsAll(BuiltInVirtualColumnDefinitions.NAMES));
+
+    // A second call must not duplicate or replace anything
+    BuiltInVirtualColumnDefinitions.addToSchema(schema);
+    assertEquals(new HashSet<>(schema.getColumnNames()), afterFirst);
+
+    for (BuiltInVirtualColumnDefinitions.Definition definition : BuiltInVirtualColumnDefinitions.DEFINITIONS) {
+      FieldSpec fieldSpec = schema.getFieldSpecFor(definition.getName());
+      assertNotNull(fieldSpec);
+      assertEquals(fieldSpec.getDataType(), definition.getDataType());
+    }
+  }
+
+  /// A user-defined column of the same name must win, so that adding a built-in virtual column never silently
+  /// changes the type of an existing user column in the broker's schema.
+  @Test
+  public void testAddToSchemaDoesNotOverrideUserColumn() {
+    Schema schema = new Schema.SchemaBuilder().setSchemaName("test")
+        .addSingleValueDimension(BuiltInVirtualColumn.TOTALDOCS, FieldSpec.DataType.STRING)
+        .build();
+    BuiltInVirtualColumnDefinitions.addToSchema(schema);
+    assertEquals(schema.getFieldSpecFor(BuiltInVirtualColumn.TOTALDOCS).getDataType(), FieldSpec.DataType.STRING);
+  }
+}
diff --git a/pinot-spi/src/test/java/org/apache/pinot/spi/data/SchemaInfoTest.java b/pinot-spi/src/test/java/org/apache/pinot/spi/data/SchemaInfoTest.java
index d3ee0e31aeb..4266bbc2e5a 100644
--- a/pinot-spi/src/test/java/org/apache/pinot/spi/data/SchemaInfoTest.java
+++ b/pinot-spi/src/test/java/org/apache/pinot/spi/data/SchemaInfoTest.java
@@ -34,6 +34,30 @@ import static org.testng.Assert.assertEquals;
 
 public class SchemaInfoTest {
 
+  /// The schema `SchemaInfo` is built from has usually been through the broker's `addBuiltInVirtualColumns`, which adds
+  /// every built-in virtual column as a plain dimension field. None of them may be reported as a user dimension,
+  /// whatever their number.
+  @Test
+  public void testBuiltInVirtualColumnsAreNotCounted() {
+    Schema schema = new Schema.SchemaBuilder().setSchemaName("TestSchema")
+        .addDimensionField("dim1", FieldSpec.DataType.STRING)
+        .addDimensionField("dim2", FieldSpec.DataType.INT)
+        .addDateTimeField("dt1", FieldSpec.DataType.LONG, "1:HOURS:EPOCH", "1:HOURS")
+        .addMetricField("metric", INT)
+        .build();
+    assertEquals(new SchemaInfo(schema).getNumDimensionFields(), 2);
+
+    // Adding the built-in virtual columns must not change the reported dimension count
+    BuiltInVirtualColumnDefinitions.addToSchema(schema);
+    assertEquals(schema.getDimensionFieldSpecs().size(),
+        2 + CommonConstants.Segment.BuiltInVirtualColumn.BUILT_IN_VIRTUAL_COLUMNS.size());
+    SchemaInfo schemaInfo = new SchemaInfo(schema);
+    assertEquals(schemaInfo.getNumDimensionFields(), 2);
+    assertEquals(schemaInfo.getNumDateTimeFields(), 1);
+    assertEquals(schemaInfo.getNumMetricFields(), 1);
+    assertEquals(schemaInfo.getNumComplexFields(), 0);
+  }
+
   @Test
   public void testSchemaInfoSerDeserWithVirtualColumns()
       throws IOException {


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@pinot.apache.org
For additional commands, e-mail: commits-help@pinot.apache.org