nsivabalan commented on code in PR #19205:
URL: https://github.com/apache/hudi/pull/19205#discussion_r3714902831


##########
hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/BaseHoodieWriteClient.java:
##########
@@ -1548,9 +1549,40 @@ public void 
validateAgainstTableProperties(HoodieTableConfig tableConfig, Hoodie
     // mismatch of table versions.
     CommonClientUtils.validateTableVersion(tableConfig, writeConfig);
 
-    // Once meta fields are disabled, it cant be re-enabled for a given table.
-    if (!tableConfig.populateMetaFields() && writeConfig.populateMetaFields()) 
{
-      throw new HoodieException(HoodieTableConfig.POPULATE_META_FIELDS.key() + 
" already disabled for the table. Can't be re-enabled back");
+    // Meta-field population is physical, so a writer must not claim columns 
the table does not
+    // have. Compare the full enum rather than the legacy booleans: those 
collapse every selective
+    // mode to false, so a writer claiming COMMIT_TIME_ONLY against a NONE 
table would slip through
+    // and advertise commit times that were never written.
+    //
+    // Two distinct cases, because writers routinely omit meta-field settings 
entirely:
+    //
+    //  - Widening is always rejected. Enabling a column now would leave 
earlier commits without it,
+    //    and readers cannot tell the two apart.
+    //  - Any disagreement is rejected when the writer *explicitly* sets 
hoodie.meta.fields.mode.
+    //    That covers narrowing too, e.g. an explicit NONE against a 
COMMIT_TIME_ONLY table, which
+    //    would write null commit times while the table still advertises 
COMMIT_TIME_ONLY and make
+    //    incremental queries silently miss those rows.
+    //
+    // A writer that never mentions the mode is left alone: resolving to NONE 
against an ALL table
+    // is long-standing behavior for callers that build a write config without 
restating the table's
+    // settings, and writing fewer meta columns cannot make a reader believe 
in absent data.
+    MetaFieldsMode tableMetaFieldsMode = tableConfig.getMetaFieldsMode();
+    MetaFieldsMode writeMetaFieldsMode = writeConfig.getMetaFieldsMode();
+    boolean writerStatedMode = 
writeConfig.contains(HoodieTableConfig.META_FIELDS_MODE)
+        && 
!StringUtils.isNullOrEmpty(writeConfig.getString(HoodieTableConfig.META_FIELDS_MODE));

Review Comment:
   You were right on both counts — it was not fixed, and `c78dc962` fixed 
resolution rather than this gate. Fixed now in two commits, because the 
diagnosis pointed at a better primary fix than tightening the guard alone.
   
   
**[`4a7fa0c`](https://github.com/apache/hudi/pull/19205/commits/4a7fa0c4dd76) — 
StreamSync inherits the mode.** Your walk-through showed the danger is 
*omission*, not contradiction, which is also why 
`HoodieWriterUtils.validateTableConfig` cannot catch it: that loop only visits 
keys the writer stated, so `hoodie.meta.fields.mode` is never examined and the 
legacy boolean that *is* present matches on-disk. Meta-field population is a 
property of the table, not of the run, so when a run does not state a mode it 
now adopts the table's — exactly what 
`HoodieSparkSqlWriter#mergeParamsAndGetHoodieConfig` already does at 
`:1101-1107` ("over-ride only if not explicitly set by the user"). That also 
removes the accident you noted: the datasource path was safe only because it 
happens to copy table props.
   
   This makes the common restart *succeed correctly* rather than start failing, 
which matters — requiring users to restate the mode in their streamer props on 
every restart would be a regression for anyone who set it once at creation.
   
   
**[`08b12f7`](https://github.com/apache/hudi/pull/19205/commits/08b12f7bed23) — 
your guard fix, as suggested.** Applied verbatim:
   
   ```java
   } else if (writeMetaFieldsMode != tableMetaFieldsMode
              && (writerStatedMode || tableMetaFieldsMode.isSelective())) {
   ```
   
   Inheritance handles the common case; this catches what remains, including 
anything that builds a write config outside StreamSync. The narrowing carve-out 
is now scoped to `ALL`/`NONE` tables, which is the `d5026e9a2485` behavior you 
identified. The error message also distinguishes an explicitly set mode from 
one defaulted off the legacy boolean, since it can no longer assume the former.
   
   Tests: the case you asked for (table `COMMIT_TIME_ONLY`, unstated writer → 
must throw) plus `COMMIT_TIME_AND_FILE_NAME`, and the sibling pair from your 
`:183` comment — `COMMIT_TIME_ONLY` vs `FILE_NAME_ONLY` rejected both ways, 
since neither is a narrowing of the other. Confirmed non-vacuous: reverting the 
guard fails exactly the two unstated-narrowing tests and leaves the `ALL`-table 
carve-out green.
   
   One thing I could not verify locally: `hudi-utilities` does not compile in 
my environment — `KafkaAvroSchemaDeserializer` fails on a Confluent version 
mismatch, and I confirmed it fails identically on the base commit with my 
changes stashed. So the StreamSync change is type-checked (compiled the module 
minus that file, zero errors in the changed lines, both API signatures 
confirmed with `javap`) but not test-run. Relying on CI for that one.



##########
hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/BaseHoodieWriteClient.java:
##########
@@ -1548,9 +1549,40 @@ public void 
validateAgainstTableProperties(HoodieTableConfig tableConfig, Hoodie
     // mismatch of table versions.
     CommonClientUtils.validateTableVersion(tableConfig, writeConfig);
 
-    // Once meta fields are disabled, it cant be re-enabled for a given table.
-    if (!tableConfig.populateMetaFields() && writeConfig.populateMetaFields()) 
{
-      throw new HoodieException(HoodieTableConfig.POPULATE_META_FIELDS.key() + 
" already disabled for the table. Can't be re-enabled back");
+    // Meta-field population is physical, so a writer must not claim columns 
the table does not
+    // have. Compare the full enum rather than the legacy booleans: those 
collapse every selective
+    // mode to false, so a writer claiming COMMIT_TIME_ONLY against a NONE 
table would slip through
+    // and advertise commit times that were never written.
+    //
+    // Two distinct cases, because writers routinely omit meta-field settings 
entirely:
+    //
+    //  - Widening is always rejected. Enabling a column now would leave 
earlier commits without it,
+    //    and readers cannot tell the two apart.
+    //  - Any disagreement is rejected when the writer *explicitly* sets 
hoodie.meta.fields.mode.
+    //    That covers narrowing too, e.g. an explicit NONE against a 
COMMIT_TIME_ONLY table, which
+    //    would write null commit times while the table still advertises 
COMMIT_TIME_ONLY and make
+    //    incremental queries silently miss those rows.
+    //
+    // A writer that never mentions the mode is left alone: resolving to NONE 
against an ALL table
+    // is long-standing behavior for callers that build a write config without 
restating the table's
+    // settings, and writing fewer meta columns cannot make a reader believe 
in absent data.
+    MetaFieldsMode tableMetaFieldsMode = tableConfig.getMetaFieldsMode();
+    MetaFieldsMode writeMetaFieldsMode = writeConfig.getMetaFieldsMode();
+    boolean writerStatedMode = 
writeConfig.contains(HoodieTableConfig.META_FIELDS_MODE)
+        && 
!StringUtils.isNullOrEmpty(writeConfig.getString(HoodieTableConfig.META_FIELDS_MODE));
+    if (writeMetaFieldsMode.isWiderThan(tableMetaFieldsMode)) {
+      throw new HoodieException(String.format(
+          "%s cannot be widened for an existing table: table is %s but the 
writer requests %s. Meta "
+              + "columns are physical, so enabling one now would leave earlier 
commits without it. "
+              + "Set %s=%s on the writer, or recreate the table to change it.",
+          HoodieTableConfig.META_FIELDS_MODE.key(), tableMetaFieldsMode, 
writeMetaFieldsMode,
+          HoodieTableConfig.META_FIELDS_MODE.key(), tableMetaFieldsMode));
+    } else if (writerStatedMode && writeMetaFieldsMode != tableMetaFieldsMode) 
{

Review Comment:
   Resolved by 
[`08b12f7`](https://github.com/apache/hudi/pull/19205/commits/08b12f7bed23), 
which takes @voonhous's read: `writerStatedMode` is load-bearing but was 
mis-scoped.
   
   It stays (the unstated `ALL`-table narrowing path genuinely needs it, per 
`d5026e9a2485` / HUDI-2161), but it is no longer the sole gate — the branch now 
also fires when the *table* is selective:
   
   ```java
   } else if (writeMetaFieldsMode != tableMetaFieldsMode
              && (writerStatedMode || tableMetaFieldsMode.isSelective())) {
   ```
   
   So the narrowing carve-out is limited to `ALL`/`NONE` tables, where nothing 
keys off a partially populated state. A selective table advertises exactly what 
the read path trusts, so a disagreement there has to be rejected whether or not 
the writer named a mode — that was the silent data-loss path.
   
   @danny0405 on eliminating the flag: it cannot go entirely, since 
`validateAgainstTablePropertiesAllowsUnstatedWriterToNarrow` (table `ALL`, 
writer sets only `withPopulateMetaFields(false)`) must keep passing. The full 
matrix @voonhous laid out at `:1573` is now pinned by tests, including the 
`COMMIT_TIME_ONLY` / `FILE_NAME_ONLY` pair that is rejected in both directions.



##########
hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/BaseHoodieWriteClient.java:
##########
@@ -1548,9 +1549,40 @@ public void 
validateAgainstTableProperties(HoodieTableConfig tableConfig, Hoodie
     // mismatch of table versions.
     CommonClientUtils.validateTableVersion(tableConfig, writeConfig);
 
-    // Once meta fields are disabled, it cant be re-enabled for a given table.
-    if (!tableConfig.populateMetaFields() && writeConfig.populateMetaFields()) 
{
-      throw new HoodieException(HoodieTableConfig.POPULATE_META_FIELDS.key() + 
" already disabled for the table. Can't be re-enabled back");
+    // Meta-field population is physical, so a writer must not claim columns 
the table does not
+    // have. Compare the full enum rather than the legacy booleans: those 
collapse every selective
+    // mode to false, so a writer claiming COMMIT_TIME_ONLY against a NONE 
table would slip through
+    // and advertise commit times that were never written.
+    //
+    // Two distinct cases, because writers routinely omit meta-field settings 
entirely:
+    //
+    //  - Widening is always rejected. Enabling a column now would leave 
earlier commits without it,
+    //    and readers cannot tell the two apart.
+    //  - Any disagreement is rejected when the writer *explicitly* sets 
hoodie.meta.fields.mode.
+    //    That covers narrowing too, e.g. an explicit NONE against a 
COMMIT_TIME_ONLY table, which
+    //    would write null commit times while the table still advertises 
COMMIT_TIME_ONLY and make
+    //    incremental queries silently miss those rows.
+    //
+    // A writer that never mentions the mode is left alone: resolving to NONE 
against an ALL table
+    // is long-standing behavior for callers that build a write config without 
restating the table's
+    // settings, and writing fewer meta columns cannot make a reader believe 
in absent data.
+    MetaFieldsMode tableMetaFieldsMode = tableConfig.getMetaFieldsMode();
+    MetaFieldsMode writeMetaFieldsMode = writeConfig.getMetaFieldsMode();
+    boolean writerStatedMode = 
writeConfig.contains(HoodieTableConfig.META_FIELDS_MODE)
+        && 
!StringUtils.isNullOrEmpty(writeConfig.getString(HoodieTableConfig.META_FIELDS_MODE));
+    if (writeMetaFieldsMode.isWiderThan(tableMetaFieldsMode)) {

Review Comment:
   Resolved by 
[`08b12f7`](https://github.com/apache/hudi/pull/19205/commits/08b12f7bed23), 
which takes @voonhous's read: `writerStatedMode` is load-bearing but was 
mis-scoped.
   
   It stays (the unstated `ALL`-table narrowing path genuinely needs it, per 
`d5026e9a2485` / HUDI-2161), but it is no longer the sole gate — the branch now 
also fires when the *table* is selective:
   
   ```java
   } else if (writeMetaFieldsMode != tableMetaFieldsMode
              && (writerStatedMode || tableMetaFieldsMode.isSelective())) {
   ```
   
   So the narrowing carve-out is limited to `ALL`/`NONE` tables, where nothing 
keys off a partially populated state. A selective table advertises exactly what 
the read path trusts, so a disagreement there has to be rejected whether or not 
the writer named a mode — that was the silent data-loss path.
   
   @danny0405 on eliminating the flag: it cannot go entirely, since 
`validateAgainstTablePropertiesAllowsUnstatedWriterToNarrow` (table `ALL`, 
writer sets only `withPopulateMetaFields(false)`) must keep passing. The full 
matrix @voonhous laid out at `:1573` is now pinned by tests, including the 
`COMMIT_TIME_ONLY` / `FILE_NAME_ONLY` pair that is rejected in both directions.



##########
hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/HoodieWriterUtils.scala:
##########
@@ -352,6 +352,23 @@ object HoodieWriterUtils {
             
diffConfigs.append(s"${HoodieTableConfig.RECORD_MERGE_STRATEGY_ID}:\t$mergeStrategyId\tnull\n")
           }
         }
+
+        // hoodie.meta.fields.mode is a physical-storage decision baked into 
files at write time.
+        // Changing it at runtime would silently produce mixed-mode files 
whose incremental / file
+        // pruning behavior differs between old and new commits. The default 
loop above only flags
+        // the mismatch when the on-disk value is non-null, so an older table 
with the property
+        // absent from hoodie.properties would let a null → non-empty 
transition slip through
+        // (silent-drop risk on pre-enablement commits). Guard the null → 
selective-mode case
+        // explicitly here. Set it only at table creation, via the hudi-cli, 
or during table upgrade;
+        // otherwise the only way to change it is to recreate the table.
+        val paramsMetaFieldsMode = 
params.getOrElse(HoodieTableConfig.META_FIELDS_MODE.key(), "")
+        val onDiskMetaFieldsMode = 
tableConfig.getString(HoodieTableConfig.META_FIELDS_MODE)
+        if (paramsMetaFieldsMode.nonEmpty && (onDiskMetaFieldsMode == null || 
onDiskMetaFieldsMode.isEmpty)) {

Review Comment:
   Fixed in 
[`8db4d71`](https://github.com/apache/hudi/pull/19205/commits/8db4d71c37fb) — 
took your suggestion, comparing resolved modes.
   
   The second-order problem you raised is the one that convinced me: since the 
property is backfilled only by `NineToTenUpgradeHandler` and `current()` is 
already `TEN`, a v10 table without it will never be upgraded again, so 
rejecting an explicit restatement left it no way to adopt the property at all.
   
   One deviation from the snippet: it uses 
`MetaFieldsMode.resolve(tableConfig)` rather than 
`tableConfig.getMetaFieldsMode`. The parameter is typed `HoodieConfig`, and 
`HoodieCatalogTable:262` passes a plain one built from a map via 
`convertMapToHoodieConfig` — so a cast to `HoodieTableConfig` would throw 
`ClassCastException` on the Spark SQL path. The `resolve(HoodieConfig)` 
overload (added for @danny0405's comment on `MetaFieldsMode:139`) reads the 
same two properties and works for both caller shapes.
   
   Tests: the acceptance case you asked for — `mode=ALL` on a table carrying 
only `populate.meta.fields=true` — plus the `NONE` mirror, and two rejections 
(`COMMIT_TIME_ONLY` on a `NONE` table, `NONE` on an `ALL` table) so the real 
guard is pinned too. Each builds a table with *only* the legacy boolean on 
disk, so it resolves through the fallback path. Verified non-vacuous: restoring 
the presence-based check fails exactly the two acceptance tests.



##########
hudi-client/hudi-client-common/src/main/java/org/apache/hudi/config/HoodieWriteConfig.java:
##########
@@ -3586,11 +3618,31 @@ public Builder withCanIgnorePostCommitFailures(boolean 
canIgnorePostCommitFailur
       return this;
     }
 
+    /**
+     * @deprecated since 1.3.0, use {@link 
#withMetaFieldsMode(MetaFieldsMode)} instead
+     * ({@code true} maps to {@link MetaFieldsMode#ALL}, {@code false} to 
{@link MetaFieldsMode#NONE}).
+     */
+    @Deprecated
     public Builder withPopulateMetaFields(boolean populateMetaFields) {
       writeConfig.setValue(HoodieTableConfig.POPULATE_META_FIELDS, 
Boolean.toString(populateMetaFields));
       return this;
     }
 
+    public Builder withMetaFieldsMode(MetaFieldsMode metaFieldsMode) {
+      // Leaving the mode unset defers to the deprecated populate.meta.fields 
boolean. Setting it
+      // also rewrites that boolean from the mode, so the two can never 
disagree — a config carrying
+      // a selective mode alongside populate.meta.fields=true would otherwise 
create a table whose
+      // hoodie.properties misleads pre-1.3.0 readers into treating it as ALL.
+      if (metaFieldsMode == null) {
+        writeConfig.setValue(HoodieTableConfig.META_FIELDS_MODE, "");
+      } else {
+        writeConfig.setValue(HoodieTableConfig.META_FIELDS_MODE, 
metaFieldsMode.name());
+        writeConfig.setValue(HoodieTableConfig.POPULATE_META_FIELDS,
+            Boolean.toString(metaFieldsMode.toLegacyPopulateMetaFields()));

Review Comment:
   Fixed in 
[`9193e874`](https://github.com/apache/hudi/pull/19205/commits/9193e8741178) — 
took the cheapest fix you suggested and re-derive in `build()`, so order stops 
mattering.
   
   It runs before `validate()` so the MoR / engine-type checks see the same 
mode the built config will, and only fires when a mode is actually set — 
otherwise it would clobber the legacy boolean for the many callers that never 
mention the mode.
   
   And you were right that the test skipped the order that did not hold: 
`legacyBooleanSetAfterModeStillResolvesFromMode` asserted the resolved mode but 
not the raw property, which is precisely where the contradiction lived. It now 
asserts the raw property too, plus a loop over all five modes in both call 
orders and a case pinning that an unset mode leaves the boolean untouched. 
Verified non-vacuous: moving the derivation back into the setter fails exactly 
the two order tests.



-- 
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]

Reply via email to