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

raghavyadav01 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 21596bbffad Configure OPEN_STRUCT indexes the way every other column 
is configured (#19736)
21596bbffad is described below

commit 21596bbffad24b71c9f7c04addcee1d4830801a5
Author: RAGHVENDRA KUMAR YADAV <[email protected]>
AuthorDate: Tue Oct 6 09:32:16 2026 -0700

    Configure OPEN_STRUCT indexes the way every other column is configured 
(#19736)
    
    * Let an OPEN_STRUCT key ask for its dictionary the way every other column 
does
    
    A dictionary is enabled on an ordinary column by putting it in `indexes`.
    An OPEN_STRUCT key could not be configured that way: fromFieldConfig, which
    builds a key's index configs, skipped the dictionary type outright and took
    the answer from encodingType alone, and the validator then rejected an
    indexes.dictionary entry that did not already agree with it.
    
    So the two spellings disagreed depending on which kind of column you were
    configuring, and the key spelling failed silently -- you got a raw key and
    no diagnostic.
    
    An enabled inverted index hid half of it, since it forces a dictionary on
    its own. A key whose only index is a range one had nothing to rescue it:
    the dictionary entry was ignored, the key stayed raw, and the range index
    was asked for over a dictionary that did not exist. That case is
    testARangeOnlyKeyCanAskForADictionaryUnderIndexes, and it is the one that
    fails when the production change alone is reverted.
    
    indexes.dictionary can only turn a dictionary *on*. Turning one off stays
    with encodingType, so a `disabled` entry contradicting the column's own
    encoding is still a contradiction and still refused rather than quietly
    winning.
    
    The forward index follows the dictionary decision rather than the declared
    encoding, for the reason it already followed encodingType: a key whose
    dictionary was enabled this way needs a dict-encoded forward index, or a
    reload rebuilds one over a dictionary that is not there.
    
    369 tests green across the OPEN_STRUCT, config and loader suites.
    
    * Let the shared sparse blob column carry its own index settings
    
    An OPEN_STRUCT column splits into materialized per-key child columns and
    one shared blob column holding every key that was not materialized. The
    per-key children already accept a FieldConfig each, so a key can ask for
    a dictionary, a range index, a bloom filter and so on. The blob column
    could not: index loading skipped it outright, so the only way to search
    the keys living inside it was a full scan.
    
    This adds a sparseFieldConfig to OpenStructIndexConfig. It is shaped
    like the FieldConfig of any other column, and it configures the blob the
    same way a per-key entry configures a child:
    
      "openStruct": {
        "sparseFieldConfig": {
          "name": "props$__sparse__",
          "indexes": { "json": {} }
        }
      }
    
    The blob is always RAW regardless of what the config asks for -- it is a
    serialized document per row, so a dictionary over it would be a
    dictionary of whole documents and buys nothing. The existing
    sparseJsonIndex flag still works and now means exactly indexes.json={},
    so the two spellings converge on one code path instead of two.
    
    Adds four tests covering a JSON index built on the blob through either
    spelling, and keeps the existing per-key cases green.
    
    * Serve a sparse key from whichever JSON index the blob was given
    
    The sparse fast path in MapFilterOperator asks the OPEN_STRUCT data source 
for
    the blob's JSON index, and that lookup only ever returned the standard one.
    An ordinary column does not stop there: tryJsonIndex falls back to the
    composite JSON index, which reads as a JsonIndexReader and answers the same
    predicates.
    
    So a blob given a composite JSON index built it and was then never asked. 
The
    key fell back to a full scan, silently costing exactly what the index was
    configured to avoid -- no error, no plan difference a reader would notice,
    just the slow path.
    
    Make the blob lookup do what the column lookup already does. The index ships
    as a plugin and is absent from most deployments, so it is resolved by id and
    the fallback is inert where nothing registered it.
    
    * Do not ask a JSON index for a key it holds no postings for
    
    The standard JSON index indexes every path, so asking it about any key is
    safe. An index that indexes a configured subset of paths is not: asked about
    a path it never indexed, it answers with an empty bitmap, and an empty 
bitmap
    is indistinguishable from "no document matches this". The query returns zero
    rows, the plan says the index served the filter, and nothing anywhere says 
the
    answer is wrong.
    
    `JsonIndexReader.isPathIndexed` already exists to answer exactly this, and
    defaults to true so a fully-indexed reader is unaffected. Nothing was 
calling
    it. Both JSON fast paths now do -- the one over a column and the one over an
    OPEN_STRUCT sparse blob -- and a key the index does not cover falls back to
    the scan, which is slower and right.
    
    The two existing tests that expect the index to serve now stub 
`isPathIndexed`:
    Mockito answers false for an unstubbed boolean, interface default or not.
---
 .../core/operator/filter/MapFilterOperator.java    |  17 ++-
 .../filter/MapFilterOperatorOpenStructTest.java    |  22 ++++
 .../segment/index/loader/IndexLoadingConfig.java   |  45 +++++++-
 .../openstruct/ImmutableOpenStructDataSource.java  |  21 +++-
 .../index/openstruct/OpenStructIndexType.java      |   8 +-
 .../index/openstruct/OpenStructIndexTypeTest.java  |   3 -
 .../OpenStructPerKeyIndexReloadTest.java           | 116 +++++++++++++++++++++
 .../segment/spi/index/FieldIndexConfigsUtil.java   |  26 ++++-
 .../spi/config/table/OpenStructIndexConfig.java    |  30 +++++-
 9 files changed, 272 insertions(+), 16 deletions(-)

diff --git 
a/pinot-core/src/main/java/org/apache/pinot/core/operator/filter/MapFilterOperator.java
 
b/pinot-core/src/main/java/org/apache/pinot/core/operator/filter/MapFilterOperator.java
index 7eca5c6100d..b248684377b 100644
--- 
a/pinot-core/src/main/java/org/apache/pinot/core/operator/filter/MapFilterOperator.java
+++ 
b/pinot-core/src/main/java/org/apache/pinot/core/operator/filter/MapFilterOperator.java
@@ -55,6 +55,9 @@ import org.roaringbitmap.buffer.ImmutableRoaringBitmap;
 /// 3. JSON index (JsonMatchFilterOperator)
 /// 4. Expression scan fallback (ExpressionFilterOperator)
 public class MapFilterOperator extends BaseFilterOperator {
+  /// A key's JSON path, in the form an index's configured paths are written 
in ("$.user_tier").
+  private static final String JSON_PATH_PREFIX = "$.";
+
   private static final String EXPLAIN_NAME = "FILTER_MAP";
 
   private enum DelegateType {
@@ -161,7 +164,7 @@ public class MapFilterOperator extends BaseFilterOperator {
   private BaseFilterOperator trySparseJsonIndex(OpenStructDataSource osDs, 
DataSource sparseKeyDs,
       QueryContext queryContext, int numDocs) {
     JsonIndexReader jsonIndex = osDs.getSparseJsonIndex();
-    if (jsonIndex == null) {
+    if (jsonIndex == null || !servesKey(jsonIndex)) {
       return null;
     }
     if (sparseKeyDs.getDataSourceMetadata().getDataType().getStoredType() != 
FieldSpec.DataType.STRING) {
@@ -250,13 +253,23 @@ public class MapFilterOperator extends BaseFilterOperator 
{
         jsonIndex = (JsonIndexReader) 
dataSource.getIndex(compositeIndex.get());
       }
     }
-    if (jsonIndex == null) {
+    if (jsonIndex == null || !servesKey(jsonIndex)) {
       return null;
     }
     FilterContext filterContext = createFilterContext();
     return new JsonMatchFilterOperator(jsonIndex, filterContext, numDocs);
   }
 
+  /// Whether this index actually holds postings for the key being filtered on.
+  ///
+  /// An index that indexes every path -- the standard JSON index -- always 
does. One that indexes a configured
+  /// subset answers an unindexed path with an empty bitmap, which is 
indistinguishable from "no document matches":
+  /// the query returns zero rows and nothing says why. Asking first turns 
that into a scan, which is slower and
+  /// right.
+  private boolean servesKey(JsonIndexReader jsonIndex) {
+    return jsonIndex.isPathIndexed(JSON_PATH_PREFIX + _keyName);
+  }
+
   private FilterContext createFilterContext() {
     Predicate rewritten = rewritePredicateForKey(_predicate);
     if (rewritten == null) {
diff --git 
a/pinot-core/src/test/java/org/apache/pinot/core/operator/filter/MapFilterOperatorOpenStructTest.java
 
b/pinot-core/src/test/java/org/apache/pinot/core/operator/filter/MapFilterOperatorOpenStructTest.java
index 2df020993d2..d818936b836 100644
--- 
a/pinot-core/src/test/java/org/apache/pinot/core/operator/filter/MapFilterOperatorOpenStructTest.java
+++ 
b/pinot-core/src/test/java/org/apache/pinot/core/operator/filter/MapFilterOperatorOpenStructTest.java
@@ -614,6 +614,8 @@ public class MapFilterOperatorOpenStructTest {
     postings.add(0);
     postings.add(4);
     
when(jsonIndex.getMatchingDocIds(any(FilterContext.class))).thenReturn(postings);
+    // Mockito answers false for an unstubbed boolean, including an interface 
default that returns true.
+    when(jsonIndex.isPathIndexed(anyString())).thenReturn(true);
 
     OpenStructDataSource osDs = 
withSparseJsonIndex(mockSparseSegmentSource(List.of("region"), Map.of()), 
jsonIndex);
     MapFilterOperator op = new MapFilterOperator(mockSegment(osDs),
@@ -629,6 +631,8 @@ public class MapFilterOperatorOpenStructTest {
     MutableRoaringBitmap postings = new MutableRoaringBitmap();
     postings.add(0);
     
when(jsonIndex.getMatchingDocIds(any(FilterContext.class))).thenReturn(postings);
+    // Mockito answers false for an unstubbed boolean, including an interface 
default that returns true.
+    when(jsonIndex.isPathIndexed(anyString())).thenReturn(true);
 
     OpenStructDataSource osDs = 
withSparseJsonIndex(mockSparseSegmentSource(List.of("region"), Map.of()), 
jsonIndex);
     MapFilterOperator op = new MapFilterOperator(mockSegment(osDs),
@@ -638,6 +642,24 @@ public class MapFilterOperatorOpenStructTest {
     assertEquals(countMatches(op), NUM_DOCS - 1);
   }
 
+  /// An index that holds postings for only some paths answers an unindexed 
one with an empty bitmap, which reads
+  /// exactly like "nothing matched". Taking that answer would return zero 
rows and report no reason, so the key
+  /// has to fall back to the scan instead.
+  @Test
+  public void testSparseJsonIndexIsRefusedForAnUnindexedKey() {
+    JsonIndexReader jsonIndex = mock(JsonIndexReader.class);
+    when(jsonIndex.getMatchingDocIds(any(FilterContext.class))).thenReturn(new 
MutableRoaringBitmap());
+    when(jsonIndex.isPathIndexed(anyString())).thenReturn(false);
+
+    OpenStructDataSource osDs =
+        withSparseJsonIndex(mockSparseSegmentSource(List.of("region"), 
Map.of()), jsonIndex);
+    MapFilterOperator op = new MapFilterOperator(mockSegment(osDs),
+        makeEqPredicate(COLUMN, "region", "us"), mockQueryContext(), NUM_DOCS);
+
+    assertFalse(op.toExplainString().contains("delegateTo:json_match"),
+        "an index without postings for this key must not be asked");
+  }
+
   @Test
   public void testSparseJsonIndexRefusals() {
     JsonIndexReader jsonIndex = mock(JsonIndexReader.class);
diff --git 
a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/index/loader/IndexLoadingConfig.java
 
b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/index/loader/IndexLoadingConfig.java
index e68480ace43..4e868d34bfa 100644
--- 
a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/index/loader/IndexLoadingConfig.java
+++ 
b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/index/loader/IndexLoadingConfig.java
@@ -18,6 +18,7 @@
  */
 package org.apache.pinot.segment.local.segment.index.loader;
 
+import com.fasterxml.jackson.databind.node.ObjectNode;
 import com.google.common.annotations.VisibleForTesting;
 import java.util.Collections;
 import java.util.HashMap;
@@ -51,6 +52,7 @@ import org.apache.pinot.spi.data.FieldSpec;
 import org.apache.pinot.spi.data.FieldSpec.DataType;
 import org.apache.pinot.spi.data.OpenStructNaming;
 import org.apache.pinot.spi.data.Schema;
+import org.apache.pinot.spi.utils.JsonUtils;
 import org.apache.pinot.spi.utils.ReadMode;
 import org.apache.pinot.spi.utils.TimestampIndexUtils;
 
@@ -495,9 +497,6 @@ public class IndexLoadingConfig {
       if (!childColumn.contains(OpenStructNaming.SEPARATOR) || 
indexConfigsByColName.containsKey(childColumn)) {
         continue;
       }
-      if (OpenStructNaming.isSparseColumn(childColumn)) {
-        continue;
-      }
       String parentColumn = OpenStructNaming.parseParentColumn(childColumn);
       FieldIndexConfigs parentConfigs = 
indexConfigsByColName.get(parentColumn);
       if (parentConfigs == null) {
@@ -508,6 +507,16 @@ public class IndexLoadingConfig {
         continue;
       }
       OpenStructIndexConfig openStructConfig = (OpenStructIndexConfig) 
osConfig;
+      if (OpenStructNaming.isSparseColumn(childColumn)) {
+        FieldIndexConfigs blobConfigs = sparseChildConfigs(openStructConfig, 
entry.getValue().getFieldSpec());
+        if (blobConfigs != null) {
+          if (updatedConfigs == null) {
+            updatedConfigs = new HashMap<>(indexConfigsByColName);
+          }
+          updatedConfigs.put(childColumn, blobConfigs);
+        }
+        continue;
+      }
       String key = OpenStructNaming.parseKey(childColumn);
       FieldConfig keyFieldConfig = openStructConfig.getValueFieldConfig(key);
       if (keyFieldConfig == null) {
@@ -536,6 +545,36 @@ public class IndexLoadingConfig {
     _resolvedIndexState = 
withOpenStructChildConfigs(segmentMetadata)._resolvedIndexState;
   }
 
+  /// Index configs for the shared blob column, or null when the table asked 
for nothing on it.
+  ///
+  /// The blob is an ordinary single-value STRING column holding each row's 
document as JSON, so an index that
+  /// reads JSON out of a STRING column applies to it unchanged -- a JSON 
index, or any other registered index
+  /// type named under `sparseFieldConfig.indexes`. What it must never get is 
a dictionary: the values are whole
+  /// documents, nearly all distinct, and a forward index of dict ids over 
them costs more than it saves. So the
+  /// encoding is forced RAW here rather than taken from the config.
+  @Nullable
+  private static FieldIndexConfigs sparseChildConfigs(OpenStructIndexConfig 
openStructConfig,
+      FieldSpec blobFieldSpec) {
+    FieldConfig sparseFieldConfig = openStructConfig.getSparseFieldConfig();
+    boolean legacyJsonIndex = openStructConfig.isSparseJsonIndex();
+    if (sparseFieldConfig == null && !legacyJsonIndex) {
+      return null;
+    }
+    FieldConfig.Builder builder = sparseFieldConfig != null
+        ? new FieldConfig.Builder(sparseFieldConfig) : new 
FieldConfig.Builder(blobFieldSpec.getName());
+    builder.withEncodingType(FieldConfig.EncodingType.RAW);
+    if (legacyJsonIndex) {
+      // `sparseJsonIndex: true` predates sparseFieldConfig and means exactly 
one thing: a JSON index on the blob.
+      ObjectNode indexes = sparseFieldConfig != null && 
sparseFieldConfig.getIndexes() instanceof ObjectNode existing
+          ? existing.deepCopy() : JsonUtils.newObjectNode();
+      if (!indexes.has(StandardIndexes.json().getPrettyName())) {
+        indexes.set(StandardIndexes.json().getPrettyName(), 
JsonUtils.newObjectNode());
+      }
+      builder.withIndexes(indexes);
+    }
+    return FieldIndexConfigsUtil.fromFieldConfig(builder.build(), 
blobFieldSpec);
+  }
+
   public IndexLoadingConfig withKnownColumns(Set<String> columns) {
     if (_knownColumns != null && _knownColumns.containsAll(columns)) {
       return this;
diff --git 
a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/index/openstruct/ImmutableOpenStructDataSource.java
 
b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/index/openstruct/ImmutableOpenStructDataSource.java
index 3176bbf9c88..7bd3274325b 100644
--- 
a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/index/openstruct/ImmutableOpenStructDataSource.java
+++ 
b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/index/openstruct/ImmutableOpenStructDataSource.java
@@ -22,6 +22,7 @@ import java.io.IOException;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.Optional;
 import java.util.Set;
 import java.util.concurrent.ConcurrentHashMap;
 import javax.annotation.Nullable;
@@ -33,6 +34,8 @@ import org.apache.pinot.segment.spi.Constants;
 import org.apache.pinot.segment.spi.datasource.DataSource;
 import org.apache.pinot.segment.spi.datasource.DataSourceMetadata;
 import org.apache.pinot.segment.spi.datasource.OpenStructDataSource;
+import org.apache.pinot.segment.spi.index.IndexService;
+import org.apache.pinot.segment.spi.index.IndexType;
 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.JsonIndexReader;
@@ -47,6 +50,10 @@ import org.apache.pinot.spi.utils.JsonUtils;
 /// get virtual [SparseKeyDataSource]s backed by the shared blob parser; keys 
absent from the segment (no sparse blob,
 /// or not listed in the sparse manifest) resolve to an all-null 
[NullDataSource].
 public class ImmutableOpenStructDataSource extends BaseDataSource implements 
OpenStructDataSource {
+  /// Looked up by id rather than referenced: the composite JSON index ships 
as a plugin and is absent from most
+  /// deployments, so this resolves to nothing unless one registered it.
+  private static final String COMPOSITE_JSON_INDEX_ID = "composite_json_index";
+
   private final ComplexFieldSpec _fieldSpec;
   private final Map<String, DataSource> _perKeyDataSources;
   @Nullable
@@ -189,7 +196,19 @@ public class ImmutableOpenStructDataSource extends 
BaseDataSource implements Ope
   @Override
   @Nullable
   public JsonIndexReader getSparseJsonIndex() {
-    return _sparseDataSource != null ? _sparseDataSource.getJsonIndex() : null;
+    if (_sparseDataSource == null) {
+      return null;
+    }
+    JsonIndexReader jsonIndex = _sparseDataSource.getJsonIndex();
+    if (jsonIndex != null) {
+      return jsonIndex;
+    }
+    // A composite JSON index reads as a [JsonIndexReader] and answers the 
same predicates, so a sparse key is
+    // served by whichever of the two the blob was given. This is the fallback 
an ordinary column already gets in
+    // [org.apache.pinot.core.operator.filter.MapFilterOperator]; without it 
here the index is built on the blob and
+    // then never consulted, and the key falls back to a full scan that 
silently costs what the index was for.
+    Optional<IndexType<?, ?, ?>> compositeIndex = 
IndexService.getInstance().getOptional(COMPOSITE_JSON_INDEX_ID);
+    return compositeIndex.map(indexType -> (JsonIndexReader) 
_sparseDataSource.getIndex(indexType)).orElse(null);
   }
 
   @Nullable
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 853b52ab7f3..dfb66045868 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
@@ -60,8 +60,9 @@ public class OpenStructIndexType
   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";
+  private static final String KEY_ENCODING_RULE = "a key is dictionary-encoded 
when its encodingType is "
+      + "DICTIONARY, or indexes.dictionary enables one, or its inverted index 
is enabled, and raw with LZ4 "
+      + "otherwise";
 
   protected OpenStructIndexType() {
     super(StandardIndexes.OPEN_STRUCT_ID);
@@ -175,7 +176,10 @@ public class OpenStructIndexType
     // 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.
+    JsonNode declaredDictionary = indexes.get(StandardIndexes.DICTIONARY_ID);
     boolean dictionaryEncoded = fieldConfig.getEncodingType() != 
FieldConfig.EncodingType.RAW
+        || (declaredDictionary != null && declaredDictionary.isObject()
+            && !declaredDictionary.path("disabled").asBoolean(false))
         || isEnabled(indexes.get(StandardIndexes.inverted().getPrettyName()));
     if (forwardIndex != null && !forwardIndex.isNull()) {
       validatePerKeyForwardIndex(forwardIndex, dictionaryEncoded, column, 
target);
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 c7f772bbc96..139f6e7a747 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
@@ -261,9 +261,6 @@ public class OpenStructIndexTypeTest {
         {"{'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 "
diff --git 
a/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/segment/index/openstruct/OpenStructPerKeyIndexReloadTest.java
 
b/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/segment/index/openstruct/OpenStructPerKeyIndexReloadTest.java
index 54d710a237c..8f59da54d97 100644
--- 
a/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/segment/index/openstruct/OpenStructPerKeyIndexReloadTest.java
+++ 
b/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/segment/index/openstruct/OpenStructPerKeyIndexReloadTest.java
@@ -166,6 +166,19 @@ public class OpenStructPerKeyIndexReloadTest {
     assertFalse(hasIndex(segmentDir, host, StandardIndexes.dictionary()), "the 
raw key must stay raw");
   }
 
+  private static List<FieldConfig> mixedMatrixKeys() {
+    FieldConfig views = new FieldConfig.Builder("views")
+        .withIndexes(JsonUtils.objectToJsonNode(Map.of("inverted", Map.of())))
+        .build();
+    FieldConfig cpu = new FieldConfig.Builder("cpu")
+        .withEncodingType(FieldConfig.EncodingType.DICTIONARY)
+        .build();
+    FieldConfig host = new FieldConfig.Builder("host")
+        .withEncodingType(FieldConfig.EncodingType.RAW)
+        .build();
+    return List.of(views, cpu, host);
+  }
+
   private static OpenStructIndexConfig withMixedMatrix() {
     FieldConfig views = new FieldConfig.Builder("views")
         .withIndexes(JsonUtils.objectToJsonNode(Map.of("inverted", Map.of())))
@@ -180,6 +193,109 @@ public class OpenStructPerKeyIndexReloadTest {
         List.of(views, cpu, host));
   }
 
+  /// The spelling every other kind of column uses: RAW encoding, with the 
dictionary asked for under `indexes`.
+  ///
+  /// An External Table requires RAW on a column's own field config, so this 
is the shape a reader who has
+  /// configured one before will reach for. An enabled inverted index already 
forced a dictionary on its own, so
+  /// this combination worked before `indexes.dictionary` was honoured; it is 
here to keep it working.
+  @Test
+  public void testADictionaryAskedForUnderIndexesIsBuilt()
+      throws Exception {
+    File segmentDir = buildSegment("dictionaryViaIndexes", 
withoutPerKeyIndex());
+    reload(segmentDir, rawKeyWithDictionaryUnderIndexes());
+
+    assertTrue(hasIndex(segmentDir, CHILD, StandardIndexes.dictionary()),
+        "indexes.dictionary must enable the dictionary on a RAW key");
+    assertTrue(hasIndex(segmentDir, CHILD, StandardIndexes.inverted()),
+        "and the inverted index built on top of it must exist");
+  }
+
+  /// The same spelling on a key whose only index is a range one, and the case 
that was actually broken.
+  ///
+  /// Unlike an inverted index, a range index needs a dictionary without 
forcing one, so nothing rescued a key
+  /// written this way: the dictionary entry was ignored, the key stayed raw, 
and the range index was built over
+  /// no dictionary at all. Reverting the fix fails this test and not the one 
above it.
+  @Test
+  public void testARangeOnlyKeyCanAskForADictionaryUnderIndexes()
+      throws Exception {
+    File segmentDir = buildSegment("rangeViaIndexes", withoutPerKeyIndex());
+    reload(segmentDir, rawRangeKeyWithDictionaryUnderIndexes());
+
+    assertTrue(hasIndex(segmentDir, 
OpenStructNaming.materializedColumnName(COLUMN, "views"),
+        StandardIndexes.dictionary()), "a range-only key may ask for a 
dictionary the same way");
+  }
+
+  private static OpenStructIndexConfig rawKeyWithDictionaryUnderIndexes() {
+    ObjectNode indexes = JsonUtils.newObjectNode();
+    indexes.set("dictionary", JsonUtils.newObjectNode());
+    indexes.set("inverted", JsonUtils.newObjectNode());
+    FieldConfig keyConfig = new FieldConfig.Builder(KEY)
+        .withEncodingType(FieldConfig.EncodingType.RAW)
+        .withIndexes(indexes)
+        .build();
+    return new OpenStructIndexConfig(false, null, -1, Set.of(KEY), 0.0, 
List.of(keyConfig));
+  }
+
+  private static OpenStructIndexConfig rawRangeKeyWithDictionaryUnderIndexes() 
{
+    ObjectNode indexes = JsonUtils.newObjectNode();
+    indexes.set("dictionary", JsonUtils.newObjectNode());
+    indexes.set("range", JsonUtils.newObjectNode());
+    FieldConfig keyConfig = new FieldConfig.Builder("views")
+        .withEncodingType(FieldConfig.EncodingType.RAW)
+        .withIndexes(indexes)
+        .build();
+    return new OpenStructIndexConfig(false, null, -1, Set.of("views"), 0.0, 
List.of(keyConfig));
+  }
+
+  /// The shared blob is an ordinary single-value STRING column holding each 
row's document, so an index that
+  /// reads JSON out of a STRING column applies to it. Without one, every 
predicate on an unshredded key scans.
+  @Test
+  public void testAJsonIndexCanBeBuiltOnTheSparseBlob()
+      throws Exception {
+    // The blob only exists when some key is left out of the dense set, so the 
segment is built with a budget.
+    File segmentDir = buildSegment("blobJsonIndex", withMixedMatrix());
+    String sparse = OpenStructNaming.sparseColumnName(COLUMN);
+    assertTrue(new 
SegmentMetadataImpl(segmentDir).getColumnMetadataMap().containsKey(sparse),
+        "precondition: a key was left unmaterialized, so the blob column 
exists");
+    assertFalse(hasIndex(segmentDir, sparse, StandardIndexes.json()),
+        "precondition: the blob starts with no index");
+
+    reload(segmentDir, withSparseJsonIndex());
+
+    assertTrue(hasIndex(segmentDir, sparse, StandardIndexes.json()),
+        "sparseJsonIndex must build a JSON index over the blob");
+    assertFalse(hasIndex(segmentDir, sparse, StandardIndexes.dictionary()),
+        "and the blob must never get a dictionary -- its values are whole 
documents");
+  }
+
+  /// `sparseFieldConfig` names the index directly, so any registered index 
type that accepts a STRING column can
+  /// be asked for on the blob rather than only the one `sparseJsonIndex` 
hard-codes.
+  @Test
+  public void testTheBlobIndexCanBeNamedDirectly()
+      throws Exception {
+    File segmentDir = buildSegment("blobNamedIndex", withMixedMatrix());
+    reload(segmentDir, withSparseFieldConfig("json"));
+
+    assertTrue(hasIndex(segmentDir, OpenStructNaming.sparseColumnName(COLUMN), 
StandardIndexes.json()),
+        "an index named under sparseFieldConfig.indexes must be built on the 
blob");
+  }
+
+  private static OpenStructIndexConfig withSparseJsonIndex() {
+    return new OpenStructIndexConfig(false, null, 3, Set.of("views", "cpu", 
"host"), 0.5,
+        mixedMatrixKeys(), true);
+  }
+
+  private static OpenStructIndexConfig withSparseFieldConfig(String indexName) 
{
+    ObjectNode indexes = JsonUtils.newObjectNode();
+    indexes.set(indexName, JsonUtils.newObjectNode());
+    FieldConfig blob = new 
FieldConfig.Builder(OpenStructNaming.sparseColumnName(COLUMN))
+        .withEncodingType(FieldConfig.EncodingType.RAW)
+        .withIndexes(indexes)
+        .build();
+    return new OpenStructIndexConfig(false, null, 3, Set.of("views", "cpu", 
"host"), 0.5,
+        mixedMatrixKeys(), null, null, null, null, blob);
+  }
+
   // ---------------------------------------------------------------- fixtures
 
   private static OpenStructIndexConfig withoutPerKeyIndex() {
diff --git 
a/pinot-segment-spi/src/main/java/org/apache/pinot/segment/spi/index/FieldIndexConfigsUtil.java
 
b/pinot-segment-spi/src/main/java/org/apache/pinot/segment/spi/index/FieldIndexConfigsUtil.java
index 3bc61ae3fbb..a79858a2d83 100644
--- 
a/pinot-segment-spi/src/main/java/org/apache/pinot/segment/spi/index/FieldIndexConfigsUtil.java
+++ 
b/pinot-segment-spi/src/main/java/org/apache/pinot/segment/spi/index/FieldIndexConfigsUtil.java
@@ -68,13 +68,22 @@ public class FieldIndexConfigsUtil {
   /// synthetic columns (e.g. OPEN_STRUCT materialized children) that exist in 
no schema.
   public static FieldIndexConfigs fromFieldConfig(@Nullable FieldConfig 
fieldConfig, FieldSpec fieldSpec) {
     FieldIndexConfigs.Builder builder = new FieldIndexConfigs.Builder();
-    FieldConfig.EncodingType encodingType =
+    FieldConfig.EncodingType declaredEncoding =
         fieldConfig != null && fieldConfig.getEncodingType() != null ? 
fieldConfig.getEncodingType()
             : FieldConfig.EncodingType.DICTIONARY;
-    boolean rawEncoded = encodingType == FieldConfig.EncodingType.RAW;
-    builder.add(StandardIndexes.dictionary(),
-        rawEncoded ? DictionaryIndexConfig.DISABLED : 
DictionaryIndexConfig.DEFAULT);
     JsonNode indexes = fieldConfig != null ? fieldConfig.getIndexes() : null;
+    // `indexes.dictionary` decides when it is there, and the column-level 
encoding decides otherwise. Both spellings
+    // have to work: every other column turns a dictionary on through 
`indexes`, and a reader who has learned that
+    // will write it here too -- silently ignoring it leaves them with a raw 
column and no clue why.
+    boolean dictionaryEncoded =
+        declaredEncoding != FieldConfig.EncodingType.RAW || 
dictionaryEnabledByIndexes(indexes);
+    builder.add(StandardIndexes.dictionary(),
+        dictionaryEncoded ? DictionaryIndexConfig.DEFAULT : 
DictionaryIndexConfig.DISABLED);
+    // The forward index follows the dictionary decision, not the declared 
encoding: a key declared RAW whose
+    // dictionary was turned on through `indexes` needs a dict-encoded forward 
index, or a reload rebuilds one over
+    // a dictionary that is not there ("Dictionary should still exist after 
rebuilding dict-encoded forward index").
+    FieldConfig.EncodingType encodingType =
+        dictionaryEncoded ? FieldConfig.EncodingType.DICTIONARY : 
FieldConfig.EncodingType.RAW;
     for (IndexType<?, ?, ?> indexType : 
IndexService.getInstance().getAllIndexes()) {
       if (indexType.getId().equals(StandardIndexes.DICTIONARY_ID)) {
         continue;
@@ -93,6 +102,15 @@ public class FieldIndexConfigsUtil {
     return builder.build();
   }
 
+  /// Whether `indexes.dictionary` asks for a dictionary. It can only turn one 
*on*: that is how a dictionary is
+  /// enabled on every other kind of column, and a reader who has learned that 
spelling will use it here too.
+  /// Turning one off is left to `encodingType`, so a `disabled` entry that 
contradicts the column's own encoding
+  /// stays a contradiction rather than silently winning.
+  private static boolean dictionaryEnabledByIndexes(@Nullable JsonNode 
indexes) {
+    JsonNode dictionary = indexes != null ? 
indexes.get(StandardIndexes.DICTIONARY_ID) : null;
+    return dictionary != null && dictionary.isObject() && 
!dictionary.path("disabled").asBoolean(false);
+  }
+
   private static ForwardIndexConfig forwardConfig(IndexType<?, ?, ?> 
forwardIndexType, @Nullable JsonNode indexes,
       FieldConfig.EncodingType encodingType) {
     JsonNode node = indexes != null ? 
indexes.get(forwardIndexType.getPrettyName()) : null;
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 a60d6474580..f94770c6a56 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
@@ -85,6 +85,9 @@ public class OpenStructIndexConfig extends IndexConfig {
   private final double _denseKeyMinFillRate;
   private final List<FieldConfig> _valueFieldConfigs;
   private final boolean _sparseJsonIndex;
+  /// Index settings for the shared sparse blob column, shaped like a 
[FieldConfig] for any other column.
+  @Nullable
+  private final FieldConfig _sparseFieldConfig;
   private final boolean _perKeyMetricsEnabled;
   private final Set<String> _ignoredKeys;
   private final int _maxNestedKeyDepth;
@@ -138,6 +141,18 @@ public class OpenStructIndexConfig extends IndexConfig {
         sparseJsonIndex, perKeyMetricsEnabled, ignoredKeys, null);
   }
 
+  /// @deprecated Use the 11-arg constructor accepting `sparseFieldConfig`. 
Kept for binary compatibility with
+  /// callers built against the pre-`sparseFieldConfig` signature.
+  @Deprecated
+  public OpenStructIndexConfig(Boolean disabled, @Nullable FieldConfig 
defaultValueFieldConfig,
+      @Nullable Integer maxDenseKeys, @Nullable Set<String> denseKeys, 
@Nullable Double denseKeyMinFillRate,
+      @Nullable List<FieldConfig> valueFieldConfigs, @Nullable Boolean 
sparseJsonIndex,
+      @Nullable Boolean perKeyMetricsEnabled, @Nullable Set<String> 
ignoredKeys,
+      @Nullable Integer maxNestedKeyDepth) {
+    this(disabled, defaultValueFieldConfig, maxDenseKeys, denseKeys, 
denseKeyMinFillRate, valueFieldConfigs,
+        sparseJsonIndex, perKeyMetricsEnabled, ignoredKeys, maxNestedKeyDepth, 
null);
+  }
+
   @JsonCreator
   public OpenStructIndexConfig(
       @JsonProperty("disabled") Boolean disabled,
@@ -149,8 +164,10 @@ public class OpenStructIndexConfig extends IndexConfig {
       @JsonProperty("sparseJsonIndex") @Nullable Boolean sparseJsonIndex,
       @JsonProperty("perKeyMetricsEnabled") @Nullable Boolean 
perKeyMetricsEnabled,
       @JsonProperty("ignoredKeys") @Nullable Set<String> ignoredKeys,
-      @JsonProperty("maxNestedKeyDepth") @Nullable Integer maxNestedKeyDepth) {
+      @JsonProperty("maxNestedKeyDepth") @Nullable Integer maxNestedKeyDepth,
+      @JsonProperty("sparseFieldConfig") @Nullable FieldConfig 
sparseFieldConfig) {
     super(disabled);
+    _sparseFieldConfig = sparseFieldConfig;
     _defaultValueFieldConfig = defaultValueFieldConfig;
     _maxDenseKeys = maxDenseKeys != null ? maxDenseKeys : 
DEFAULT_MAX_DENSE_KEYS;
     _denseKeys = denseKeys;
@@ -250,6 +267,17 @@ public class OpenStructIndexConfig extends IndexConfig {
     return _sparseJsonIndex;
   }
 
+  /// Index settings for the shared sparse blob column, or null when the table 
asked for none.
+  ///
+  /// The blob is a single-value STRING column holding each row's unshredded 
keys as a JSON document, so any index
+  /// that reads JSON out of a STRING column applies to it. `sparseJsonIndex: 
true` is the older spelling of
+  /// asking for a JSON index here and still works.
+  @JsonProperty("sparseFieldConfig")
+  @Nullable
+  public FieldConfig getSparseFieldConfig() {
+    return _sparseFieldConfig;
+  }
+
   /// When `true`, `OPEN_STRUCT_LAST_SEGMENT_KEY_DOC_COUNT` is emitted for 
every key present in the
   /// sealed segment — dense, sparse, configured, or discovered. The cost is 
that the number of
   /// metrics-registry entries follows the ingested key space, and table 
deletion can only sweep keys


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

Reply via email to