xiangfu0 commented on code in PR #18406:
URL: https://github.com/apache/pinot/pull/18406#discussion_r3201306396


##########
pinot-segment-local/src/main/java/org/apache/pinot/segment/local/utils/TableConfigUtils.java:
##########
@@ -2111,6 +2115,372 @@ private static void overwriteConfig(JsonNode oldCfg, 
JsonNode newCfg) {
     }
   }
 
+  // Top-level keys in {@code tableIndexConfig} that look like per-column 
collections (string-array of column
+  // names) but must NOT be touched by the consuming-override scrub. Anything 
else of string-array shape is treated
+  // as a per-column list and the overridden column name is stripped from it. 
Inverting the policy this way
+  // (deny-list rather than allow-list) keeps the scrub correct as new 
per-column index config keys are added to
+  // {@link IndexingConfig} — there is no hand-curated list to drift out of 
sync. Today every string-array in
+  // {@link IndexingConfig} IS a per-column list (`invertedIndexColumns`, 
`noDictionaryColumns`,
+  // `bloomFilterColumns`, `rangeIndexColumns`, `jsonIndexColumns`, 
`onHeapDictionaryColumns`,
+  // `varLengthDictionaryColumns`), so this is correct. **Important:** any 
future non-per-column string-array field
+  // added to {@link IndexingConfig} MUST be added to this exclusion set, 
otherwise the scrub will silently strip
+  // entries from it whenever an overridden column name happens to match.
+  //
+  // - sortedColumn is structurally tied to the segment layout; a separate 
validate-time check rejects overrides
+  //   on sorted columns.
+  // - starTreeIndexConfigs is a list of objects (not column names); leave it 
alone.
+  // - tierOverwrites is the tier-overlay JSON, handled by a separate 
transform.
+  private static final Set<String> CONSUMING_OVERRIDE_SCRUB_EXCLUDED_KEYS = 
Set.of(
+      "sortedColumn",
+      "starTreeIndexConfigs",
+      "tierOverwrites");
+
+  /// Returns a [TableConfig] with each [FieldConfig#getConsumingOverride()] 
merged into its parent
+  /// [FieldConfig]. The result is intended for building the realtime *mutable 
consuming* segment only.
+  /// The committed/immutable segment and all loaded immutable segments use 
the un-overridden table config — that is
+  /// the persisted shape on disk. A typical use is keeping the table in raw 
encoding for storage efficiency while
+  /// giving the consuming segment a dictionary + inverted index for fast 
filtering during the consuming window.
+  ///
+  /// Supported override keys: `encodingType`, `indexes`. Other [FieldConfig] 
fields are intentionally not
+  /// overridable — see [#ALLOWED_CONSUMING_OVERRIDE_KEYS] for the rationale.
+  ///
+  /// Merge semantics: every supported field present in `consumingOverride` 
replaces the parent field wholesale.
+  /// For `indexes` the entire JSON subtree is replaced rather than merged 
key-by-key.
+  ///
+  /// For each overridden column, the column is also scrubbed from legacy 
per-column lists in `tableIndexConfig`
+  /// (e.g. `invertedIndexColumns`, `rangeIndexColumns`, ...) so that those 
lists do not contradict the merged
+  /// [FieldConfig].
+  ///
+  /// If no [FieldConfig] declares a consuming override, the original 
[TableConfig] is returned unchanged.
+  ///
+  /// @param tableConfig original table config; not mutated
+  /// @return a new table config with overrides merged, or the original config 
when no overrides are configured
+  public static TableConfig applyConsumingOverrides(TableConfig tableConfig) {
+    if (!hasConsumingOverride(tableConfig)) {
+      return tableConfig;
+    }
+    // Enforce the same invariants that {@link #validateConsumingOverrides} 
enforces, so a hand-edited or
+    // older-controller-written TableConfig can't reach the merge with an 
unsupported override and silently produce
+    // a wrong-shape consuming segment. We re-check here (rather than relying 
solely on validate-time) because the
+    // merge runs on every server at consuming-segment build time and may see 
stale configs.
+    enforceConsumingOverrideInvariants(tableConfig);
+    try {
+      // Deep-copy the JSON tree before mutating it. {@code toJsonNode} may 
alias subtrees on JsonNode-typed fields
+      // of FieldConfig (notably {@code _indexes}, {@code _tierOverwrites}, 
{@code _consumingOverride}); mutating
+      // those in place would silently corrupt the cached TableConfig that 
other threads are concurrently reading.
+      JsonNode tblCfgJson = tableConfig.toJsonNode().deepCopy();
+      JsonNode fieldCfgListJson = 
tblCfgJson.get(TableConfig.FIELD_CONFIG_LIST_KEY);
+      // hasConsumingOverride already proved at least one FieldConfig has a 
non-empty override, so the JSON view
+      // must surface the field config list as an array. If not, the 
TableConfig (de)serialization layer is
+      // inconsistent — fail loudly rather than silently shipping the 
un-overridden shape.
+      Preconditions.checkState(fieldCfgListJson != null && 
fieldCfgListJson.isArray(),
+          "fieldConfigList missing from JSON view of table %s despite 
hasConsumingOverride==true",
+          tableConfig.getTableName());
+      JsonNode tblIdxCfgJson = tblCfgJson.get(TableConfig.INDEXING_CONFIG_KEY);
+      Set<String> overriddenColumns = new HashSet<>();
+      Iterator<JsonNode> fieldCfgItr = fieldCfgListJson.elements();
+      while (fieldCfgItr.hasNext()) {
+        JsonNode fieldCfgJson = fieldCfgItr.next();
+        JsonNode overrideJson = 
fieldCfgJson.get(TableConfig.CONSUMING_OVERRIDE_KEY);
+        if (overrideJson == null || !overrideJson.isObject() || 
overrideJson.isEmpty()) {
+          continue;
+        }
+        JsonNode nameNode = fieldCfgJson.get("name");
+        // FieldConfig's @JsonCreator already guarantees name is non-null, but 
we re-parsed via toJsonNode so guard
+        // defensively. A missing name is a bug — fail loudly rather than 
silently dropping the override.
+        Preconditions.checkState(nameNode != null && nameNode.isTextual(),
+            "FieldConfig with consumingOverride is missing a textual 'name' 
field in table: %s",
+            tableConfig.getTableName());
+        overriddenColumns.add(nameNode.asText());
+        overwriteConfig(fieldCfgJson, overrideJson);
+        // Remove the override marker so downstream consumers see only the 
merged shape.
+        ((ObjectNode) fieldCfgJson).remove(TableConfig.CONSUMING_OVERRIDE_KEY);
+      }
+      Preconditions.checkState(!overriddenColumns.isEmpty(),
+          "no overridden columns produced from JSON view of table %s despite 
hasConsumingOverride==true",
+          tableConfig.getTableName());
+      if (tblIdxCfgJson != null && tblIdxCfgJson.isObject()) {
+        scrubOverriddenColumnsFromTableIndexConfig((ObjectNode) tblIdxCfgJson, 
overriddenColumns);
+      }
+      return JsonUtils.jsonNodeToObject(tblCfgJson, TableConfig.class);
+    } catch (IOException e) {
+      // Surface override parse/deserialization failures (e.g. unknown keys, 
type mismatches) loudly so the bad
+      // config is rejected at validate time rather than silently falling back 
to the un-overridden shape — that
+      // would build the consuming segment with the wrong layout and the user 
would get no signal.
+      throw new IllegalStateException(
+          "Failed to apply consuming overrides for table: " + 
tableConfig.getTableName(), e);
+    }
+  }
+
+  /// For each top-level entry in `tableIndexConfig` that holds an array of 
column-name strings (legacy
+  /// per-column lists like `invertedIndexColumns`, `noDictionaryColumns`, 
etc.), strip any name in
+  /// `overriddenColumns`. Entries whose key is in 
[#CONSUMING_OVERRIDE_SCRUB_EXCLUDED_KEYS], that are not arrays,
+  /// or that contain non-string elements, are left alone.
+  ///
+  /// Object-shaped values (e.g. `segmentPartitionConfig.columnPartitionMap`, 
feature configs) are intentionally
+  /// NOT scrubbed: the heuristic "drop any object key matching an overridden 
column name" would silently drop
+  /// unrelated feature-config keys whose names happen to collide with a 
column name. Validation already rejects
+  /// overrides on partition columns up front.
+  private static void scrubOverriddenColumnsFromTableIndexConfig(ObjectNode 
tblIdxCfgObj,
+      Set<String> overriddenColumns) {
+    Iterator<Map.Entry<String, JsonNode>> entries = 
tblIdxCfgObj.properties().iterator();
+    while (entries.hasNext()) {
+      Map.Entry<String, JsonNode> entry = entries.next();
+      String key = entry.getKey();
+      if (CONSUMING_OVERRIDE_SCRUB_EXCLUDED_KEYS.contains(key)) {
+        continue;
+      }
+      JsonNode value = entry.getValue();
+      if (value == null || !value.isArray() || value.isEmpty()) {

Review Comment:
   This scrub only handles array-valued `tableIndexConfig` entries, but 
`ForwardIndexType` still treats `noDictionaryConfig.keySet()` as the RAW 
signal. That means a table using `noDictionaryConfig` for the persisted RAW 
column cannot opt into `consumingOverride.encodingType=DICTIONARY`: the merged 
config still carries the map entry and 
`validateIndexingConfigAndFieldConfigList` will reject it as a 
DICTIONARY-vs-noDictionary conflict. Please scrub `noDictionaryConfig` for 
overridden columns too, otherwise the documented RAW-on-disk / 
DICTIONARY-on-consuming use case only works for `noDictionaryColumns`.



##########
pinot-segment-local/src/main/java/org/apache/pinot/segment/local/utils/TableConfigUtils.java:
##########
@@ -2111,6 +2115,372 @@ private static void overwriteConfig(JsonNode oldCfg, 
JsonNode newCfg) {
     }
   }
 
+  // Top-level keys in {@code tableIndexConfig} that look like per-column 
collections (string-array of column
+  // names) but must NOT be touched by the consuming-override scrub. Anything 
else of string-array shape is treated
+  // as a per-column list and the overridden column name is stripped from it. 
Inverting the policy this way
+  // (deny-list rather than allow-list) keeps the scrub correct as new 
per-column index config keys are added to
+  // {@link IndexingConfig} — there is no hand-curated list to drift out of 
sync. Today every string-array in
+  // {@link IndexingConfig} IS a per-column list (`invertedIndexColumns`, 
`noDictionaryColumns`,
+  // `bloomFilterColumns`, `rangeIndexColumns`, `jsonIndexColumns`, 
`onHeapDictionaryColumns`,
+  // `varLengthDictionaryColumns`), so this is correct. **Important:** any 
future non-per-column string-array field
+  // added to {@link IndexingConfig} MUST be added to this exclusion set, 
otherwise the scrub will silently strip
+  // entries from it whenever an overridden column name happens to match.
+  //
+  // - sortedColumn is structurally tied to the segment layout; a separate 
validate-time check rejects overrides
+  //   on sorted columns.
+  // - starTreeIndexConfigs is a list of objects (not column names); leave it 
alone.
+  // - tierOverwrites is the tier-overlay JSON, handled by a separate 
transform.
+  private static final Set<String> CONSUMING_OVERRIDE_SCRUB_EXCLUDED_KEYS = 
Set.of(
+      "sortedColumn",
+      "starTreeIndexConfigs",
+      "tierOverwrites");
+
+  /// Returns a [TableConfig] with each [FieldConfig#getConsumingOverride()] 
merged into its parent
+  /// [FieldConfig]. The result is intended for building the realtime *mutable 
consuming* segment only.
+  /// The committed/immutable segment and all loaded immutable segments use 
the un-overridden table config — that is
+  /// the persisted shape on disk. A typical use is keeping the table in raw 
encoding for storage efficiency while
+  /// giving the consuming segment a dictionary + inverted index for fast 
filtering during the consuming window.
+  ///
+  /// Supported override keys: `encodingType`, `indexes`. Other [FieldConfig] 
fields are intentionally not
+  /// overridable — see [#ALLOWED_CONSUMING_OVERRIDE_KEYS] for the rationale.
+  ///
+  /// Merge semantics: every supported field present in `consumingOverride` 
replaces the parent field wholesale.
+  /// For `indexes` the entire JSON subtree is replaced rather than merged 
key-by-key.
+  ///
+  /// For each overridden column, the column is also scrubbed from legacy 
per-column lists in `tableIndexConfig`
+  /// (e.g. `invertedIndexColumns`, `rangeIndexColumns`, ...) so that those 
lists do not contradict the merged
+  /// [FieldConfig].
+  ///
+  /// If no [FieldConfig] declares a consuming override, the original 
[TableConfig] is returned unchanged.
+  ///
+  /// @param tableConfig original table config; not mutated
+  /// @return a new table config with overrides merged, or the original config 
when no overrides are configured
+  public static TableConfig applyConsumingOverrides(TableConfig tableConfig) {
+    if (!hasConsumingOverride(tableConfig)) {
+      return tableConfig;
+    }
+    // Enforce the same invariants that {@link #validateConsumingOverrides} 
enforces, so a hand-edited or
+    // older-controller-written TableConfig can't reach the merge with an 
unsupported override and silently produce
+    // a wrong-shape consuming segment. We re-check here (rather than relying 
solely on validate-time) because the
+    // merge runs on every server at consuming-segment build time and may see 
stale configs.
+    enforceConsumingOverrideInvariants(tableConfig);
+    try {
+      // Deep-copy the JSON tree before mutating it. {@code toJsonNode} may 
alias subtrees on JsonNode-typed fields
+      // of FieldConfig (notably {@code _indexes}, {@code _tierOverwrites}, 
{@code _consumingOverride}); mutating
+      // those in place would silently corrupt the cached TableConfig that 
other threads are concurrently reading.
+      JsonNode tblCfgJson = tableConfig.toJsonNode().deepCopy();
+      JsonNode fieldCfgListJson = 
tblCfgJson.get(TableConfig.FIELD_CONFIG_LIST_KEY);
+      // hasConsumingOverride already proved at least one FieldConfig has a 
non-empty override, so the JSON view
+      // must surface the field config list as an array. If not, the 
TableConfig (de)serialization layer is
+      // inconsistent — fail loudly rather than silently shipping the 
un-overridden shape.
+      Preconditions.checkState(fieldCfgListJson != null && 
fieldCfgListJson.isArray(),
+          "fieldConfigList missing from JSON view of table %s despite 
hasConsumingOverride==true",
+          tableConfig.getTableName());
+      JsonNode tblIdxCfgJson = tblCfgJson.get(TableConfig.INDEXING_CONFIG_KEY);
+      Set<String> overriddenColumns = new HashSet<>();
+      Iterator<JsonNode> fieldCfgItr = fieldCfgListJson.elements();
+      while (fieldCfgItr.hasNext()) {
+        JsonNode fieldCfgJson = fieldCfgItr.next();
+        JsonNode overrideJson = 
fieldCfgJson.get(TableConfig.CONSUMING_OVERRIDE_KEY);
+        if (overrideJson == null || !overrideJson.isObject() || 
overrideJson.isEmpty()) {
+          continue;
+        }
+        JsonNode nameNode = fieldCfgJson.get("name");
+        // FieldConfig's @JsonCreator already guarantees name is non-null, but 
we re-parsed via toJsonNode so guard
+        // defensively. A missing name is a bug — fail loudly rather than 
silently dropping the override.
+        Preconditions.checkState(nameNode != null && nameNode.isTextual(),
+            "FieldConfig with consumingOverride is missing a textual 'name' 
field in table: %s",
+            tableConfig.getTableName());
+        overriddenColumns.add(nameNode.asText());
+        overwriteConfig(fieldCfgJson, overrideJson);
+        // Remove the override marker so downstream consumers see only the 
merged shape.
+        ((ObjectNode) fieldCfgJson).remove(TableConfig.CONSUMING_OVERRIDE_KEY);
+      }
+      Preconditions.checkState(!overriddenColumns.isEmpty(),
+          "no overridden columns produced from JSON view of table %s despite 
hasConsumingOverride==true",
+          tableConfig.getTableName());
+      if (tblIdxCfgJson != null && tblIdxCfgJson.isObject()) {
+        scrubOverriddenColumnsFromTableIndexConfig((ObjectNode) tblIdxCfgJson, 
overriddenColumns);
+      }
+      return JsonUtils.jsonNodeToObject(tblCfgJson, TableConfig.class);
+    } catch (IOException e) {
+      // Surface override parse/deserialization failures (e.g. unknown keys, 
type mismatches) loudly so the bad
+      // config is rejected at validate time rather than silently falling back 
to the un-overridden shape — that
+      // would build the consuming segment with the wrong layout and the user 
would get no signal.
+      throw new IllegalStateException(
+          "Failed to apply consuming overrides for table: " + 
tableConfig.getTableName(), e);
+    }
+  }
+
+  /// For each top-level entry in `tableIndexConfig` that holds an array of 
column-name strings (legacy
+  /// per-column lists like `invertedIndexColumns`, `noDictionaryColumns`, 
etc.), strip any name in
+  /// `overriddenColumns`. Entries whose key is in 
[#CONSUMING_OVERRIDE_SCRUB_EXCLUDED_KEYS], that are not arrays,
+  /// or that contain non-string elements, are left alone.
+  ///
+  /// Object-shaped values (e.g. `segmentPartitionConfig.columnPartitionMap`, 
feature configs) are intentionally
+  /// NOT scrubbed: the heuristic "drop any object key matching an overridden 
column name" would silently drop
+  /// unrelated feature-config keys whose names happen to collide with a 
column name. Validation already rejects
+  /// overrides on partition columns up front.
+  private static void scrubOverriddenColumnsFromTableIndexConfig(ObjectNode 
tblIdxCfgObj,
+      Set<String> overriddenColumns) {
+    Iterator<Map.Entry<String, JsonNode>> entries = 
tblIdxCfgObj.properties().iterator();
+    while (entries.hasNext()) {
+      Map.Entry<String, JsonNode> entry = entries.next();
+      String key = entry.getKey();
+      if (CONSUMING_OVERRIDE_SCRUB_EXCLUDED_KEYS.contains(key)) {
+        continue;
+      }
+      JsonNode value = entry.getValue();
+      if (value == null || !value.isArray() || value.isEmpty()) {
+        continue;
+      }
+      ArrayNode arr = (ArrayNode) value;
+      for (JsonNode element : arr) {
+        if (!element.isTextual()) {
+          // Non-string-array entry — not a per-column list; leave alone.
+          arr = null;
+          break;
+        }
+      }
+      if (arr == null) {
+        continue;
+      }
+      List<JsonNode> kept = new ArrayList<>(arr.size());
+      for (JsonNode element : arr) {
+        if (!overriddenColumns.contains(element.asText())) {
+          kept.add(element);
+        }
+      }
+      if (kept.size() != arr.size()) {
+        ArrayNode replacement = JsonUtils.newArrayNode();
+        for (JsonNode element : kept) {
+          replacement.add(element);
+        }
+        tblIdxCfgObj.set(key, replacement);
+      }
+    }
+  }
+
+  /// Builds the [RealtimeSegmentConfig.Builder] used to construct a mutable 
consuming segment, applying any
+  /// [FieldConfig#getConsumingOverride()] present on the table config. When 
no override is configured, the
+  /// un-modified [IndexLoadingConfig]-driven builder is returned so existing 
code paths (tier overwrites,
+  /// instance-level mutations on `IndexLoadingConfig`) flow through 
unchanged. If the override merge throws (a
+  /// misconfiguration that slipped past validate), the call falls back to the 
`IndexLoadingConfig`-driven builder,
+  /// logs the error, and invokes `onFallback` so the caller can emit a metric 
/ surface the degradation. The
+  /// fallback-applied builder produces a consuming segment matching the 
persisted shape (no override) for the
+  /// full lifetime of that consuming segment — there is no auto-retry; 
operators must reload the table after
+  /// fixing the override config to pick up the corrected shape on the next 
consuming-segment build.
+  ///
+  /// **IndexLoadingConfig contract on the override path:** the helper 
rebuilds a fresh [IndexLoadingConfig] from
+  /// `(indexLoadingConfig.getInstanceDataManagerConfig(), mergedTableConfig, 
schemaCopy)` and re-applies the
+  /// `segmentTier`. Any other in-place mutation the caller made on the 
supplied `indexLoadingConfig` (segment
+  /// version, star-tree configs, etc.) is **not** carried over on the 
override path. Callers that need such
+  /// mutations preserved must either set them on the returned Builder after 
this call, or extend this helper.
+  ///
+  /// **Schema clone:** the override path defensively deep-copies the schema 
via JSON round-trip because the
+  /// override-path `IndexLoadingConfig` constructor invokes 
`TimestampIndexUtils.applyTimestampIndex` which
+  /// mutates the schema under a lock keyed to the *new* (per-call) 
TableConfig instance. The non-override path
+  /// constructs `IndexLoadingConfig` once at table-data-manager-load time 
(not per-segment-build), so the lock
+  /// is not racing per call there. The clone is therefore needed only on the 
override path. TODO: fix
+  /// TimestampIndexUtils to not mutate its inputs so this clone can be 
removed.
+  ///
+  /// @param tableConfig source table config; not mutated
+  /// @param schema      schema in scope; deep-copied before being passed to 
the override-path IndexLoadingConfig
+  /// @param indexLoadingConfig fallback / no-override path; on the override 
path only `instanceDataManagerConfig`
+  ///                           and `segmentTier` are read (see contract above)
+  /// @param logger      logger to emit error on fallback
+  /// @param onFallback  optional callback invoked when the override merge 
fails and the helper falls back to the
+  ///                    persisted shape; intended for the caller to bump a 
metric (e.g.
+  ///                    [ServerMeter#CONSUMING_OVERRIDE_FALLBACK]) tagged 
with its own table-name context. May
+  ///                    be {@code null} to skip metric emission (e.g. in unit 
tests).
+  /// @return a Builder ready for the caller to chain `.set...` calls and 
`.build()`
+  public static RealtimeSegmentConfig.Builder 
buildConsumingSegmentConfigBuilder(TableConfig tableConfig,
+      Schema schema, IndexLoadingConfig indexLoadingConfig, Logger logger, 
@Nullable Runnable onFallback) {
+    if (hasConsumingOverride(tableConfig)) {
+      try {
+        TableConfig consumingTableConfig = 
applyConsumingOverrides(tableConfig);
+        Schema schemaCopy;
+        try {
+          schemaCopy = JsonUtils.jsonNodeToObject(schema.toJsonObject(), 
Schema.class);
+        } catch (IOException ioe) {
+          throw new IllegalStateException("Failed to clone schema for 
consumingOverride path on table: "
+              + tableConfig.getTableName(), ioe);
+        }
+        IndexLoadingConfig consumingIlc = new 
IndexLoadingConfig(indexLoadingConfig.getInstanceDataManagerConfig(),
+            consumingTableConfig, schemaCopy);
+        /// `segmentVersion` and `readMode` are already derived by the 
constructor from the same
+        /// `instanceDataManagerConfig` + 
`consumingTableConfig.indexingConfig` inputs the source ILC used, so they
+        /// don't need to be copied across. Tier overlay, however, is set by 
the caller post-construction (it is
+        /// not in either input), so we re-apply it explicitly.
+        String segmentTier = indexLoadingConfig.getSegmentTier();
+        if (segmentTier != null) {
+          consumingIlc.setSegmentTier(segmentTier);
+        }
+        return new RealtimeSegmentConfig.Builder(consumingIlc);
+      } catch (RuntimeException e) {
+        /// Include the override snippet so the consuming-segment build log 
line is self-sufficient for triage —
+        /// operators reading the log do not need to also fetch the table 
config from ZK to see which override
+        /// failed. The metric (CONSUMING_OVERRIDE_FALLBACK) is the alerting 
hook; this log is the diagnostic.
+        logger.error("Failed to apply consumingOverride for table: {} 
(override snippet: {}); falling back to "
+            + "persisted shape — consuming segment will run with the 
un-overridden shape for its full lifetime",
+            tableConfig.getTableName(), 
summarizeConsumingOverrides(tableConfig), e);
+        if (onFallback != null) {
+          onFallback.run();
+        }
+      }
+    }
+    return new RealtimeSegmentConfig.Builder(indexLoadingConfig);
+  }
+
+  /// Returns a `column → override` short summary for the failure log.
+  private static String summarizeConsumingOverrides(TableConfig tableConfig) {
+    List<FieldConfig> fieldConfigs = tableConfig.getFieldConfigList();
+    if (fieldConfigs == null) {
+      return "{}";
+    }
+    StringBuilder sb = new StringBuilder("{");
+    boolean first = true;
+    for (FieldConfig fieldConfig : fieldConfigs) {
+      JsonNode override = fieldConfig.getConsumingOverride();
+      if (override == null || !override.isObject() || override.isEmpty()) {
+        continue;
+      }
+      if (!first) {
+        sb.append(", ");
+      }
+      sb.append(fieldConfig.getName()).append('=').append(override);
+      first = false;
+    }
+    sb.append('}');
+    return sb.toString();
+  }
+
+  public static boolean hasConsumingOverride(TableConfig tableConfig) {
+    List<FieldConfig> fieldConfigList = tableConfig.getFieldConfigList();
+    if (fieldConfigList == null) {
+      return false;
+    }
+    for (FieldConfig fieldConfig : fieldConfigList) {
+      JsonNode overrideJson = fieldConfig.getConsumingOverride();
+      if (overrideJson != null && overrideJson.isObject() && 
!overrideJson.isEmpty()) {

Review Comment:
   Non-object `consumingOverride` values are silently treated as "no override" 
here. For example, `consumingOverride: []` or `consumingOverride: "foo"` passes 
validation, then the realtime path takes the default builder with no error, 
log, or metric. Since this is a user-facing config and the rest of the patch is 
trying to fail fast on bad override shapes, please reject any non-null 
`consumingOverride` that is not an object instead of letting 
`hasConsumingOverride()` filter it out.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


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

Reply via email to