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 866bb59cb9e Report V7 compression stats and reject per-key OPEN_STRUCT 
codecSpec (#19308)
866bb59cb9e is described below

commit 866bb59cb9e16f87ecbaad30717c7382ff35c480
Author: Xiang Fu <[email protected]>
AuthorDate: Mon Sep 28 23:12:13 2026 +0700

    Report V7 compression stats and reject per-key OPEN_STRUCT codecSpec 
(#19308)
    
    * Support codecSpec on segment reload and enable codecSpec end to end
    
    * Harden codec reload and compression stats
    
    * Report uncompressed value size for V7 codec-pipeline columns
    
    This PR enabled compression statistics for V7 columns (the metadata and
    reader no longer require a legacy ChunkCompressionType), but the creator
    still returned -1 for the V7 writer's uncompressed value size. That -1 maps
    to UNAVAILABLE, so codecSpec columns never reported compression stats at
    creation or on reload-to-V7, and testV7SegmentCreationPersistsAndReports-
    CompressionStats failed.
    
    The V7 fixed-byte layout stores exactly one value per doc, so the
    uncompressed size is totalDocs * valueType.size(). Report it when tracking
    is enabled, mirroring the legacy writer's contract. Update the V7 round-trip
    assertion in ForwardIndexCreatorFactoryTest, which encoded the old -1.
    
    * Address review feedback on V7 compression stats and OPEN_STRUCT codecSpec
    
    - Report the V7 uncompressed value size from the values actually written
      (new FixedByteChunkForwardIndexWriterV7.getUncompressedValueSizeInBytes)
      instead of the declared doc count, matching the legacy writer.
    - Scope the codecSpec guidance in the CompressionCodec Javadoc to
      single-value INT/LONG raw columns on V7-capable clusters, and correct
      the DELTA wording for columns without a forward index.
    - Name the OPEN_STRUCT column, and the key or defaultValueFieldConfig, in
      codecSpec rejection errors; document which per-key settings OPEN_STRUCT
      keys honor.
    - Add V7 reload compression-stats tests to
      ForwardIndexHandlerCompressionStatsTest.
    - Fold the duplicate codecSpec validation cases into the existing
      TableConfigUtilsTest case, and drop the no-op noDictionaryColumns case
      and its TableConfigUtils comment.
    - Use static Mockito imports and the DataType import in
      ForwardIndexTypeTest, and the non-deprecated OpenStructIndexConfig
      constructor in the new OPEN_STRUCT tests.
    
    ---------
    
    Co-authored-by: intentlab-ai[bot] 
<intentlab-ai[bot]@users.noreply.github.com>
---
 .../ColumnCompressionStatsContribution.java        |   3 +-
 .../resources/ColumnCompressionStatsInfo.java      |   3 +-
 .../impl/FixedByteChunkForwardIndexWriterV7.java   |   6 +
 ...ompressionStatsTrackingForwardIndexCreator.java |   3 +-
 .../fwd/SingleValueFixedByteRawIndexCreator.java   |  13 +-
 .../impl/openstruct/OpenStructColumnSplitter.java  |   8 ++
 .../index/forward/CompressionStatsMetadata.java    |   6 +-
 .../index/openstruct/OpenStructIndexType.java      |  47 ++++---
 .../openstruct/OpenStructColumnSplitterTest.java   |  39 ++++++
 .../forward/CompressionStatsMetadataTest.java      |   6 +
 .../forward/ForwardIndexCreatorFactoryTest.java    |  13 +-
 .../index/forward/ForwardIndexTypeTest.java        |  35 +++++
 .../ForwardIndexHandlerCompressionStatsTest.java   | 145 ++++++++++++++++++++-
 .../index/openstruct/OpenStructIndexTypeTest.java  |  50 +++++++
 .../segment/local/utils/TableConfigUtilsTest.java  |  18 +++
 .../resources/SegmentCompressionStatsReader.java   |   2 +-
 .../SegmentCompressionStatsReaderTest.java         |  77 +++++++++++
 .../apache/pinot/spi/config/table/FieldConfig.java |  34 ++++-
 .../spi/config/table/OpenStructIndexConfig.java    |   4 +-
 19 files changed, 475 insertions(+), 37 deletions(-)

diff --git 
a/pinot-common/src/main/java/org/apache/pinot/common/restlet/resources/ColumnCompressionStatsContribution.java
 
b/pinot-common/src/main/java/org/apache/pinot/common/restlet/resources/ColumnCompressionStatsContribution.java
index cce98a1ab5f..240df22bc5c 100644
--- 
a/pinot-common/src/main/java/org/apache/pinot/common/restlet/resources/ColumnCompressionStatsContribution.java
+++ 
b/pinot-common/src/main/java/org/apache/pinot/common/restlet/resources/ColumnCompressionStatsContribution.java
@@ -119,7 +119,8 @@ public final class ColumnCompressionStatsContribution {
       return _encoding;
     }
 
-    /// Returns the raw forward-index chunk-compression type, or null for 
dictionary encoding.
+    /// Returns the raw forward-index chunk-compression type, or null for 
dictionary encoding and raw codec-pipeline
+    /// formats that cannot be represented by one legacy 
[ChunkCompressionType].
     @Nullable
     @JsonInclude(JsonInclude.Include.NON_NULL)
     public ChunkCompressionType getChunkCompressionType() {
diff --git 
a/pinot-common/src/main/java/org/apache/pinot/common/restlet/resources/ColumnCompressionStatsInfo.java
 
b/pinot-common/src/main/java/org/apache/pinot/common/restlet/resources/ColumnCompressionStatsInfo.java
index 5ea44700c35..943876cbae7 100644
--- 
a/pinot-common/src/main/java/org/apache/pinot/common/restlet/resources/ColumnCompressionStatsInfo.java
+++ 
b/pinot-common/src/main/java/org/apache/pinot/common/restlet/resources/ColumnCompressionStatsInfo.java
@@ -187,7 +187,8 @@ public final class ColumnCompressionStatsInfo {
       return _encoding;
     }
 
-    /// Returns the raw forward-index chunk-compression type, or null for 
dictionary encoding.
+    /// Returns the raw forward-index chunk-compression type, or null for 
dictionary encoding and raw codec-pipeline
+    /// formats that cannot be represented by one legacy 
[ChunkCompressionType].
     @Nullable
     @JsonInclude(JsonInclude.Include.NON_NULL)
     public ChunkCompressionType getChunkCompressionType() {
diff --git 
a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/io/writer/impl/FixedByteChunkForwardIndexWriterV7.java
 
b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/io/writer/impl/FixedByteChunkForwardIndexWriterV7.java
index a35dbf6687c..ca254bea315 100644
--- 
a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/io/writer/impl/FixedByteChunkForwardIndexWriterV7.java
+++ 
b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/io/writer/impl/FixedByteChunkForwardIndexWriterV7.java
@@ -255,6 +255,12 @@ public class FixedByteChunkForwardIndexWriterV7 implements 
FixedByteChunkWriter
     throw new UnsupportedOperationException("V7 codec-pipeline writer does not 
support DOUBLE");
   }
 
+  /// Returns the value bytes written so far (`docsWritten * sizeOfEntry`), 
including values in the chunk that has
+  /// not been flushed yet. These are the bytes presented to the codec 
pipeline before any transform or compression.
+  public long getUncompressedValueSizeInBytes() {
+    return (long) _docsWritten * _sizeOfEntry;
+  }
+
   /// Fail fast at write time if the caller would exceed the declared 
`totalDocs`. Without this
   /// guard the writer keeps producing chunks past the declared length and 
only `close()` catches
   /// the mismatch, leaving a semantically-invalid partial file behind.
diff --git 
a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/creator/impl/fwd/CompressionStatsTrackingForwardIndexCreator.java
 
b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/creator/impl/fwd/CompressionStatsTrackingForwardIndexCreator.java
index 6550f6553e6..8fc541dfb44 100644
--- 
a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/creator/impl/fwd/CompressionStatsTrackingForwardIndexCreator.java
+++ 
b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/creator/impl/fwd/CompressionStatsTrackingForwardIndexCreator.java
@@ -34,7 +34,8 @@ public interface CompressionStatsTrackingForwardIndexCreator 
extends ForwardInde
   @Override
   long getRawForwardIndexUncompressedValueSizeInBytes();
 
-  /// Returns the raw forward-index chunk-compression type, or null when 
tracking is disabled.
+  /// Returns the raw forward-index chunk-compression type, or null when 
tracking is disabled or the
+  /// raw format cannot be represented by one legacy [ChunkCompressionType].
   @Override
   @Nullable
   ChunkCompressionType getRawForwardIndexChunkCompressionType();
diff --git 
a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/creator/impl/fwd/SingleValueFixedByteRawIndexCreator.java
 
b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/creator/impl/fwd/SingleValueFixedByteRawIndexCreator.java
index 77bc481de89..b7e9a712e00 100644
--- 
a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/creator/impl/fwd/SingleValueFixedByteRawIndexCreator.java
+++ 
b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/creator/impl/fwd/SingleValueFixedByteRawIndexCreator.java
@@ -20,6 +20,7 @@ package 
org.apache.pinot.segment.local.segment.creator.impl.fwd;
 
 import java.io.File;
 import java.io.IOException;
+import javax.annotation.Nullable;
 import org.apache.pinot.segment.local.io.codec.CodecPipelineExecutor;
 import 
org.apache.pinot.segment.local.io.writer.impl.FixedByteChunkForwardIndexWriter;
 import 
org.apache.pinot.segment.local.io.writer.impl.FixedByteChunkForwardIndexWriterV7;
@@ -40,7 +41,11 @@ import org.apache.pinot.spi.data.FieldSpec.DataType;
 public class SingleValueFixedByteRawIndexCreator implements 
CompressionStatsTrackingForwardIndexCreator {
   private final FixedByteChunkWriter _indexWriter;
   private final DataType _valueType;
+  @Nullable
   private final ChunkCompressionType _chunkCompressionType;
+  // Whether compression-statistics tracking was requested. Mirrors the legacy 
writer's contract of
+  // reporting an uncompressed value size only when tracking is enabled.
+  private boolean _trackUncompressedValueSize;
 
   /// Constructor for the class
   ///
@@ -138,20 +143,26 @@ public class SingleValueFixedByteRawIndexCreator 
implements CompressionStatsTrac
 
   @Override
   public long getRawForwardIndexUncompressedValueSizeInBytes() {
-    // Compression-statistics metadata supports only the legacy 
single-compressor format.
     if (_indexWriter instanceof FixedByteChunkForwardIndexWriter legacyWriter) 
{
       return legacyWriter.getRawForwardIndexUncompressedValueSizeInBytes();
     }
+    // V7 codec-pipeline writer: like the legacy writer, report the value 
bytes actually written, or -1 unless
+    // tracking was requested.
+    if (_trackUncompressedValueSize && _indexWriter instanceof 
FixedByteChunkForwardIndexWriterV7 v7Writer) {
+      return v7Writer.getUncompressedValueSizeInBytes();
+    }
     return -1;
   }
 
   @Override
+  @Nullable
   public ChunkCompressionType getRawForwardIndexChunkCompressionType() {
     return _chunkCompressionType;
   }
 
   @Override
   public void enableRawForwardIndexUncompressedValueSizeTracking() {
+    _trackUncompressedValueSize = true;
     if (_indexWriter instanceof FixedByteChunkForwardIndexWriter legacyWriter) 
{
       legacyWriter.enableRawForwardIndexUncompressedValueSizeTracking();
     }
diff --git 
a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/creator/impl/openstruct/OpenStructColumnSplitter.java
 
b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/creator/impl/openstruct/OpenStructColumnSplitter.java
index d86cd641b9f..79664a04f22 100644
--- 
a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/creator/impl/openstruct/OpenStructColumnSplitter.java
+++ 
b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/creator/impl/openstruct/OpenStructColumnSplitter.java
@@ -18,6 +18,7 @@
  */
 package org.apache.pinot.segment.local.segment.creator.impl.openstruct;
 
+import com.google.common.base.Preconditions;
 import com.google.common.base.Utf8;
 import java.io.File;
 import java.io.IOException;
@@ -543,6 +544,13 @@ public class OpenStructColumnSplitter implements 
ColumnarOpenStructIndexCreator
 
     boolean useDictionary = resolveUseDictionary(childFieldSpec, 
configsForDecision, statsCollector);
 
+    // Defense-in-depth mirror of OpenStructIndexType.validatePerKeyIndexes: 
the child forward config built
+    // below discards any per-key codecSpec, so refuse to silently drop one 
that slipped past validation.
+    ForwardIndexConfig configuredForwardIndex = 
configsForDecision.getConfig(StandardIndexes.forward());
+    Preconditions.checkState(configuredForwardIndex.getCodecSpec() == null,
+        "OPEN_STRUCT column '%s': codecSpec is not supported for key '%s'; 
materialized keys always use a "
+            + "dictionary-encoded or LZ4 raw forward index", _columnName, key);
+
     // Reconcile dictionary + forward encoding with the final decision 
(mirrors BaseSegmentCreator.adaptConfig);
     // ForwardIndexCreatorFactory selects dict-vs-raw from the forward 
config's EncodingType. A compression codec
     // applies only to the raw forward format (LZ4 preserves the dense child's 
current on-disk layout); attaching
diff --git 
a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/index/forward/CompressionStatsMetadata.java
 
b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/index/forward/CompressionStatsMetadata.java
index 42329efa424..95fe70bb1af 100644
--- 
a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/index/forward/CompressionStatsMetadata.java
+++ 
b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/index/forward/CompressionStatsMetadata.java
@@ -47,10 +47,12 @@ public final class CompressionStatsMetadata {
     _dictionaryEncodedUncompressedValueSizeInBytes = 
dictionaryUncompressedValueSizeInBytes;
   }
 
-  /// Creates metadata for a raw forward index, or unavailable metadata when 
either required value is absent.
+  /// Creates metadata for a raw forward index, or unavailable metadata when 
the size was not tracked. The
+  /// compression type is nullable because codec-pipeline V7 indexes do not 
have one legacy
+  /// [ChunkCompressionType]; their uncompressed size is still valid and must 
remain reportable.
   public static CompressionStatsMetadata forRawForwardIndex(long 
uncompressedValueSizeInBytes,
       @Nullable ChunkCompressionType chunkCompressionType) {
-    return uncompressedValueSizeInBytes >= 0 && chunkCompressionType != null
+    return uncompressedValueSizeInBytes >= 0
         ? new CompressionStatsMetadata(uncompressedValueSizeInBytes, 
chunkCompressionType, null) : UNAVAILABLE;
   }
 
diff --git 
a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/index/openstruct/OpenStructIndexType.java
 
b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/index/openstruct/OpenStructIndexType.java
index ebdad5e4afe..5dfcd8c72e8 100644
--- 
a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/index/openstruct/OpenStructIndexType.java
+++ 
b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/index/openstruct/OpenStructIndexType.java
@@ -20,7 +20,6 @@ package 
org.apache.pinot.segment.local.segment.index.openstruct;
 
 import com.fasterxml.jackson.databind.JsonNode;
 import com.google.common.base.Preconditions;
-import java.util.ArrayList;
 import java.util.Iterator;
 import java.util.List;
 import java.util.Map;
@@ -86,7 +85,7 @@ public class OpenStructIndexType
       Preconditions.checkState(fieldSpec.isSingleValueField(),
           "OPEN_STRUCT index can only be created on single-value columns, but 
column '%s' is multi-value",
           fieldSpec.getName());
-      validatePerKeyIndexes(config);
+      validatePerKeyIndexes(config, fieldSpec.getName());
       validateIgnoredKeys(config, fieldSpec);
       if (fieldSpec instanceof ComplexFieldSpec) {
         validateChildFieldSpecTypes((ComplexFieldSpec) fieldSpec);
@@ -124,26 +123,36 @@ public class OpenStructIndexType
     }
   }
 
-  private void validatePerKeyIndexes(OpenStructIndexConfig config) {
-    List<FieldConfig> fieldConfigs = new ArrayList<>();
+  private void validatePerKeyIndexes(OpenStructIndexConfig config, String 
column) {
     if (config.getValueFieldConfigs() != null) {
-      fieldConfigs.addAll(config.getValueFieldConfigs());
+      for (FieldConfig fieldConfig : config.getValueFieldConfigs()) {
+        validatePerKeyFieldConfig(fieldConfig, column, "key '" + 
fieldConfig.getName() + "'");
+      }
     }
-    if (config.getDefaultValueFieldConfig() != null) {
-      fieldConfigs.add(config.getDefaultValueFieldConfig());
+    FieldConfig defaultValueFieldConfig = config.getDefaultValueFieldConfig();
+    if (defaultValueFieldConfig != null) {
+      validatePerKeyFieldConfig(defaultValueFieldConfig, column, 
"defaultValueFieldConfig");
     }
-    for (FieldConfig fieldConfig : fieldConfigs) {
-      JsonNode indexes = fieldConfig.getIndexes();
-      if (indexes == null) {
-        continue;
-      }
-      Iterator<String> indexNames = indexes.fieldNames();
-      while (indexNames.hasNext()) {
-        String indexName = indexNames.next();
-        
Preconditions.checkState(OpenStructSupportedIndexes.ALLOWED_PRETTY_NAMES.contains(indexName),
-            "OPEN_STRUCT key '%s' declares unsupported index '%s'; supported 
indexes are %s",
-            fieldConfig.getName(), indexName, 
OpenStructSupportedIndexes.ALLOWED_PRETTY_NAMES);
-      }
+  }
+
+  /// Validates one per-key [FieldConfig] of OPEN_STRUCT `column`; `target` 
names it in error messages.
+  private static void validatePerKeyFieldConfig(FieldConfig fieldConfig, 
String column, String target) {
+    JsonNode indexes = fieldConfig.getIndexes();
+    if (indexes == null) {
+      return;
+    }
+    JsonNode forwardIndex = 
indexes.get(StandardIndexes.forward().getPrettyName());
+    // The OPEN_STRUCT splitter builds its own per-key forward-index configs 
(dict-vs-raw decision plus a
+    // fixed LZ4 raw compression), so a per-key codecSpec would be silently 
discarded. Reject it explicitly.
+    Preconditions.checkState(forwardIndex == null || 
!forwardIndex.hasNonNull("codecSpec"),
+        "OPEN_STRUCT column '%s': codecSpec is not supported for %s; 
materialized keys always use a "
+            + "dictionary-encoded or LZ4 raw forward index", column, target);
+    Iterator<String> indexNames = indexes.fieldNames();
+    while (indexNames.hasNext()) {
+      String indexName = indexNames.next();
+      
Preconditions.checkState(OpenStructSupportedIndexes.ALLOWED_PRETTY_NAMES.contains(indexName),
+          "OPEN_STRUCT key '%s' declares unsupported index '%s'; supported 
indexes are %s",
+          fieldConfig.getName(), indexName, 
OpenStructSupportedIndexes.ALLOWED_PRETTY_NAMES);
     }
   }
 
diff --git 
a/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/segment/creator/impl/openstruct/OpenStructColumnSplitterTest.java
 
b/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/segment/creator/impl/openstruct/OpenStructColumnSplitterTest.java
index 03f97a38213..22057b34f98 100644
--- 
a/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/segment/creator/impl/openstruct/OpenStructColumnSplitterTest.java
+++ 
b/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/segment/creator/impl/openstruct/OpenStructColumnSplitterTest.java
@@ -20,6 +20,7 @@ package 
org.apache.pinot.segment.local.segment.creator.impl.openstruct;
 
 import com.fasterxml.jackson.core.type.TypeReference;
 import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.node.ObjectNode;
 import java.io.File;
 import java.math.BigDecimal;
 import java.nio.file.Files;
@@ -51,6 +52,7 @@ import org.apache.pinot.spi.utils.JsonUtils;
 import org.roaringbitmap.buffer.MutableRoaringBitmap;
 import org.testng.annotations.AfterMethod;
 import org.testng.annotations.BeforeMethod;
+import org.testng.annotations.DataProvider;
 import org.testng.annotations.Test;
 
 import static org.mockito.ArgumentMatchers.anyLong;
@@ -65,6 +67,7 @@ import static org.testng.Assert.assertFalse;
 import static org.testng.Assert.assertNotNull;
 import static org.testng.Assert.assertThrows;
 import static org.testng.Assert.assertTrue;
+import static org.testng.Assert.expectThrows;
 
 
 public class OpenStructColumnSplitterTest {
@@ -97,6 +100,16 @@ public class OpenStructColumnSplitterTest {
         perKeyMetricsEnabled);
   }
 
+  @DataProvider(name = "codecSpecOpenStructConfigs")
+  public Object[][] codecSpecOpenStructConfigs() {
+    FieldConfig valueFieldConfig = rawCodecSpecFieldConfig("clicks");
+    FieldConfig defaultValueFieldConfig = rawCodecSpecFieldConfig("default");
+    return new Object[][]{
+        {new OpenStructIndexConfig(false, null, -1, null, 0.5, 
List.of(valueFieldConfig), null, null, null)},
+        {new OpenStructIndexConfig(false, defaultValueFieldConfig, -1, null, 
0.5, null, null, null, null)}
+    };
+  }
+
   @Test
   public void testClassifyByFillRate()
       throws Exception {
@@ -410,6 +423,32 @@ public class OpenStructColumnSplitterTest {
         denseCol + 
V1Constants.Indexes.RAW_SV_FORWARD_INDEX_FILE_EXTENSION).exists());
   }
 
+  @Test(dataProvider = "codecSpecOpenStructConfigs")
+  public void 
testSealRejectsCodecSpecBeforeReplacingChildForwardConfig(OpenStructIndexConfig 
openStructConfig)
+      throws Exception {
+    OpenStructColumnSplitter splitter = new OpenStructColumnSplitter(
+        _tempDir, "metrics", "testTable_OFFLINE", spec(), openStructConfig);
+    for (int docId = 0; docId < 10; docId++) {
+      splitter.add(Map.of("clicks", docId), docId);
+    }
+
+    IllegalStateException exception = 
expectThrows(IllegalStateException.class, splitter::seal);
+    assertEquals(exception.getMessage(), "OPEN_STRUCT column 'metrics': 
codecSpec is not supported for key 'clicks'; "
+        + "materialized keys always use a dictionary-encoded or LZ4 raw 
forward index");
+  }
+
+  private static FieldConfig rawCodecSpecFieldConfig(String name) {
+    ObjectNode forward = JsonUtils.newObjectNode();
+    forward.put("encodingType", FieldConfig.EncodingType.RAW.name());
+    forward.put("codecSpec", "LZ4");
+    ObjectNode indexes = JsonUtils.newObjectNode();
+    indexes.set("forward", forward);
+    return new FieldConfig.Builder(name)
+        .withEncodingType(FieldConfig.EncodingType.RAW)
+        .withIndexes(indexes)
+        .build();
+  }
+
   @Test
   public void testRangeAndBloomIndexesWrittenForKey()
       throws Exception {
diff --git 
a/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/segment/index/forward/CompressionStatsMetadataTest.java
 
b/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/segment/index/forward/CompressionStatsMetadataTest.java
index 88aea24a722..e9cba4ca115 100644
--- 
a/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/segment/index/forward/CompressionStatsMetadataTest.java
+++ 
b/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/segment/index/forward/CompressionStatsMetadataTest.java
@@ -51,6 +51,12 @@ public class CompressionStatsMetadataTest {
     assertNull(
         properties.get(getKeyFor(COLUMN, 
FORWARD_INDEX_DICTIONARY_ENCODED_UNCOMPRESSED_VALUE_SIZE_IN_BYTES)));
 
+    CompressionStatsMetadata.forRawForwardIndex(64, null).applyTo(properties, 
COLUMN);
+    assertEquals(properties.get(getKeyFor(COLUMN, 
FORWARD_INDEX_RAW_UNCOMPRESSED_VALUE_SIZE_IN_BYTES)), "64");
+    assertNull(properties.get(getKeyFor(COLUMN, 
FORWARD_INDEX_RAW_CHUNK_COMPRESSION_TYPE)));
+    assertNull(
+        properties.get(getKeyFor(COLUMN, 
FORWARD_INDEX_DICTIONARY_ENCODED_UNCOMPRESSED_VALUE_SIZE_IN_BYTES)));
+
     CompressionStatsMetadata.unavailable().applyTo(properties, COLUMN);
     assertNull(properties.get(getKeyFor(COLUMN, 
FORWARD_INDEX_RAW_UNCOMPRESSED_VALUE_SIZE_IN_BYTES)));
     assertNull(properties.get(getKeyFor(COLUMN, 
FORWARD_INDEX_RAW_CHUNK_COMPRESSION_TYPE)));
diff --git 
a/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/segment/index/forward/ForwardIndexCreatorFactoryTest.java
 
b/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/segment/index/forward/ForwardIndexCreatorFactoryTest.java
index 85f3d963f62..8da8be2c939 100644
--- 
a/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/segment/index/forward/ForwardIndexCreatorFactoryTest.java
+++ 
b/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/segment/index/forward/ForwardIndexCreatorFactoryTest.java
@@ -138,7 +138,8 @@ public class ForwardIndexCreatorFactoryTest {
       long[] values = storedType == DataType.INT
           ? new long[]{11, 13, 21}
           : new long[]{Long.MIN_VALUE, (long) Integer.MAX_VALUE + 1, 
Long.MAX_VALUE};
-      // Legacy compression stats are opted in here on purpose: a codecSpec 
column never reports them.
+      // Compression stats are opted in here on purpose: a V7 codecSpec column 
reports its uncompressed
+      // value size (values written * storedType.size()) even though it has no 
legacy ChunkCompressionType.
       File indexFile = roundTripCodecSpecIndex(indexDir, codecSpec, 
storedType, 2, values, true, 0);
       try (PinotDataBuffer buffer = 
PinotDataBuffer.mapReadOnlyBigEndianFile(indexFile)) {
         assertEquals(buffer.getInt(0), 
FixedByteChunkSVForwardIndexReaderV7.VERSION);
@@ -188,9 +189,10 @@ public class ForwardIndexCreatorFactoryTest {
   /// Writes `values` through a `codecSpec` forward-index creator and reads 
every one of them back, then
   /// returns the index file so the caller can assert on its header. Covers 
the parts both codecSpec round
   /// trips share: the creator contract that holds for any codecSpec column 
(not dictionary encoded, no
-  /// legacy chunk compression type, and no legacy uncompressed value size 
even when the table opts into
-  /// compression stats), that the pipeline routed the file to the V7 reader, 
and a sequential read of every
-  /// value. `randomReads` further reads land on random doc ids through a 
second, random-access context.
+  /// legacy chunk compression type, and an uncompressed value size of 
`values.length * storedType.size()` when
+  /// the table opts into compression stats and UNAVAILABLE when it does not), 
that the pipeline routed the
+  /// file to the V7 reader, and a sequential read of every value. 
`randomReads` further reads land on
+  /// random doc ids through a second, random-access context.
   private static File roundTripCodecSpecIndex(File indexDir, String codecSpec, 
DataType storedType,
       int targetDocsPerChunk, long[] values, boolean compressionStatsEnabled, 
int randomReads)
       throws Exception {
@@ -210,7 +212,8 @@ public class ForwardIndexCreatorFactoryTest {
         }
       }
       creator.seal();
-      assertEquals(creator.getRawForwardIndexUncompressedValueSizeInBytes(), 
-1L);
+      assertEquals(creator.getRawForwardIndexUncompressedValueSizeInBytes(),
+          compressionStatsEnabled ? (long) values.length * storedType.size() : 
-1L);
     }
     File indexFile = new File(indexDir, COLUMN_NAME + 
V1Constants.Indexes.RAW_SV_FORWARD_INDEX_FILE_EXTENSION);
     assertTrue(indexFile.exists());
diff --git 
a/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/segment/index/forward/ForwardIndexTypeTest.java
 
b/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/segment/index/forward/ForwardIndexTypeTest.java
index 43fde236a76..765f8ef7d76 100644
--- 
a/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/segment/index/forward/ForwardIndexTypeTest.java
+++ 
b/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/segment/index/forward/ForwardIndexTypeTest.java
@@ -25,17 +25,25 @@ import java.io.IOException;
 import java.util.Arrays;
 import java.util.Map;
 import java.util.stream.Collectors;
+import org.apache.pinot.segment.local.io.writer.impl.DirectMemoryManager;
+import 
org.apache.pinot.segment.local.realtime.impl.forward.FixedByteSVMutableForwardIndex;
 import org.apache.pinot.segment.local.segment.index.AbstractSerdeIndexContract;
 import org.apache.pinot.segment.spi.compression.ChunkCompressionType;
 import org.apache.pinot.segment.spi.compression.DictIdCompressionType;
 import org.apache.pinot.segment.spi.index.ForwardIndexConfig;
 import org.apache.pinot.segment.spi.index.StandardIndexes;
+import org.apache.pinot.segment.spi.index.mutable.MutableIndex;
+import org.apache.pinot.segment.spi.index.mutable.provider.MutableIndexContext;
 import org.apache.pinot.spi.config.table.FieldConfig;
+import org.apache.pinot.spi.data.DimensionFieldSpec;
+import org.apache.pinot.spi.data.FieldSpec.DataType;
 import org.apache.pinot.spi.utils.JsonUtils;
 import org.testng.Assert;
 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.assertFalse;
 import static org.testng.Assert.assertNotNull;
 import static org.testng.Assert.assertSame;
@@ -495,4 +503,31 @@ public class ForwardIndexTypeTest {
     assertSame(StandardIndexes.forward(), StandardIndexes.forward(), "Standard 
index should use the same as "
         + "the ForwardIndexType static instance");
   }
+
+  /// codecSpec applies only at immutable segment creation/conversion time. 
The mutable (consuming)
+  /// forward index must build the standard in-memory format, ignoring the 
configured codecSpec, so
+  /// realtime tables with a codecSpec keep consuming normally.
+  @Test
+  public void testCodecSpecBuildsStandardMutableIndexForRealtime()
+      throws Exception {
+    MutableIndexContext context = mock(MutableIndexContext.class);
+    when(context.getFieldSpec()).thenReturn(new DimensionFieldSpec("dimInt", 
DataType.INT, true));
+    when(context.getSegmentName()).thenReturn("testSegment");
+    when(context.getCapacity()).thenReturn(16);
+    ForwardIndexConfig config = new 
ForwardIndexConfig.Builder(FieldConfig.EncodingType.RAW)
+        .withCodecSpec("DELTA,LZ4")
+        .build();
+
+    try (DirectMemoryManager memoryManager = new 
DirectMemoryManager("testSegment")) {
+      when(context.getMemoryManager()).thenReturn(memoryManager);
+      MutableIndex mutableIndex = 
StandardIndexes.forward().createMutableIndex(context, config);
+      assertNotNull(mutableIndex);
+      try {
+        assertTrue(mutableIndex instanceof FixedByteSVMutableForwardIndex,
+            "Expected the standard SV mutable forward index, got: " + 
mutableIndex.getClass());
+      } finally {
+        mutableIndex.close();
+      }
+    }
+  }
 }
diff --git 
a/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/segment/index/loader/ForwardIndexHandlerCompressionStatsTest.java
 
b/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/segment/index/loader/ForwardIndexHandlerCompressionStatsTest.java
index 369bb5400db..9ac7e03fd5b 100644
--- 
a/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/segment/index/loader/ForwardIndexHandlerCompressionStatsTest.java
+++ 
b/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/segment/index/loader/ForwardIndexHandlerCompressionStatsTest.java
@@ -27,15 +27,19 @@ import java.util.List;
 import java.util.Map;
 import java.util.Random;
 import java.util.Set;
+import javax.annotation.Nullable;
 import org.apache.commons.io.FileUtils;
 import 
org.apache.pinot.segment.local.segment.creator.impl.SegmentIndexCreationDriverImpl;
+import 
org.apache.pinot.segment.local.segment.index.readers.forward.FixedByteChunkSVForwardIndexReaderV7;
 import org.apache.pinot.segment.local.segment.readers.GenericRowRecordReader;
 import org.apache.pinot.segment.local.segment.store.SegmentLocalFSDirectory;
 import org.apache.pinot.segment.spi.ColumnMetadata;
 import org.apache.pinot.segment.spi.V1Constants;
 import org.apache.pinot.segment.spi.compression.ChunkCompressionType;
 import org.apache.pinot.segment.spi.creator.SegmentGeneratorConfig;
+import org.apache.pinot.segment.spi.index.StandardIndexes;
 import org.apache.pinot.segment.spi.index.metadata.SegmentMetadataImpl;
+import org.apache.pinot.segment.spi.memory.PinotDataBuffer;
 import org.apache.pinot.segment.spi.store.SegmentDirectory;
 import org.apache.pinot.segment.spi.utils.SegmentMetadataUtils;
 import org.apache.pinot.spi.config.table.FieldConfig;
@@ -57,8 +61,8 @@ import static org.testng.Assert.*;
 
 /// Tests compression metadata persistence across [ForwardIndexHandler] reload 
operations.
 ///
-/// Coverage includes chunk-compression changes, dictionary/raw conversions, 
forward-index disable/re-enable, and
-/// stale metadata cleanup.
+/// Coverage includes chunk-compression changes, codec-pipeline (V7) rewrites 
and rollback, dictionary/raw
+/// conversions, forward-index disable/re-enable, and stale metadata cleanup.
 public class ForwardIndexHandlerCompressionStatsTest {
   private static final String RAW_TABLE_NAME = "compressionStatsReloadTest";
   private static final String SEGMENT_NAME = "compressionStatsReloadSegment";
@@ -604,4 +608,141 @@ public class ForwardIndexHandlerCompressionStatsTest {
     assertTrue(dictMeta.getDictionaryEncodedUncompressedValueSizeInBytes() > 0,
         "Dictionary uncompressed value size should be collected during 
raw-to-dictionary conversion");
   }
+
+  @Test
+  public void testLegacyToCodecSpecRewritePersistsRawSizeWithoutChunkType()
+      throws Exception {
+    ColumnMetadata legacyMeta = new 
SegmentMetadataImpl(INDEX_DIR).getColumnMetadataFor(RAW_INT_COL);
+    assertEquals(legacyMeta.getRawForwardIndexChunkCompressionType(), 
ChunkCompressionType.SNAPPY);
+    assertEquals(legacyMeta.getRawForwardIndexUncompressedValueSizeInBytes(), 
(long) NUM_ROWS * Integer.BYTES);
+
+    _fieldConfigMap.put(RAW_INT_COL, codecSpecFieldConfig(RAW_INT_COL, 
"DELTA,ZSTD(3)", false));
+    updateForwardIndex(createIndexLoadingConfig());
+
+    // A V7 index has no single legacy chunk-compression type, but its 
uncompressed value size is still reported.
+    assertEquals(readCodecSpec(RAW_INT_COL), "DELTA,ZSTD(3)");
+    ColumnMetadata v7Meta = new 
SegmentMetadataImpl(INDEX_DIR).getColumnMetadataFor(RAW_INT_COL);
+    assertNull(v7Meta.getRawForwardIndexChunkCompressionType());
+    assertEquals(v7Meta.getRawForwardIndexUncompressedValueSizeInBytes(), 
(long) NUM_ROWS * Integer.BYTES);
+  }
+
+  @Test
+  public void testCodecSpecChangeAndLegacyRollbackPersistRawStats()
+      throws Exception {
+    _fieldConfigMap.put(RAW_INT_COL, codecSpecFieldConfig(RAW_INT_COL, 
"DELTA,LZ4", false));
+    updateForwardIndex(createIndexLoadingConfig());
+    _fieldConfigMap.put(RAW_INT_COL, codecSpecFieldConfig(RAW_INT_COL, 
"T64,LZ4", false));
+    updateForwardIndex(createIndexLoadingConfig());
+
+    assertEquals(readCodecSpec(RAW_INT_COL), "T64,LZ4");
+    ColumnMetadata v7Meta = new 
SegmentMetadataImpl(INDEX_DIR).getColumnMetadataFor(RAW_INT_COL);
+    assertNull(v7Meta.getRawForwardIndexChunkCompressionType());
+    assertEquals(v7Meta.getRawForwardIndexUncompressedValueSizeInBytes(), 
(long) NUM_ROWS * Integer.BYTES);
+
+    // Rolling back to a legacy codec restores the legacy chunk-compression 
type alongside the size.
+    _fieldConfigMap.put(RAW_INT_COL,
+        new FieldConfig(RAW_INT_COL, FieldConfig.EncodingType.RAW, List.of(), 
CompressionCodec.LZ4, null));
+    updateForwardIndex(createIndexLoadingConfig());
+
+    assertNull(readCodecSpec(RAW_INT_COL));
+    ColumnMetadata legacyMeta = new 
SegmentMetadataImpl(INDEX_DIR).getColumnMetadataFor(RAW_INT_COL);
+    assertEquals(legacyMeta.getRawForwardIndexChunkCompressionType(), 
ChunkCompressionType.LZ4);
+    assertEquals(legacyMeta.getRawForwardIndexUncompressedValueSizeInBytes(), 
(long) NUM_ROWS * Integer.BYTES);
+  }
+
+  @Test
+  public void testDictToCodecSpecPersistsRawStats()
+      throws Exception {
+    _noDictionaryColumns.add(DICT_INT_COL);
+    _fieldConfigMap.put(DICT_INT_COL, codecSpecFieldConfig(DICT_INT_COL, 
"DELTA,LZ4", false));
+    updateForwardIndex(createIndexLoadingConfig());
+
+    assertEquals(readCodecSpec(DICT_INT_COL), "DELTA,LZ4");
+    ColumnMetadata colMeta = new 
SegmentMetadataImpl(INDEX_DIR).getColumnMetadataFor(DICT_INT_COL);
+    assertFalse(colMeta.hasDictionary());
+    assertNull(colMeta.getRawForwardIndexChunkCompressionType());
+    assertEquals(colMeta.getRawForwardIndexUncompressedValueSizeInBytes(), 
(long) NUM_ROWS * Integer.BYTES);
+    assertEquals(colMeta.getDictionaryEncodedUncompressedValueSizeInBytes(), 
ColumnMetadata.UNAVAILABLE);
+  }
+
+  @Test
+  public void testEnableDictionaryOnCodecSpecColumnKeepsRawStats()
+      throws Exception {
+    _fieldConfigMap.put(RAW_INT_COL, codecSpecFieldConfig(RAW_INT_COL, 
"DELTA,LZ4", false));
+    updateForwardIndex(createIndexLoadingConfig());
+
+    // Adding a dictionary keeps the V7 raw forward index, so its raw stats 
are carried over unchanged.
+    _noDictionaryColumns.remove(RAW_INT_COL);
+    _fieldConfigMap.put(RAW_INT_COL, codecSpecFieldConfig(RAW_INT_COL, 
"DELTA,LZ4", true));
+    try (SegmentDirectory segmentDirectory = new 
SegmentLocalFSDirectory(INDEX_DIR, ReadMode.mmap);
+        SegmentDirectory.Writer writer = segmentDirectory.createWriter()) {
+      ForwardIndexHandler handler = new ForwardIndexHandler(segmentDirectory, 
createIndexLoadingConfig());
+      assertEquals(handler.computeOperations(writer),
+          Map.of(RAW_INT_COL, 
List.of(ForwardIndexHandler.Operation.ENABLE_DICTIONARY)));
+      handler.updateIndices(writer);
+      handler.postUpdateIndicesCleanup(writer);
+    }
+
+    assertEquals(readCodecSpec(RAW_INT_COL), "DELTA,LZ4");
+    ColumnMetadata colMeta = new 
SegmentMetadataImpl(INDEX_DIR).getColumnMetadataFor(RAW_INT_COL);
+    assertTrue(colMeta.hasDictionary());
+    assertEquals(colMeta.getForwardIndexEncoding(), 
FieldConfig.EncodingType.RAW);
+    assertNull(colMeta.getRawForwardIndexChunkCompressionType());
+    assertEquals(colMeta.getRawForwardIndexUncompressedValueSizeInBytes(), 
(long) NUM_ROWS * Integer.BYTES);
+  }
+
+  @Test
+  public void testCodecSpecRewriteClearsStatsWhenDisabled()
+      throws Exception {
+    _fieldConfigMap.put(RAW_INT_COL, codecSpecFieldConfig(RAW_INT_COL, 
"DELTA,LZ4", false));
+    TableConfig configWithStatsDisabled = new 
TableConfigBuilder(TableType.OFFLINE)
+        .setTableName(RAW_TABLE_NAME)
+        .setNoDictionaryColumns(new ArrayList<>(_noDictionaryColumns))
+        .setFieldConfigList(new ArrayList<>(_fieldConfigMap.values()))
+        .build();
+    updateForwardIndex(new IndexLoadingConfig(configWithStatsDisabled, 
SCHEMA));
+
+    assertEquals(readCodecSpec(RAW_INT_COL), "DELTA,LZ4");
+    ColumnMetadata colMeta = new 
SegmentMetadataImpl(INDEX_DIR).getColumnMetadataFor(RAW_INT_COL);
+    assertNull(colMeta.getRawForwardIndexChunkCompressionType());
+    assertEquals(colMeta.getRawForwardIndexUncompressedValueSizeInBytes(), 
ColumnMetadata.UNAVAILABLE);
+  }
+
+  /// Builds a RAW [FieldConfig] whose forward index uses `codecSpec`, 
optionally keeping a dictionary.
+  private static FieldConfig codecSpecFieldConfig(String column, String 
codecSpec, boolean withDictionary) {
+    ObjectNode forward = JsonUtils.newObjectNode();
+    forward.put("codecSpec", codecSpec);
+    ObjectNode indexes = JsonUtils.newObjectNode();
+    indexes.set("forward", forward);
+    if (withDictionary) {
+      indexes.set("dictionary", JsonUtils.newObjectNode());
+    }
+    return new FieldConfig.Builder(column)
+        .withEncodingType(FieldConfig.EncodingType.RAW)
+        .withIndexes(indexes)
+        .build();
+  }
+
+  private static void updateForwardIndex(IndexLoadingConfig indexLoadingConfig)
+      throws Exception {
+    try (SegmentDirectory segmentDirectory = new 
SegmentLocalFSDirectory(INDEX_DIR, ReadMode.mmap);
+        SegmentDirectory.Writer writer = segmentDirectory.createWriter()) {
+      ForwardIndexHandler handler = new ForwardIndexHandler(segmentDirectory, 
indexLoadingConfig);
+      assertTrue(handler.needUpdateIndices(writer), "Handler should detect the 
forward-index change");
+      handler.updateIndices(writer);
+      handler.postUpdateIndicesCleanup(writer);
+    }
+  }
+
+  /// Returns the canonical codec spec from the column's V7 forward-index 
header, or null for a legacy raw index.
+  @Nullable
+  private static String readCodecSpec(String column)
+      throws Exception {
+    try (SegmentDirectory segmentDirectory = new 
SegmentLocalFSDirectory(INDEX_DIR, ReadMode.mmap);
+        SegmentDirectory.Reader reader = segmentDirectory.createReader()) {
+      PinotDataBuffer buffer = reader.getIndexFor(column, 
StandardIndexes.forward());
+      return 
FixedByteChunkSVForwardIndexReaderV7.hasCodecPipelineHeader(buffer)
+          ? FixedByteChunkSVForwardIndexReaderV7.readCodecSpec(buffer) : null;
+    }
+  }
 }
diff --git 
a/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/segment/index/openstruct/OpenStructIndexTypeTest.java
 
b/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/segment/index/openstruct/OpenStructIndexTypeTest.java
index 1c91e348e9d..8baa99d66c5 100644
--- 
a/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/segment/index/openstruct/OpenStructIndexTypeTest.java
+++ 
b/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/segment/index/openstruct/OpenStructIndexTypeTest.java
@@ -19,27 +19,44 @@
 package org.apache.pinot.segment.local.segment.index.openstruct;
 
 import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.node.ObjectNode;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
+import org.apache.pinot.segment.local.utils.TableConfigUtils;
 import org.apache.pinot.segment.spi.index.FieldIndexConfigs;
 import org.apache.pinot.segment.spi.index.StandardIndexes;
 import org.apache.pinot.spi.config.table.FieldConfig;
 import org.apache.pinot.spi.config.table.OpenStructIndexConfig;
+import org.apache.pinot.spi.config.table.TableConfig;
+import org.apache.pinot.spi.config.table.TableType;
 import org.apache.pinot.spi.data.ComplexFieldSpec;
 import org.apache.pinot.spi.data.DimensionFieldSpec;
 import org.apache.pinot.spi.data.FieldSpec;
+import org.apache.pinot.spi.data.Schema;
 import org.apache.pinot.spi.utils.JsonUtils;
+import org.apache.pinot.spi.utils.builder.TableConfigBuilder;
+import org.testng.annotations.DataProvider;
 import org.testng.annotations.Test;
 
 import static org.testng.Assert.assertEquals;
 import static org.testng.Assert.assertNotNull;
 import static org.testng.Assert.assertSame;
 import static org.testng.Assert.assertThrows;
+import static org.testng.Assert.expectThrows;
 
 
 public class OpenStructIndexTypeTest {
 
+  @DataProvider(name = "codecSpecOpenStructConfigs")
+  public Object[][] codecSpecOpenStructConfigs() {
+    OpenStructIndexConfig valueConfig = new OpenStructIndexConfig(false, null, 
-1, null, 0.5,
+        List.of(rawCodecSpecFieldConfig("clicks")), null, null, null);
+    OpenStructIndexConfig defaultConfig = new OpenStructIndexConfig(false, 
rawCodecSpecFieldConfig("default"), -1,
+        null, 0.5, null, null, null, null);
+    return new Object[][]{{valueConfig, "key 'clicks'"}, {defaultConfig, 
"defaultValueFieldConfig"}};
+  }
+
   @Test
   public void testServiceLoaderResolves() {
     assertNotNull(StandardIndexes.openStruct(),
@@ -184,4 +201,37 @@ public class OpenStructIndexTypeTest {
     // Must not throw - validation is skipped entirely when the index is 
disabled.
     StandardIndexes.openStruct().validate(fieldIndexConfigs, openStructSpec, 
null);
   }
+
+  @Test(dataProvider = "codecSpecOpenStructConfigs")
+  public void 
testTableValidationRejectsCodecSpecForMaterializedChildren(OpenStructIndexConfig
 openStructConfig,
+      String expectedTarget) {
+    ObjectNode indexes = JsonUtils.newObjectNode();
+    indexes.set(StandardIndexes.openStruct().getPrettyName(), 
JsonUtils.objectToJsonNode(openStructConfig));
+    FieldConfig parentFieldConfig = new 
FieldConfig.Builder("payload").withIndexes(indexes).build();
+    TableConfig tableConfig = new TableConfigBuilder(TableType.OFFLINE)
+        .setTableName("openStructCodecSpecTest")
+        .setFieldConfigList(List.of(parentFieldConfig))
+        .build();
+    Schema schema = new Schema.SchemaBuilder()
+        .setSchemaName("openStructCodecSpecTest")
+        .addOpenStruct("payload", Map.of())
+        .build();
+
+    IllegalStateException exception = expectThrows(IllegalStateException.class,
+        () -> TableConfigUtils.validate(tableConfig, schema));
+    assertEquals(exception.getMessage(), "OPEN_STRUCT column 'payload': 
codecSpec is not supported for "
+        + expectedTarget + "; materialized keys always use a 
dictionary-encoded or LZ4 raw forward index");
+  }
+
+  private static FieldConfig rawCodecSpecFieldConfig(String name) {
+    ObjectNode forward = JsonUtils.newObjectNode();
+    forward.put("encodingType", FieldConfig.EncodingType.RAW.name());
+    forward.put("codecSpec", "LZ4");
+    ObjectNode indexes = JsonUtils.newObjectNode();
+    indexes.set(StandardIndexes.forward().getPrettyName(), forward);
+    return new FieldConfig.Builder(name)
+        .withEncodingType(FieldConfig.EncodingType.RAW)
+        .withIndexes(indexes)
+        .build();
+  }
 }
diff --git 
a/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/utils/TableConfigUtilsTest.java
 
b/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/utils/TableConfigUtilsTest.java
index 42b2639974b..97a0263b5a2 100644
--- 
a/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/utils/TableConfigUtilsTest.java
+++ 
b/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/utils/TableConfigUtilsTest.java
@@ -1812,12 +1812,24 @@ public class TableConfigUtilsTest {
         fieldConfigWithCodecSpec("intCol", FieldConfig.EncodingType.RAW, 
"DELTA,LZ4"),
         fieldConfigWithCodecSpec("longCol", FieldConfig.EncodingType.RAW, 
"ZSTD(3)")));
     TableConfigUtils.validate(tableConfig, schema);
+    // Chained value transforms, a value transform feeding a packing 
transform, and transform chains on LONG.
+    assertCodecSpecValidationPasses(schema, "intCol", "DELTA,ZSTD(3)");
+    assertCodecSpecValidationPasses(schema, "intCol", "DELTA,DELTADELTA,LZ4");
+    assertCodecSpecValidationPasses(schema, "intCol", "DELTA,T64,LZ4");
+    assertCodecSpecValidationPasses(schema, "longCol", "DELTADELTA,LZ4");
 
     assertCodecSpecValidationFails(schema, "intCol", 
FieldConfig.EncodingType.RAW, "LZ4,UNKNOWN", "Unknown codec");
     assertCodecSpecValidationFails(schema, "intCol", 
FieldConfig.EncodingType.RAW, "LZ4,DELTA",
         "all transforms must precede any compression stage");
+    // T64 output is not a typed value array, so a value transform cannot 
follow it.
+    assertCodecSpecValidationFails(schema, "intCol", 
FieldConfig.EncodingType.RAW, "T64,DELTA,LZ4",
+        "must operate on column values");
+    // Every codecSpec, compression-only or transform, uses the V7 writer, 
which only supports single-value
+    // INT/LONG columns.
     assertCodecSpecValidationFails(schema, "mvIntCol", 
FieldConfig.EncodingType.RAW, "LZ4",
         "only supports single-value columns");
+    assertCodecSpecValidationFails(schema, "mvIntCol", 
FieldConfig.EncodingType.RAW, "DELTA,LZ4",
+        "only supports single-value columns");
     assertCodecSpecValidationFails(schema, "stringCol", 
FieldConfig.EncodingType.RAW, "SNAPPY",
         "only supports INT and LONG columns");
     assertCodecSpecChunkSizeValidationFails(schema, -1, "numDocsPerChunk must 
be positive");
@@ -1862,6 +1874,12 @@ public class TableConfigUtilsTest {
         exception.getMessage());
   }
 
+  private static void assertCodecSpecValidationPasses(Schema schema, String 
column, String codecSpec) {
+    TableConfig tableConfig = new 
TableConfigBuilder(TableType.OFFLINE).setTableName(TABLE_NAME).build();
+    tableConfig.setFieldConfigList(List.of(fieldConfigWithCodecSpec(column, 
FieldConfig.EncodingType.RAW, codecSpec)));
+    TableConfigUtils.validate(tableConfig, schema);
+  }
+
   private static void assertCodecSpecValidationFails(Schema schema, String 
column,
       FieldConfig.EncodingType encodingType, String codecSpec, String 
expectedMessage) {
     TableConfig tableConfig = new 
TableConfigBuilder(TableType.OFFLINE).setTableName(TABLE_NAME).build();
diff --git 
a/pinot-server/src/main/java/org/apache/pinot/server/api/resources/SegmentCompressionStatsReader.java
 
b/pinot-server/src/main/java/org/apache/pinot/server/api/resources/SegmentCompressionStatsReader.java
index 7084843bc4c..d9f3ef5d68a 100644
--- 
a/pinot-server/src/main/java/org/apache/pinot/server/api/resources/SegmentCompressionStatsReader.java
+++ 
b/pinot-server/src/main/java/org/apache/pinot/server/api/resources/SegmentCompressionStatsReader.java
@@ -105,7 +105,7 @@ final class SegmentCompressionStatsReader {
         chunkCompressionType = null;
       } else {
         long uncompressedValueSize = 
columnMetadata.getRawForwardIndexUncompressedValueSizeInBytes();
-        if (uncompressedValueSize < 0 || 
columnMetadata.getRawForwardIndexChunkCompressionType() == null) {
+        if (uncompressedValueSize < 0) {
           if (!includeColumnCompressionStats) {
             return incomplete(segmentMetadata);
           }
diff --git 
a/pinot-server/src/test/java/org/apache/pinot/server/api/resources/SegmentCompressionStatsReaderTest.java
 
b/pinot-server/src/test/java/org/apache/pinot/server/api/resources/SegmentCompressionStatsReaderTest.java
index 91720990279..14059aa83eb 100644
--- 
a/pinot-server/src/test/java/org/apache/pinot/server/api/resources/SegmentCompressionStatsReaderTest.java
+++ 
b/pinot-server/src/test/java/org/apache/pinot/server/api/resources/SegmentCompressionStatsReaderTest.java
@@ -18,6 +18,7 @@
  */
 package org.apache.pinot.server.api.resources;
 
+import com.fasterxml.jackson.databind.node.ObjectNode;
 import java.io.File;
 import java.nio.file.Files;
 import java.util.ArrayList;
@@ -26,6 +27,7 @@ import java.util.Map;
 import java.util.TreeMap;
 import javax.ws.rs.WebApplicationException;
 import org.apache.commons.io.FileUtils;
+import 
org.apache.pinot.common.restlet.resources.ColumnCompressionStatsContribution;
 import 
org.apache.pinot.common.restlet.resources.SegmentCompressionStatsContribution;
 import 
org.apache.pinot.common.restlet.resources.ServerCompressionStatsResponse;
 import 
org.apache.pinot.segment.local.segment.creator.impl.SegmentIndexCreationDriverImpl;
@@ -44,6 +46,7 @@ import org.apache.pinot.spi.config.table.TableType;
 import org.apache.pinot.spi.data.FieldSpec.DataType;
 import org.apache.pinot.spi.data.Schema;
 import org.apache.pinot.spi.data.readers.GenericRow;
+import org.apache.pinot.spi.utils.JsonUtils;
 import org.apache.pinot.spi.utils.builder.TableConfigBuilder;
 import org.testng.Assert;
 import org.testng.annotations.Test;
@@ -120,6 +123,80 @@ public class SegmentCompressionStatsReaderTest {
     }
   }
 
+  @Test
+  public void testRawCodecPipelineWithoutLegacyCompressionTypeIsComplete() {
+    ColumnMetadata columnMetadata = mock(ColumnMetadata.class);
+    when(columnMetadata.getColumnName()).thenReturn("value");
+    
when(columnMetadata.getIndexSizeFor(StandardIndexes.forward())).thenReturn(20L);
+    
when(columnMetadata.getForwardIndexEncoding()).thenReturn(FieldConfig.EncodingType.RAW);
+    
when(columnMetadata.getRawForwardIndexUncompressedValueSizeInBytes()).thenReturn(80L);
+    
when(columnMetadata.getRawForwardIndexChunkCompressionType()).thenReturn(null);
+
+    SegmentMetadata segmentMetadata = mock(SegmentMetadata.class);
+    when(segmentMetadata.getName()).thenReturn("v7");
+    when(segmentMetadata.getColumnMetadataMap()).thenReturn(new 
TreeMap<>(Map.of("value", columnMetadata)));
+
+    SegmentCompressionStatsContribution contribution = 
SegmentCompressionStatsReader.read(segmentMetadata, true);
+    assertTrue(contribution.isComplete());
+    assertEquals(contribution.getUncompressedValueSizeInBytes(), 80L);
+    
assertEquals(contribution.getForwardIndexAndDictionaryStorageSizeInBytes(), 
20L);
+    ColumnCompressionStatsContribution.EncodingContribution encoding =
+        
contribution.getColumnCompressionStats().get("value").getEncodingBreakdown().get(0);
+    assertEquals(encoding.getEncoding(), FieldConfig.EncodingType.RAW);
+    assertNull(encoding.getChunkCompressionType());
+  }
+
+  @Test
+  public void testV7SegmentCreationPersistsAndReportsCompressionStats()
+      throws Exception {
+    File outputDir = 
Files.createTempDirectory("SegmentCompressionStatsReaderV7Test").toFile();
+    String segmentName = "trackedV7";
+    try {
+      Schema schema = new Schema.SchemaBuilder().setSchemaName(segmentName)
+          .addSingleValueDimension("value", DataType.INT)
+          .build();
+      ObjectNode forward = JsonUtils.newObjectNode();
+      forward.put("codecSpec", "DELTA,LZ4");
+      ObjectNode indexes = JsonUtils.newObjectNode();
+      indexes.set("forward", forward);
+      FieldConfig fieldConfig = new FieldConfig.Builder("value")
+          .withEncodingType(FieldConfig.EncodingType.RAW)
+          .withIndexes(indexes)
+          .build();
+      TableConfig tableConfig = new 
TableConfigBuilder(TableType.OFFLINE).setTableName(segmentName)
+          .setFieldConfigList(List.of(fieldConfig))
+          .setCompressionStatsEnabled(true)
+          .build();
+      SegmentGeneratorConfig config = new SegmentGeneratorConfig(tableConfig, 
schema);
+      config.setOutDir(outputDir.getAbsolutePath());
+      config.setSegmentName(segmentName);
+      List<GenericRow> rows = new ArrayList<>();
+      for (int i = 0; i < 10; i++) {
+        GenericRow row = new GenericRow();
+        row.putValue("value", i);
+        rows.add(row);
+      }
+      SegmentIndexCreationDriverImpl driver = new 
SegmentIndexCreationDriverImpl();
+      driver.init(config, new GenericRowRecordReader(rows));
+      driver.build();
+
+      SegmentMetadata metadata = new SegmentMetadataImpl(new File(outputDir, 
segmentName));
+      ColumnMetadata columnMetadata = metadata.getColumnMetadataFor("value");
+      
assertEquals(columnMetadata.getRawForwardIndexUncompressedValueSizeInBytes(), 
10L * Integer.BYTES);
+      assertNull(columnMetadata.getRawForwardIndexChunkCompressionType());
+
+      SegmentCompressionStatsContribution contribution = 
SegmentCompressionStatsReader.read(metadata, true);
+      assertTrue(contribution.isComplete());
+      assertEquals(contribution.getUncompressedValueSizeInBytes(), 10L * 
Integer.BYTES);
+      ColumnCompressionStatsContribution.EncodingContribution encoding =
+          
contribution.getColumnCompressionStats().get("value").getEncodingBreakdown().get(0);
+      assertEquals(encoding.getEncoding(), FieldConfig.EncodingType.RAW);
+      assertNull(encoding.getChunkCompressionType());
+    } finally {
+      FileUtils.deleteQuietly(outputDir);
+    }
+  }
+
   @Test
   public void testServerRejectsOversizedColumnContributionResponse() {
     SegmentMetadata segmentMetadata = mock(SegmentMetadata.class);
diff --git 
a/pinot-spi/src/main/java/org/apache/pinot/spi/config/table/FieldConfig.java 
b/pinot-spi/src/main/java/org/apache/pinot/spi/config/table/FieldConfig.java
index bbeec54847b..3e459eacf9f 100644
--- a/pinot-spi/src/main/java/org/apache/pinot/spi/config/table/FieldConfig.java
+++ b/pinot-spi/src/main/java/org/apache/pinot/spi/config/table/FieldConfig.java
@@ -148,23 +148,49 @@ public class FieldConfig extends BaseJsonConfig {
 
   public enum CompressionCodec {
     //@formatter:off
+    /// No compression. This is the default for `METRIC` columns and has no 
`codecSpec` equivalent:
+    /// the DSL has no identity codec and rejects a blank spec, so this 
remains the only way to state
+    /// "uncompressed" explicitly.
     PASS_THROUGH(true, false),
+    /// Snappy compression for raw forward indexes. For single-value INT/LONG 
raw columns on a cluster
+    /// where every server reads the V7 format, `codecSpec="SNAPPY"` is the 
codec-pipeline equivalent;
+    /// other column shapes keep using this value.
     SNAPPY(true, false),
+    /// Zstandard compression for raw forward indexes. For single-value 
INT/LONG raw columns on a cluster
+    /// where every server reads the V7 format, `codecSpec="ZSTD(3)"` is the 
codec-pipeline equivalent;
+    /// other column shapes keep using this value.
     ZSTANDARD(true, false),
+    /// LZ4 compression for raw forward indexes. For single-value INT/LONG raw 
columns on a cluster
+    /// where every server reads the V7 format, `codecSpec="LZ4"` is the 
codec-pipeline equivalent;
+    /// other column shapes keep using this value.
     LZ4(true, false),
+    /// GZIP (DEFLATE) compression for raw forward indexes. For single-value 
INT/LONG raw columns on a
+    /// cluster where every server reads the V7 format, `codecSpec="GZIP"` is 
the codec-pipeline
+    /// equivalent; other column shapes keep using this value.
     GZIP(true, false),
 
-    // For MV dictionary encoded forward index, add a second level dictionary 
encoding for the multi-value entries
+    /// Second-level dictionary encoding of the multi-value entries of a 
dictionary-encoded MV forward
+    /// index. No `codecSpec` equivalent: `codecSpec` applies only to RAW 
forward indexes, so this
+    /// remains the only way to express it.
     MV_ENTRY_DICT(false, true),
 
-    // CLP is a special type of compression codec that isn't generally 
applicable to all RAW columns and has a special
-    // handling for log lines (see {@link CLPForwardIndexCreatorV1} and {@link 
CLPForwardIndexCreatorV2)
+    /// CLP is a special type of compression codec that isn't generally 
applicable to all RAW columns and has
+    /// special handling for log lines (see `CLPForwardIndexCreatorV1` and 
`CLPForwardIndexCreatorV2`).
+    /// The CLP family has no `codecSpec` equivalent and remains the only way 
to express it: these are
+    /// whole-index formats for STRING columns rather than chunk codecs, and 
they are validated against the
+    /// column's stored type instead of the raw/dictionary applicability flags 
below.
     CLP(false, false),
     CLPV2(false, false),
     CLPV2_ZSTD(false, false),
     CLPV2_LZ4(false, false),
 
+    /// Delta encoding for raw forward indexes. Rejected by table-config 
validation on every column with a
+    /// forward index: it is applicable to neither raw nor dictionary-encoded 
forward indexes. Use
+    /// `codecSpec="DELTA,LZ4"` on SV INT/LONG raw columns instead, which 
writes the codec-pipeline format
+    /// rather than the legacy chunk format.
     DELTA(false, false),
+    /// Second-order delta encoding for raw forward indexes. Rejected by 
table-config validation for the
+    /// same reason as [#DELTA]; use `codecSpec="DELTADELTA,LZ4"` on SV 
INT/LONG raw columns instead.
     DELTADELTA(false, false);
 
     //@formatter:on
@@ -212,6 +238,8 @@ public class FieldConfig extends BaseJsonConfig {
     return _tierOverwrites;
   }
 
+  /// Returns the raw forward-index compression codec, or `null` when using 
`codecSpec` or the
+  /// default. Still the only way to express `MV_ENTRY_DICT` and the CLP 
family — not deprecated.
   @Nullable
   public CompressionCodec getCompressionCodec() {
     return _compressionCodec;
diff --git 
a/pinot-spi/src/main/java/org/apache/pinot/spi/config/table/OpenStructIndexConfig.java
 
b/pinot-spi/src/main/java/org/apache/pinot/spi/config/table/OpenStructIndexConfig.java
index 7feb8923a4d..46dcd1a749e 100644
--- 
a/pinot-spi/src/main/java/org/apache/pinot/spi/config/table/OpenStructIndexConfig.java
+++ 
b/pinot-spi/src/main/java/org/apache/pinot/spi/config/table/OpenStructIndexConfig.java
@@ -56,7 +56,9 @@ import org.apache.pinot.spi.utils.JsonUtils;
 /// **Per-key index settings** are specified via `valueFieldConfigs` — each 
entry is a standard
 /// [FieldConfig] (modern `indexes` format) for one materialized OPEN_STRUCT 
key. Keys without an
 /// entry fall back to `defaultValueFieldConfig`. When neither is set, the 
built-in default is
-/// DICTIONARY encoding with an inverted index.
+/// DICTIONARY encoding with an inverted index. A per-key config selects the 
dictionary vs raw
+/// encoding and the dictionary, inverted, range and bloom filter indexes; 
forward-index tuning is not
+/// configurable per key (a raw key always uses LZ4 compression), and a 
per-key `codecSpec` is rejected.
 public class OpenStructIndexConfig extends IndexConfig {
   public static final OpenStructIndexConfig DISABLED = new 
OpenStructIndexConfig(false);
   public static final OpenStructIndexConfig DEFAULT = new 
OpenStructIndexConfig(true);


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

Reply via email to