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 9e5a41fd6a5 Reject per-key OPEN_STRUCT settings that materialized keys 
silently ignore (#19687)
9e5a41fd6a5 is described below

commit 9e5a41fd6a545a1f632ac344f5d851c3d559c5ed
Author: Xiang Fu <[email protected]>
AuthorDate: Wed Sep 30 11:40:11 2026 +0700

    Reject per-key OPEN_STRUCT settings that materialized keys silently ignore 
(#19687)
    
    * Reject per-key OPEN_STRUCT settings that materialized keys silently ignore
    
    OPEN_STRUCT materialized keys honor only the per-key encodingType and the
    inverted, range and bloom indexes. The splitter replaces the per-key
    forward-index config with a dictionary-encoded or LZ4 raw one,
    FieldIndexConfigsUtil.fromFieldConfig skips the per-key dictionary config,
    and the compressionCodec, indexTypes, timestampConfig, properties and
    tierOverwrites fields are never read, at segment creation or on reload.
    
    Table-config validation now rejects each of those settings instead of
    dropping it silently, naming the OPEN_STRUCT column and the key or
    defaultValueFieldConfig. indexes.forward and indexes.dictionary may only
    restate what encodingType decides. The OpenStructIndexConfig Javadoc now
    lists the honored settings.
    
    * Check per-key OPEN_STRUCT forward and dictionary configs against how the 
key is built
    
    An enabled inverted index requires a dictionary, so a RAW key with one is
    built dictionary-encoded. validatePerKeyDictionary compared
    indexes.dictionary only against encodingType, so it rejected an accurate
    `dictionary: {}` on such a key and accepted `dictionary: {disabled: true}`;
    indexes.forward.encodingType had the same flaw. Both now compare against
    the built outcome: dictionary-encoded when encodingType is DICTIONARY or
    the inverted index is enabled.
    
    A test pins inverted as the only vetted per-key index that requires a
    dictionary, and new cases cover JSON-null fields and non-object forward and
    dictionary blocks.
---
 .../index/openstruct/OpenStructIndexType.java      | 101 +++++++++++-
 .../openstruct/OpenStructSupportedIndexes.java     |   3 +-
 .../index/openstruct/OpenStructIndexTypeTest.java  | 180 +++++++++++++++++++--
 .../spi/config/table/OpenStructIndexConfig.java    |  15 +-
 4 files changed, 281 insertions(+), 18 deletions(-)

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 5dfcd8c72e8..853b52ab7f3 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
@@ -58,6 +58,10 @@ public class OpenStructIndexType
 
   public static final String INDEX_DISPLAY_NAME = "open_struct";
   private static final List<String> EXTENSIONS = List.of(".open_struct.idx");
+  private static final String FIXED_FORWARD_INDEX =
+      "materialized keys always use a dictionary-encoded or LZ4 raw forward 
index";
+  private static final String KEY_ENCODING_RULE = "a key is dictionary-encoded 
when its encodingType is DICTIONARY or "
+      + "its inverted index is enabled, and raw with LZ4 otherwise";
 
   protected OpenStructIndexType() {
     super(StandardIndexes.OPEN_STRUCT_ID);
@@ -136,17 +140,31 @@ public class OpenStructIndexType
   }
 
   /// Validates one per-key [FieldConfig] of OPEN_STRUCT `column`; `target` 
names it in error messages.
+  ///
+  /// A materialized key honors only its `encodingType` and its `inverted`, 
`range` and `bloom` indexes, both when
+  /// `OpenStructColumnSplitter` builds it and when a reload resolves its 
index configs through
+  /// `FieldIndexConfigsUtil.fromFieldConfig`. Every other setting would be 
silently dropped, so it is rejected here:
+  /// `indexes.forward` and `indexes.dictionary` may only restate how the key 
is built, and the legacy
+  /// `compressionCodec`, `indexTypes`, `timestampConfig`, `properties` and 
`tierOverwrites` fields are never read.
   private static void validatePerKeyFieldConfig(FieldConfig fieldConfig, 
String column, String target) {
+    checkPerKeySetting(fieldConfig.getCompressionCodec() == null, column, 
"compressionCodec", target,
+        FIXED_FORWARD_INDEX);
+    checkPerKeySetting(fieldConfig.getIndexTypes().isEmpty(), column, 
"indexTypes", target,
+        "declare per-key indexes under 'indexes'");
+    checkPerKeySetting(fieldConfig.getTimestampConfig() == null, column, 
"timestampConfig", target,
+        "materialized keys do not build TIMESTAMP indexes");
+    Map<String, String> properties = fieldConfig.getProperties();
+    checkPerKeySetting(properties == null || properties.isEmpty(), column, 
"properties", target,
+        "configure the key through encodingType and 'indexes'");
+    checkPerKeySetting(isUnset(fieldConfig.getTierOverwrites()), column, 
"tierOverwrites", target,
+        "per-key configs are not tier-specific");
+
     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);
+        "OPEN_STRUCT column '%s': codecSpec is not supported for %s; " + 
FIXED_FORWARD_INDEX, column, target);
     Iterator<String> indexNames = indexes.fieldNames();
     while (indexNames.hasNext()) {
       String indexName = indexNames.next();
@@ -154,6 +172,79 @@ public class OpenStructIndexType
           "OPEN_STRUCT key '%s' declares unsupported index '%s'; supported 
indexes are %s",
           fieldConfig.getName(), indexName, 
OpenStructSupportedIndexes.ALLOWED_PRETTY_NAMES);
     }
+    // Mirrors OpenStructColumnSplitter: a key gets a dictionary and a 
dictionary-encoded forward index when its
+    // encodingType is DICTIONARY or its inverted index is enabled. Inverted 
is the only vetted per-key index that
+    // requires a dictionary, so it overrides a RAW encodingType.
+    boolean dictionaryEncoded = fieldConfig.getEncodingType() != 
FieldConfig.EncodingType.RAW
+        || isEnabled(indexes.get(StandardIndexes.inverted().getPrettyName()));
+    if (forwardIndex != null && !forwardIndex.isNull()) {
+      validatePerKeyForwardIndex(forwardIndex, dictionaryEncoded, column, 
target);
+    }
+    // An empty `indexes.dictionary` still enables the dictionary, so only an 
absent or null one is skipped.
+    JsonNode dictionary = 
indexes.get(StandardIndexes.dictionary().getPrettyName());
+    if (dictionary != null && !dictionary.isNull()) {
+      validatePerKeyDictionary(dictionary, dictionaryEncoded, column, target);
+    }
+  }
+
+  /// The splitter replaces a key's forward-index config with a 
dictionary-encoded or LZ4 raw one, so
+  /// `indexes.forward` may only restate that encoding or keep the index 
enabled.
+  private static void validatePerKeyForwardIndex(JsonNode forwardIndex, 
boolean dictionaryEncoded, String column,
+      String target) {
+    Preconditions.checkState(forwardIndex.isObject(), "OPEN_STRUCT column 
'%s': indexes.forward of %s must be a JSON "
+        + "object", column, target);
+    for (Map.Entry<String, JsonNode> field : forwardIndex.properties()) {
+      String name = field.getKey();
+      JsonNode value = field.getValue();
+      if (value.isNull()) {
+        continue;
+      }
+      if (name.equals("encodingType")) {
+        FieldConfig.EncodingType builtEncoding =
+            dictionaryEncoded ? FieldConfig.EncodingType.DICTIONARY : 
FieldConfig.EncodingType.RAW;
+        Preconditions.checkState(value.asText().equals(builtEncoding.name()),
+            "OPEN_STRUCT column '%s': indexes.forward.encodingType %s of %s 
does not match the %s forward index it is "
+                + "built with; %s", column, value.asText(), target, 
dictionaryEncoded ? "dictionary-encoded" : "raw",
+            KEY_ENCODING_RULE);
+      } else {
+        checkPerKeySetting(name.equals("disabled") && !value.asBoolean(), 
column, "indexes.forward." + name, target,
+            FIXED_FORWARD_INDEX);
+      }
+    }
+  }
+
+  /// A key's per-key dictionary config is never read: the key has a 
dictionary exactly when it is dictionary-encoded.
+  /// `indexes.dictionary` may only restate that.
+  private static void validatePerKeyDictionary(JsonNode dictionary, boolean 
dictionaryEncoded, String column,
+      String target) {
+    Preconditions.checkState(dictionary.isObject(), "OPEN_STRUCT column '%s': 
indexes.dictionary of %s must be a "
+        + "JSON object", column, target);
+    for (Map.Entry<String, JsonNode> field : dictionary.properties()) {
+      if (!field.getKey().equals("disabled")) {
+        checkPerKeySetting(field.getValue().isNull(), column, 
"indexes.dictionary." + field.getKey(), target,
+            KEY_ENCODING_RULE);
+      }
+    }
+    boolean disabled = dictionary.path("disabled").asBoolean(false);
+    Preconditions.checkState(disabled != dictionaryEncoded,
+        "OPEN_STRUCT column '%s': indexes.dictionary of %s %s the dictionary, 
but the key is built %s one; %s", column,
+        target, disabled ? "disables" : "enables", dictionaryEncoded ? "with" 
: "without", KEY_ENCODING_RULE);
+  }
+
+  /// Returns `true` for a present index config that is not disabled, as 
`IndexConfig` deserializes it.
+  private static boolean isEnabled(@Nullable JsonNode indexConfig) {
+    return indexConfig != null && !indexConfig.isNull() && 
!indexConfig.path("disabled").asBoolean(false);
+  }
+
+  private static void checkPerKeySetting(boolean supported, String column, 
String setting, String target,
+      String reason) {
+    Preconditions.checkState(supported, "OPEN_STRUCT column '%s': %s is not 
supported for %s; %s", column, setting,
+        target, reason);
+  }
+
+  /// Returns `true` for an absent, JSON null or empty JSON value.
+  private static boolean isUnset(@Nullable JsonNode node) {
+    return node == null || node.isNull() || node.isMissingNode() || 
(node.isContainerNode() && node.size() == 0);
   }
 
   private void validateIgnoredKeys(OpenStructIndexConfig config, FieldSpec 
fieldSpec) {
diff --git 
a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/index/openstruct/OpenStructSupportedIndexes.java
 
b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/index/openstruct/OpenStructSupportedIndexes.java
index cf23f188319..60ccde9b071 100644
--- 
a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/index/openstruct/OpenStructSupportedIndexes.java
+++ 
b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/index/openstruct/OpenStructSupportedIndexes.java
@@ -24,7 +24,8 @@ import org.apache.pinot.segment.spi.index.StandardIndexes;
 
 /// The set of index types (by pretty name) supported on OPEN_STRUCT 
materialized child columns. A key's
 /// `FieldConfig` may declare only these; non-vetted indexes are rejected at 
table-config validation.
-/// `dictionary` is built structurally (lifecycle CUSTOM); `forward` is always 
written.
+/// `dictionary` is built structurally (lifecycle CUSTOM) and `forward` is 
always written, so their per-key configs
+/// may only restate how the key is built (see `OpenStructIndexType`).
 public final class OpenStructSupportedIndexes {
   private OpenStructSupportedIndexes() {
   }
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 8baa99d66c5..c7f772bbc96 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
@@ -23,10 +23,14 @@ import com.fasterxml.jackson.databind.node.ObjectNode;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
+import javax.annotation.Nullable;
 import org.apache.pinot.segment.local.utils.TableConfigUtils;
 import org.apache.pinot.segment.spi.index.FieldIndexConfigs;
+import org.apache.pinot.segment.spi.index.IndexService;
+import org.apache.pinot.segment.spi.index.IndexType;
 import org.apache.pinot.segment.spi.index.StandardIndexes;
 import org.apache.pinot.spi.config.table.FieldConfig;
+import org.apache.pinot.spi.config.table.IndexConfig;
 import org.apache.pinot.spi.config.table.OpenStructIndexConfig;
 import org.apache.pinot.spi.config.table.TableConfig;
 import org.apache.pinot.spi.config.table.TableType;
@@ -43,6 +47,7 @@ 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.assertTrue;
 import static org.testng.Assert.expectThrows;
 
 
@@ -205,22 +210,179 @@ public class OpenStructIndexTypeTest {
   @Test(dataProvider = "codecSpecOpenStructConfigs")
   public void 
testTableValidationRejectsCodecSpecForMaterializedChildren(OpenStructIndexConfig
 openStructConfig,
       String expectedTarget) {
+    IllegalStateException exception = expectThrows(IllegalStateException.class,
+        () -> validateTableWithOpenStruct(openStructConfig));
+    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");
+  }
+
+  /// Per-key settings that a materialized key would silently ignore. JSON 
uses single quotes for readability.
+  @DataProvider(name = "ignoredPerKeySettings")
+  public Object[][] ignoredPerKeySettings() {
+    String unsupported = "OPEN_STRUCT column 'payload': %s is not supported 
for key 'clicks'; ";
+    return new Object[][]{
+        {"{'name': 'clicks', 'encodingType': 'RAW', 'compressionCodec': 
'ZSTANDARD'}",
+            String.format(unsupported, "compressionCodec")},
+        {"{'name': 'clicks', 'indexTypes': ['INVERTED']}", 
String.format(unsupported, "indexTypes")},
+        {"{'name': 'clicks', 'timestampConfig': {'granularities': ['DAY']}}",
+            String.format(unsupported, "timestampConfig")},
+        {"{'name': 'clicks', 'properties': {'forwardIndexDisabled': 'true'}}",
+            String.format(unsupported, "properties")},
+        {"{'name': 'clicks', 'tierOverwrites': {'hotTier': {'encodingType': 
'RAW'}}}",
+            String.format(unsupported, "tierOverwrites")},
+        {rawKeyWithForward("'compressionCodec': 'ZSTANDARD'"),
+            String.format(unsupported, "indexes.forward.compressionCodec")},
+        {rawKeyWithForward("'chunkCompressionType': 'ZSTANDARD'"),
+            String.format(unsupported, 
"indexes.forward.chunkCompressionType")},
+        {"{'name': 'clicks', 'indexes': {'forward': {'dictIdCompressionType': 
'MV_ENTRY_DICT'}}}",
+            String.format(unsupported, 
"indexes.forward.dictIdCompressionType")},
+        {rawKeyWithForward("'targetDocsPerChunk': 2048"),
+            String.format(unsupported, "indexes.forward.targetDocsPerChunk")},
+        {rawKeyWithForward("'targetMaxChunkSize': '512K'"),
+            String.format(unsupported, "indexes.forward.targetMaxChunkSize")},
+        {rawKeyWithForward("'rawIndexWriterVersion': 4"),
+            String.format(unsupported, 
"indexes.forward.rawIndexWriterVersion")},
+        {rawKeyWithForward("'deriveNumDocsPerChunk': true"),
+            String.format(unsupported, 
"indexes.forward.deriveNumDocsPerChunk")},
+        {rawKeyWithForward("'configs': {'key': 'value'}"), 
String.format(unsupported, "indexes.forward.configs")},
+        {rawKeyWithForward("'disabled': true"), String.format(unsupported, 
"indexes.forward.disabled")},
+        {"{'name': 'clicks', 'indexes': {'dictionary': {'onHeap': true}}}",
+            String.format(unsupported, "indexes.dictionary.onHeap")},
+        {"{'name': 'clicks', 'indexes': {'dictionary': 
{'useVarLengthDictionary': true}}}",
+            String.format(unsupported, 
"indexes.dictionary.useVarLengthDictionary")},
+        // A key is dictionary-encoded when its encodingType is DICTIONARY or 
its inverted index is enabled; the
+        // per-key forward and dictionary configs may only restate that 
outcome.
+        {"{'name': 'clicks', 'indexes': {'forward': {'encodingType': 'RAW'}}}",
+            "OPEN_STRUCT column 'payload': indexes.forward.encodingType RAW of 
key 'clicks' does not match the "
+                + "dictionary-encoded forward index it is built with; "},
+        {"{'name': 'clicks', 'encodingType': 'RAW', 'indexes': {'inverted': 
{}, 'forward': {'encodingType': 'RAW'}}}",
+            "OPEN_STRUCT column 'payload': indexes.forward.encodingType RAW of 
key 'clicks' does not match the "
+                + "dictionary-encoded forward index it is built with; "},
+        {"{'name': 'clicks', 'indexes': {'dictionary': {'disabled': true}}}",
+            "OPEN_STRUCT column 'payload': indexes.dictionary of key 'clicks' 
disables the dictionary, but the key is "
+                + "built with one; "},
+        {"{'name': 'clicks', 'encodingType': 'RAW', 'indexes': {'dictionary': 
{}}}",
+            "OPEN_STRUCT column 'payload': indexes.dictionary of key 'clicks' 
enables the dictionary, but the key is "
+                + "built without one; "},
+        // An enabled inverted index requires a dictionary, so a RAW key with 
one is still built with a dictionary.
+        {"{'name': 'clicks', 'encodingType': 'RAW', 'indexes': {'inverted': 
{}, 'dictionary': {'disabled': true}}}",
+            "OPEN_STRUCT column 'payload': indexes.dictionary of key 'clicks' 
disables the dictionary, but the key is "
+                + "built with one; "},
+        {"{'name': 'clicks', 'indexes': {'forward': 'LZ4'}}",
+            "OPEN_STRUCT column 'payload': indexes.forward of key 'clicks' 
must be a JSON object"},
+        {"{'name': 'clicks', 'indexes': {'dictionary': true}}",
+            "OPEN_STRUCT column 'payload': indexes.dictionary of key 'clicks' 
must be a JSON object"}
+    };
+  }
+
+  @Test(dataProvider = "ignoredPerKeySettings")
+  public void testValidateRejectsIgnoredPerKeySettings(String keyConfigJson, 
String expectedMessagePrefix)
+      throws Exception {
+    FieldConfig keyConfig = parseFieldConfig(keyConfigJson);
+    IllegalStateException exception = expectThrows(IllegalStateException.class,
+        () -> validatePerKeyConfigs(null, List.of(keyConfig)));
+    assertTrue(exception.getMessage().startsWith(expectedMessagePrefix), 
exception.getMessage());
+  }
+
+  @Test
+  public void testValidateNamesDefaultValueFieldConfigInPerKeyErrors()
+      throws Exception {
+    FieldConfig defaultConfig = parseFieldConfig("{'name': 'default', 
'properties': {'forwardIndexDisabled': 'true'}}");
+    IllegalStateException exception = expectThrows(IllegalStateException.class,
+        () -> validatePerKeyConfigs(defaultConfig, null));
+    assertEquals(exception.getMessage(), "OPEN_STRUCT column 'payload': 
properties is not supported for "
+        + "defaultValueFieldConfig; configure the key through encodingType and 
'indexes'");
+  }
+
+  /// Settings a materialized key honors, and forward/dictionary entries that 
only restate how it is built, pass.
+  @Test
+  public void testValidateAllowsHonoredAndRestatedPerKeySettings()
+      throws Exception {
+    FieldConfig rawKey = parseFieldConfig("{'name': 'clicks', 'encodingType': 
'RAW', 'indexes': {"
+        + "'forward': {'encodingType': 'RAW', 'disabled': false}, 
'dictionary': {'disabled': true}, "
+        + "'range': {'version': 2}, 'bloom': {'fpp': 0.01}, 'inverted': 
{'disabled': true}}}");
+    // Empty and JSON-null values count as unset.
+    FieldConfig dictionaryKey = parseFieldConfig("{'name': 'views', 
'properties': {}, 'tierOverwrites': {}, "
+        + "'indexes': {'forward': {'compressionCodec': null}, 'dictionary': 
{'disabled': false, 'onHeap': null}, "
+        + "'inverted': {}}}");
+    // The inverted index makes a RAW key dictionary-encoded, which its 
forward and dictionary configs may restate.
+    FieldConfig rawInvertedKey = parseFieldConfig("{'name': 'tags', 
'encodingType': 'RAW', 'indexes': {"
+        + "'inverted': {}, 'forward': {'encodingType': 'DICTIONARY'}, 
'dictionary': {}}}");
+    FieldConfig defaultConfig = parseFieldConfig("{'name': 'default', 
'encodingType': 'RAW', "
+        + "'indexes': {'forward': null, 'dictionary': null, 'bloom': {}}}");
+    validatePerKeyConfigs(defaultConfig, List.of(rawKey, dictionaryKey, 
rawInvertedKey));
+  }
+
+  /// `validatePerKeyFieldConfig` treats an enabled inverted index as the only 
per-key index that forces a
+  /// dictionary; fail here if another vetted per-key index starts requiring 
one.
+  @Test
+  public void testInvertedIsTheOnlyVettedPerKeyIndexRequiringDictionary() {
+    FieldSpec keySpec = new DimensionFieldSpec("clicks", 
FieldSpec.DataType.INT, true);
+    for (IndexType<?, ?, ?> indexType : 
IndexService.getInstance().getAllIndexes()) {
+      String name = indexType.getPrettyName();
+      if (OpenStructSupportedIndexes.ALLOWED_PRETTY_NAMES.contains(name)
+          && !name.equals(StandardIndexes.dictionary().getPrettyName())) {
+        assertEquals(requiresDictionary(indexType, keySpec), 
name.equals(StandardIndexes.inverted().getPrettyName()),
+            name);
+      }
+    }
+  }
+
+  private static <C extends IndexConfig> boolean 
requiresDictionary(IndexType<C, ?, ?> indexType, FieldSpec spec) {
+    return indexType.requiresDictionary(spec, indexType.getDefaultConfig());
+  }
+
+  /// The table-config path deserializes per-key configs from JSON; a round 
trip must neither lose a rejected setting
+  /// nor turn an unset field into a rejected one.
+  @Test
+  public void testTableValidationChecksIgnoredPerKeySettings()
+      throws Exception {
+    FieldConfig honoredKey = parseFieldConfig("{'name': 'clicks', 
'encodingType': 'RAW', "
+        + "'indexes': {'forward': {'encodingType': 'RAW'}, 'range': {}}}");
+    validateTableWithOpenStruct(
+        new OpenStructIndexConfig(false, null, -1, null, 0.5, 
List.of(honoredKey), null, null, null, null));
+
+    FieldConfig ignoredKey = parseFieldConfig("{'name': 'clicks', 
'encodingType': 'RAW', "
+        + "'indexes': {'forward': {'targetDocsPerChunk': 2048}}}");
+    IllegalStateException exception = 
expectThrows(IllegalStateException.class, () -> validateTableWithOpenStruct(
+        new OpenStructIndexConfig(false, null, -1, null, 0.5, 
List.of(ignoredKey), null, null, null, null)));
+    assertEquals(exception.getMessage(), "OPEN_STRUCT column 'payload': 
indexes.forward.targetDocsPerChunk is not "
+        + "supported for key 'clicks'; materialized keys always use a 
dictionary-encoded or LZ4 raw forward index");
+  }
+
+  private static FieldConfig parseFieldConfig(String singleQuotedJson)
+      throws Exception {
+    return JsonUtils.stringToObject(singleQuotedJson.replace('\'', '"'), 
FieldConfig.class);
+  }
+
+  private static String rawKeyWithForward(String forwardFields) {
+    return "{'name': 'clicks', 'encodingType': 'RAW', 'indexes': {'forward': 
{" + forwardFields + "}}}";
+  }
+
+  /// Validates an OPEN_STRUCT column `payload` whose index config has the 
given per-key configs.
+  private static void validatePerKeyConfigs(@Nullable FieldConfig 
defaultValueFieldConfig,
+      @Nullable List<FieldConfig> valueFieldConfigs) {
+    OpenStructIndexConfig config = new OpenStructIndexConfig(false, 
defaultValueFieldConfig, -1, null, 0.5,
+        valueFieldConfigs, null, null, null, null);
+    FieldIndexConfigs fieldIndexConfigs =
+        new FieldIndexConfigs.Builder().add(StandardIndexes.openStruct(), 
config).build();
+    StandardIndexes.openStruct().validate(fieldIndexConfigs, 
openStructSchema().getFieldSpecFor("payload"), null);
+  }
+
+  /// Runs full table-config validation, which reads the OPEN_STRUCT config 
back from JSON.
+  private static void validateTableWithOpenStruct(OpenStructIndexConfig 
openStructConfig) {
     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")
+        .setTableName("openStructPerKeyTest")
         .setFieldConfigList(List.of(parentFieldConfig))
         .build();
-    Schema schema = new Schema.SchemaBuilder()
-        .setSchemaName("openStructCodecSpecTest")
-        .addOpenStruct("payload", Map.of())
-        .build();
+    TableConfigUtils.validate(tableConfig, openStructSchema());
+  }
 
-    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 Schema openStructSchema() {
+    return new 
Schema.SchemaBuilder().setSchemaName("openStructPerKeyTest").addOpenStruct("payload",
 Map.of()).build();
   }
 
   private static FieldConfig rawCodecSpecFieldConfig(String name) {
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 46dcd1a749e..a60d6474580 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,9 +56,18 @@ 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. 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.
+/// DICTIONARY encoding with an inverted index. A per-key config honors only:
+/// - `encodingType`: DICTIONARY (the default) builds a dictionary-encoded 
key, and RAW builds a raw
+///   forward index with LZ4 compression, unless the key's inverted index is 
enabled, which requires a
+///   dictionary and makes the key dictionary-encoded.
+/// - The `inverted`, `range` and `bloom` entries under `indexes`. A per-key 
config without an
+///   `inverted` entry builds no inverted index.
+///
+/// `indexes.forward` and `indexes.dictionary` may only restate how the key is 
built: its forward
+/// encoding and whether it has a dictionary. Table-config validation rejects 
every other per-key
+/// setting, which a materialized key would silently ignore:
+/// forward-index tuning (such as `codecSpec`, `compressionCodec` or chunk 
sizes), dictionary tuning, and
+/// the `indexTypes`, `properties`, `timestampConfig` and `tierOverwrites` 
fields.
 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