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


##########
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:
   This guard tests whether the property is *present* on disk, not whether it 
*disagrees*. So a write that passes `hoodie.meta.fields.mode=ALL` to an 
ordinary default table throws, even though it is asking for exactly what the 
table already is. Same for `NONE` against a `populate.meta.fields=false` table.
   
   There is a second-order problem: the property is only ever backfilled by 
`NineToTenUpgradeHandler`, and `HoodieTableVersion.current()` is already `TEN`. 
A table already at v10 without the property will never be upgraded again, so it 
can never adopt the explicit property at all.
   
   Compare resolved modes instead:
   ```scala
   if (paramsMetaFieldsMode.nonEmpty
       && MetaFieldsMode.parse(paramsMetaFieldsMode) != 
tableConfig.getMetaFieldsMode) {
   ```
   That keeps the intended guard -- a legacy table resolves to `ALL`/`NONE`, so 
`null -> COMMIT_TIME_ONLY` still throws -- and drops the false positive. Worth 
a test that `mode=ALL` on a table with only `populate.meta.fields=true` 
succeeds.



##########
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:
   `withMetaFieldsMode` re-derives the boolean, but `withPopulateMetaFields` 
does not re-derive the mode -- so the invariant this comment claims holds in 
only one call order:
   
   ```
   .withPopulateMetaFields(true).withMetaFieldsMode(COMMIT_TIME_ONLY)
       -> populate.meta.fields = false   (consistent)
   .withMetaFieldsMode(COMMIT_TIME_ONLY).withPopulateMetaFields(true)
       -> populate.meta.fields = true    (contradictory)
   ```
   
   The second order produces exactly the config `664ff2ec` was meant to make 
impossible: a selective mode sitting next to `populate.meta.fields=true`. 
`getMetaFieldsMode()` still resolves correctly because the mode wins, but any 
path that copies raw write-config props into `hoodie.properties` carries the 
contradiction to disk.
   
   Cheapest fix is to re-derive in `build()` rather than in the setter, so 
order stops mattering. Then `TestHoodieWriteConfigMetaFieldsMode:122` can 
assert the raw property in both orders, which it currently skips for the order 
that does not hold.



##########
hudi-common/src/main/java/org/apache/hudi/common/model/MetaFieldsMode.java:
##########
@@ -0,0 +1,191 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hudi.common.model;
+
+import org.apache.hudi.common.config.HoodieConfig;
+import org.apache.hudi.common.table.HoodieTableConfig;
+import org.apache.hudi.common.util.StringUtils;
+
+import java.util.Locale;
+
+/**
+ * Which of Hudi's meta columns are physically populated on disk.
+ *
+ * <p>Selective modes exist so that tables that opt out of the default {@code 
populate.meta.fields=true}
+ * can still keep the two columns that matter for downstream operations 
without paying for the other
+ * three:
+ *
+ * <ul>
+ *   <li>{@code _hoodie_commit_time} — required for incremental queries.</li>
+ *   <li>{@code _hoodie_file_name} — useful for file-level pruning / 
investigation lookups.</li>
+ * </ul>
+ *
+ * <p>The remaining three meta columns ({@code _hoodie_commit_seqno}, {@code 
_hoodie_record_key},
+ * {@code _hoodie_partition_path}) are all-or-nothing — either populate every 
meta column ({@link #ALL})
+ * or none of them beyond the two selectable ones. If you need any of the 
remaining columns, set
+ * {@code hoodie.populate.meta.fields=true}.
+ *
+ * <p>This enum is the single source of truth for meta-column population. The 
legacy boolean
+ * {@code hoodie.populate.meta.fields} is deprecated and consulted only when
+ * {@code hoodie.meta.fields.mode} is absent, so that tables written before 
the mode property
+ * existed keep their behavior:
+ *
+ * <ul>
+ *   <li>{@code populate.meta.fields=true} (or absent) → {@link #ALL} — 
today's default.</li>
+ *   <li>{@code populate.meta.fields=false} → {@link #NONE}.</li>
+ * </ul>
+ *
+ * <p>On-disk representation: the enum {@link #name()} is persisted in {@code 
hoodie.properties}
+ * under the property {@code hoodie.meta.fields.mode}.
+ */
+public enum MetaFieldsMode {
+  /**
+   * All five Hudi meta columns are populated — today's default.
+   */
+  ALL(true, true),
+
+  /**
+   * No Hudi meta columns are populated. Incremental queries are unsupported. 
File-level pruning
+   * that depends on {@code _hoodie_file_name} is unsupported.
+   */
+  NONE(false, false),
+
+  /**
+   * Only {@code _hoodie_commit_time} is populated. Incremental queries remain 
functional; other
+   * meta columns stay null on disk.
+   */
+  COMMIT_TIME_ONLY(true, false),
+
+  /**
+   * Only {@code _hoodie_file_name} is populated. Useful for file-level 
lookups and debugging;
+   * incremental queries are unsupported.
+   */
+  FILE_NAME_ONLY(false, true),
+
+  /**
+   * Both {@code _hoodie_commit_time} and {@code _hoodie_file_name} are 
populated.
+   */
+  COMMIT_TIME_AND_FILE_NAME(true, true);
+
+  private final boolean commitTimePopulated;
+  private final boolean fileNamePopulated;
+
+  MetaFieldsMode(boolean commitTimePopulated, boolean fileNamePopulated) {
+    this.commitTimePopulated = commitTimePopulated;
+    this.fileNamePopulated = fileNamePopulated;
+  }
+
+  public boolean isCommitTimePopulated() {
+    return commitTimePopulated;
+  }
+
+  public boolean isFileNamePopulated() {
+    return fileNamePopulated;
+  }
+
+  /**
+   * @return true when all five meta columns are populated (i.e. this is 
{@link #ALL}). Selective
+   * modes never populate {@code _hoodie_record_key}, {@code 
_hoodie_partition_path}, or
+   * {@code _hoodie_commit_seqno}.
+   */
+  public boolean isRecordKeyPopulated() {
+    return this == ALL;
+  }
+
+  /**
+   * Resolve the effective mode. {@code hoodie.meta.fields.mode} is the source 
of truth; the
+   * deprecated {@code hoodie.populate.meta.fields} boolean is a fallback for 
tables written before
+   * the mode property existed. Precedence:
+   *
+   * <ul>
+   *   <li>non-empty mode → the parsed enum value (the legacy boolean is not 
consulted).</li>
+   *   <li>null/empty mode + {@code populateMetaFields=false} → {@link 
#NONE}.</li>
+   *   <li>null/empty mode + {@code populateMetaFields=true} → {@link 
#ALL}.</li>
+   * </ul>
+   *
+   * @param rawMode             raw {@code hoodie.meta.fields.mode} value; may 
be null or empty.
+   * @param legacyPopulateMetaFields value of the deprecated {@code 
hoodie.populate.meta.fields}.
+   * @throws IllegalArgumentException when the raw mode value does not match 
any enum value. This
+   *         includes the pre-enum comma-separated format — callers that 
upgrade an old table must
+   *         migrate the value through the hudi-cli.
+   */
+  /**
+   * Resolve the effective mode from any {@link HoodieConfig} that may carry 
the two properties —
+   * a table config, a write config, or a bare config built from write 
options. Preferred over the
+   * two-argument overload: it keeps the property keys and the precedence rule 
in one place instead
+   * of repeating them at every call site.
+   */
+  public static MetaFieldsMode resolve(HoodieConfig config) {
+    return 
resolve(config.getStringOrDefault(HoodieTableConfig.META_FIELDS_MODE),
+        config.getBooleanOrDefault(HoodieTableConfig.POPULATE_META_FIELDS));
+  }
+
+  public static MetaFieldsMode resolve(String rawMode, boolean 
legacyPopulateMetaFields) {
+    if (StringUtils.isNullOrEmpty(rawMode)) {
+      return legacyPopulateMetaFields ? ALL : NONE;
+    }
+    return parse(rawMode);
+  }
+
+  /**
+   * Parse a raw {@code hoodie.meta.fields.mode} value into an enum constant, 
with a message that
+   * lists the allowed values. Prefer this over {@link #valueOf(String)} for 
user-supplied input.
+   */
+  public static MetaFieldsMode parse(String rawMode) {
+    try {
+      // Case-insensitive: users hand-editing hoodie.properties or passing 
write options should not
+      // have to match the enum's casing exactly.
+      return MetaFieldsMode.valueOf(rawMode.trim().toUpperCase(Locale.ROOT));
+    } catch (IllegalArgumentException e) {
+      throw new IllegalArgumentException(String.format(
+          "Unsupported value '%s' for hoodie.meta.fields.mode. Allowed values: 
%s, %s, %s, %s, %s.",
+          rawMode, ALL, NONE, COMMIT_TIME_ONLY, FILE_NAME_ONLY, 
COMMIT_TIME_AND_FILE_NAME), e);
+    }
+  }
+
+  /**
+   * @return the equivalent value of the deprecated {@code 
hoodie.populate.meta.fields} boolean, so
+   * that call sites not yet migrated to this enum keep observing consistent 
behavior.
+   */
+  public boolean toLegacyPopulateMetaFields() {
+    return this == ALL;
+  }
+
+  /**
+   * @return true when this mode populates at least one meta column that 
{@code other} does not.
+   *
+   * <p>Meta-field population is a physical-storage decision baked into files 
at write time, so it
+   * can never be widened for an existing table: earlier commits would be 
missing columns that later
+   * commits have, and readers cannot tell the two apart. Every transition 
that adds a column is
+   * therefore rejected — {@code NONE -> COMMIT_TIME_ONLY} and
+   * {@code FILE_NAME_ONLY -> COMMIT_TIME_AND_FILE_NAME} just as much as 
{@code NONE -> ALL}.
+   *
+   * <p>Narrowing is not flagged here: writing fewer meta columns than the 
table advertises cannot
+   * make a reader believe in data that is absent, and it is long-standing 
behavior for a writer to
+   * resolve to {@link #NONE} against an {@link #ALL} table without restating 
its settings.
+   */
+  public boolean isWiderThan(MetaFieldsMode other) {

Review Comment:
   `isWiderThan` has exactly one caller (`BaseHoodieWriteClient.java:1573`) and 
no direct unit test, and `TestBaseHoodieWriteClient` never mentions 
`FILE_NAME_ONLY` or `COMMIT_TIME_AND_FILE_NAME` -- so two of the three clauses 
are mutation-survivable. Deleting the `isRecordKeyPopulated()` clause changes 
exactly one pair (`ALL` vs `COMMIT_TIME_AND_FILE_NAME`) that nothing tests.
   
   The interesting case is that the relation is not a total order: 
`COMMIT_TIME_ONLY` and `FILE_NAME_ONLY` are each wider than the other, so a 
transition between them is rejected in **both** directions. That is correct, 
and it is exactly what makes the narrowing question subtle -- worth pinning.
   
   Suggest two cases: `COMMIT_TIME_AND_FILE_NAME` writer vs `ALL` table 
(allowed) and the reverse (rejected), plus `COMMIT_TIME_ONLY` vs 
`FILE_NAME_ONLY` rejected both ways.



##########
hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/io/storage/HoodieSparkFileWriterFactory.java:
##########
@@ -56,7 +56,9 @@ public HoodieSparkFileWriterFactory(HoodieStorage storage) {
   protected HoodieFileWriter newParquetFileWriter(
       String instantTime, StoragePath path, HoodieConfig config, HoodieSchema 
schema,
       TaskContextSupplier taskContextSupplier) throws IOException {
-    boolean populateMetaFields = 
config.getBooleanOrDefault(HoodieTableConfig.POPULATE_META_FIELDS);
+    org.apache.hudi.common.model.MetaFieldsMode metaFieldsMode =

Review Comment:
   nit: `MetaFieldsMode` is referenced fully qualified here (and in 
`HoodieAvroFileWriterFactory.java:72-73`) while every other file this PR 
touches imports it. Add the import for consistency.



##########
hudi-utilities/src/test/java/org/apache/hudi/utilities/deltastreamer/TestHoodieStreamerMetaFieldsMode.java:
##########
@@ -0,0 +1,139 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.hudi.utilities.deltastreamer;
+
+import org.apache.hudi.common.model.HoodieRecord;
+import org.apache.hudi.common.model.MetaFieldsMode;
+import org.apache.hudi.common.model.WriteOperationType;
+import org.apache.hudi.common.table.HoodieTableConfig;
+import org.apache.hudi.common.table.HoodieTableMetaClient;
+import org.apache.hudi.common.testutils.HoodieTestUtils;
+
+import org.apache.spark.sql.Dataset;
+import org.apache.spark.sql.Row;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.EnumSource;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * End-to-end coverage for {@code hoodie.meta.fields.mode} through the 
HoodieStreamer entrypoint.
+ * Each parameterized invocation runs a single ingest cycle in the given 
{@link MetaFieldsMode} and
+ * verifies both the persisted table property and the actual on-disk parquet 
column population.
+ *
+ * <p>Rejection paths (unknown token, populate=true+mode, MoR+mode) are 
exercised in the datasource
+ * test {@code TestMetaFieldsMode}; this fixture focuses on the streamer 
control-flow.
+ */
+public class TestHoodieStreamerMetaFieldsMode extends 
HoodieDeltaStreamerTestBase {

Review Comment:
   This class costs a full streamer fixture but does not test the 
streamer-specific thing.
   
   - The only new streamer production code is `StreamSync.java:481-482`. For 
`ALL` and `NONE` the mode key is absent from `cfg.configs`, so 
`setMetaFieldsModeFromString("")` is a no-op -- 2 of the 5 parameterized runs 
exercise zero new code.
   - `testStreamerRejectsMorWithSelectiveMode` is the third copy of one 
`checkArgument` (also in `TestMetaFieldsMode` and 
`TestHoodieWriteConfigMetaFieldsMode`), and this class's own javadoc at 
`:46-47` says rejection paths are covered elsewhere.
   - The regression this change exists for -- cshuo's "restart with only 
`populate=false` downgrades `COMMIT_TIME_ONLY` to `NONE`" -- is **not** tested. 
There is only one `ingestOnce()` in the file, so no restart ever happens.
   
   Suggest deleting this class and adding one method to 
`TestHoodieDeltaStreamer`: create with `COMMIT_TIME_ONLY`, `ingestOnce()`, then 
build a second streamer with only `POPULATE_META_FIELDS=false` and 
`ingestOnce()` again, asserting the mode is still `COMMIT_TIME_ONLY`. Given the 
`BaseHoodieWriteClient` finding, I expect that test to fail today -- which is 
the point.



##########
hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/TestHoodieSparkSqlWriterWithTestFormat.scala:
##########
@@ -106,7 +106,7 @@ class TestHoodieSparkSqlWriterWithTestFormat extends 
HoodieSparkWriterTestBase {
     // fetch all records from parquet files generated from write to hudi
     val actualDf = sqlContext.read.parquet(fullPartitionPaths(0), 
fullPartitionPaths(1), fullPartitionPaths(2))
     if (!populateMetaFields) {
-      List(0, 1, 2, 3, 4).foreach(i => assertEquals(0, 
actualDf.select(HoodieRecord.HOODIE_META_COLUMNS.get(i)).filter(entry => 
!(entry.mkString(",").equals(""))).count()))
+      List(0, 1, 2, 3, 4).foreach(i => assertEquals(0, 
actualDf.select(HoodieRecord.HOODIE_META_COLUMNS.get(i)).filter(entry => 
!entry.isNullAt(0) && entry.getString(0).nonEmpty).count()))

Review Comment:
   This update is correct -- the old form would fail on NULL, since 
`Row.mkString` renders null as the string "null" -- but it relaxed further than 
the behaviour change required. `!entry.isNullAt(0) && 
entry.getString(0).nonEmpty` accepts **both** NULL and `""`, so it no longer 
pins which representation is written.
   
   That is worth keeping strict, because the whole point of `77f4e9148c4a` is 
that these are now NULL, and `TestMetaFieldsMode.java:149` asserts exactly 
`assertNull` for the same scenario in this same PR. Right now the two tests 
disagree on strictness.
   
   ```suggestion
         List(0, 1, 2, 3, 4).foreach(i => assertEquals(0, 
actualDf.select(HoodieRecord.HOODIE_META_COLUMNS.get(i)).filter(entry => 
!entry.isNullAt(0)).count()))
   ```
   
   Since this exact edit is being made in two near-identical files, a shared 
`assertNoMetaFieldsPopulated(df)` in `HoodieSparkWriterTestBase` (next to 
`dropMetaFields`) would be better than duplicating it.



##########
hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/upgrade/TenToNineDowngradeHandler.java:
##########
@@ -18,25 +18,61 @@
 
 package org.apache.hudi.table.upgrade;
 
+import org.apache.hudi.common.config.ConfigProperty;
 import org.apache.hudi.common.engine.HoodieEngineContext;
+import org.apache.hudi.common.model.MetaFieldsMode;
 import org.apache.hudi.common.table.HoodieTableConfig;
 import org.apache.hudi.config.HoodieWriteConfig;
 
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
 import java.util.Collections;
+import java.util.HashSet;
+import java.util.Set;
 
 /**
  * Version 10 writes native log files by default. Downgrading to version 9 
requires
  * full compaction of native data/delete logs before the downgrade completes.
+ *
+ * <p>Version 10 also introduced {@code hoodie.meta.fields.mode}. Version 9 
does not understand it,
+ * so the property is dropped here while {@code hoodie.populate.meta.fields} 
is left exactly as it
+ * stands — {@code ALL} and {@code NONE} tables round-trip unchanged because 
those are precisely the
+ * two states the legacy boolean can express. Selective modes cannot be 
expressed in version 9, so
+ * the table degrades to what its legacy boolean says (which is {@code false}, 
i.e. NONE) and we warn.
  */
 public class TenToNineDowngradeHandler implements DowngradeHandler {
+
+  private static final Logger LOG = 
LoggerFactory.getLogger(TenToNineDowngradeHandler.class);
+
   @Override
   public UpgradeDowngrade.TableConfigChangeSet downgrade(
       HoodieWriteConfig config,
       HoodieEngineContext context,
       String instantTime,
       SupportsUpgradeDowngrade upgradeDowngradeHelper) {
+    Set<ConfigProperty> propertiesToDelete = new HashSet<>();
+    propertiesToDelete.add(HoodieTableConfig.TABLE_STORAGE_LAYOUT);
+
+    // The warning is best-effort: dropping the property is what matters, and 
the helper is not
+    // always available (some callers drive the change set directly).
+    MetaFieldsMode metaFieldsMode = upgradeDowngradeHelper == null
+        ? MetaFieldsMode.ALL
+        : upgradeDowngradeHelper.getTable(config, 
context).getMetaClient().getTableConfig().getMetaFieldsMode();
+    if (metaFieldsMode != MetaFieldsMode.ALL && metaFieldsMode != 
MetaFieldsMode.NONE) {
+      LOG.warn("Table is using {}={}, which table version 9 cannot express. 
The property is being "
+              + "removed and the table will behave as {}=false (no meta 
columns) to version 9 readers. "
+              + "Already-written files keep their populated meta columns, but 
incremental queries that "
+              + "relied on {} will stop returning rows. Recreate the table if 
you need that behavior back.",
+          HoodieTableConfig.META_FIELDS_MODE.key(), metaFieldsMode,
+          HoodieTableConfig.POPULATE_META_FIELDS.key(), metaFieldsMode);
+    }
+    // hoodie.populate.meta.fields is deliberately left untouched: whatever 
the table recorded before
+    // the downgrade stays, so ALL and NONE tables are bit-identical 
afterwards.
+    propertiesToDelete.add(HoodieTableConfig.META_FIELDS_MODE);

Review Comment:
   This breaks the symmetry convention this package already established, and 
the result is lossy.
   
   The precedent, from `b60d38c40fa1` [HUDI-8401]:
   - upgrade adds the new property and **removes** the legacy one -- 
`EightToNineUpgradeHandler.java:171-172` and `:240-241`
   - downgrade **restores** the legacy one from the new one and removes the new 
one -- `NineToEightDowngradeHandler.java:116-117` and `:152-153`
   
   Here the downgrade deletes `hoodie.meta.fields.mode` and deliberately writes 
nothing back. Two consequences:
   
   1. If a table ever carries the mode without the boolean, 
`POPULATE_META_FIELDS` defaults to `true`, so the table downgrades to `ALL` -- 
Hudi then believes `_hoodie_record_key` is populated on files where it is 
physically null.
   2. A selective mode is unrecoverable. Downgrade drops it, re-upgrade derives 
`NONE` from the boolean, and `isWiderThan` now rejects any writer trying to 
restore `COMMIT_TIME_ONLY` as a widening. No other handler pair in this package 
is one-way like that.
   
   Suggest adding the derived boolean here, mirroring 
`NineToEightDowngradeHandler.java:117`:
   ```java
   propertiesToUpdate.put(HoodieTableConfig.POPULATE_META_FIELDS.key(),
       String.valueOf(metaFieldsMode.toLegacyPopulateMetaFields()));
   ```
   For `ALL`/`NONE` that is a no-op, so nothing regresses; it just closes the 
hole. (This also answers your open question at `:76` -- yes, infer and add it.)
   
   Separately: a selective mode is silently destroyed here with only a 
`LOG.warn`. Consider throwing unless the caller opts in, since the operator 
gets no acknowledgement today.



##########
hudi-spark-datasource/hudi-spark/src/test/java/org/apache/hudi/functional/TestMetaFieldsMode.java:
##########
@@ -0,0 +1,469 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.hudi.functional;
+
+import org.apache.hudi.DataSourceWriteOptions;
+import org.apache.hudi.SparkAdapterSupport$;
+import org.apache.hudi.common.config.HoodieMetadataConfig;
+import org.apache.hudi.common.model.HoodieRecord;
+import org.apache.hudi.common.model.MetaFieldsMode;
+import org.apache.hudi.common.table.HoodieTableConfig;
+import org.apache.hudi.common.table.HoodieTableMetaClient;
+import org.apache.hudi.testutils.SparkClientFunctionalTestHarness;
+
+import org.apache.spark.sql.Dataset;
+import org.apache.spark.sql.Row;
+import org.apache.spark.sql.RowFactory;
+import org.apache.spark.sql.SaveMode;
+import org.apache.spark.sql.types.DataTypes;
+import org.apache.spark.sql.types.StructField;
+import org.apache.spark.sql.types.StructType;
+import org.junit.jupiter.api.Test;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Spark-datasource end-to-end tests for the {@code hoodie.meta.fields.mode} 
property on CoW tables.
+ * Every {@link MetaFieldsMode} value is exercised via a write / re-read round 
trip; on-disk column
+ * population is verified by reading the parquet files back and inspecting the 
meta-column values.
+ */
+class TestMetaFieldsMode extends SparkClientFunctionalTestHarness {
+
+  private static StructType simpleSchema() {
+    return DataTypes.createStructType(new StructField[]{
+        DataTypes.createStructField("column1", DataTypes.StringType, true),
+        DataTypes.createStructField("column2", DataTypes.StringType, true),
+        DataTypes.createStructField("column3", DataTypes.StringType, true)
+    }).asNullable();
+  }
+
+  private Map<String, String> baseOptions() {
+    Map<String, String> opts = new HashMap<>();
+    opts.put(DataSourceWriteOptions.RECORDKEY_FIELD().key(), "column1");
+    opts.put(DataSourceWriteOptions.PARTITIONPATH_FIELD().key(), "column2");
+    opts.put(DataSourceWriteOptions.ORDERING_FIELDS().key(), "column3");
+    opts.put(HoodieTableConfig.NAME.key(), "test_meta_fields_mode");
+    opts.put(DataSourceWriteOptions.TABLE_TYPE().key(), "COPY_ON_WRITE");
+    opts.put(HoodieMetadataConfig.ENABLE.key(), "false");

Review Comment:
   The metadata table is disabled in every functional test here, but it is on 
by default in production.
   
   I checked and the MDT write config is built fresh 
(`HoodieMetadataWriteUtils.createMetadataWriteConfig`) so 
`hoodie.meta.fields.mode` does not leak into it and the new MoR gate cannot 
false-fire -- but nothing in the PR proves that, and it is one 
`withProps(dataWriteConfig.getProps())` refactor away from turning every 
MDT-enabled selective write into a hard `checkArgument` failure.
   
   Suggest running at least one selective-mode case with MDT enabled, asserting 
the write succeeds and the MDT table config resolves to `NONE`.



##########
hudi-common/src/main/java/org/apache/hudi/common/model/MetaFieldsMode.java:
##########
@@ -0,0 +1,191 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hudi.common.model;
+
+import org.apache.hudi.common.config.HoodieConfig;
+import org.apache.hudi.common.table.HoodieTableConfig;
+import org.apache.hudi.common.util.StringUtils;
+
+import java.util.Locale;
+
+/**
+ * Which of Hudi's meta columns are physically populated on disk.
+ *
+ * <p>Selective modes exist so that tables that opt out of the default {@code 
populate.meta.fields=true}
+ * can still keep the two columns that matter for downstream operations 
without paying for the other
+ * three:
+ *
+ * <ul>
+ *   <li>{@code _hoodie_commit_time} — required for incremental queries.</li>
+ *   <li>{@code _hoodie_file_name} — useful for file-level pruning / 
investigation lookups.</li>
+ * </ul>
+ *
+ * <p>The remaining three meta columns ({@code _hoodie_commit_seqno}, {@code 
_hoodie_record_key},
+ * {@code _hoodie_partition_path}) are all-or-nothing — either populate every 
meta column ({@link #ALL})
+ * or none of them beyond the two selectable ones. If you need any of the 
remaining columns, set
+ * {@code hoodie.populate.meta.fields=true}.
+ *
+ * <p>This enum is the single source of truth for meta-column population. The 
legacy boolean
+ * {@code hoodie.populate.meta.fields} is deprecated and consulted only when
+ * {@code hoodie.meta.fields.mode} is absent, so that tables written before 
the mode property
+ * existed keep their behavior:
+ *
+ * <ul>
+ *   <li>{@code populate.meta.fields=true} (or absent) → {@link #ALL} — 
today's default.</li>
+ *   <li>{@code populate.meta.fields=false} → {@link #NONE}.</li>
+ * </ul>
+ *
+ * <p>On-disk representation: the enum {@link #name()} is persisted in {@code 
hoodie.properties}
+ * under the property {@code hoodie.meta.fields.mode}.
+ */
+public enum MetaFieldsMode {
+  /**
+   * All five Hudi meta columns are populated — today's default.
+   */
+  ALL(true, true),
+
+  /**
+   * No Hudi meta columns are populated. Incremental queries are unsupported. 
File-level pruning
+   * that depends on {@code _hoodie_file_name} is unsupported.
+   */
+  NONE(false, false),
+
+  /**
+   * Only {@code _hoodie_commit_time} is populated. Incremental queries remain 
functional; other
+   * meta columns stay null on disk.
+   */
+  COMMIT_TIME_ONLY(true, false),
+
+  /**
+   * Only {@code _hoodie_file_name} is populated. Useful for file-level 
lookups and debugging;
+   * incremental queries are unsupported.
+   */
+  FILE_NAME_ONLY(false, true),
+
+  /**
+   * Both {@code _hoodie_commit_time} and {@code _hoodie_file_name} are 
populated.
+   */
+  COMMIT_TIME_AND_FILE_NAME(true, true);
+
+  private final boolean commitTimePopulated;
+  private final boolean fileNamePopulated;
+
+  MetaFieldsMode(boolean commitTimePopulated, boolean fileNamePopulated) {
+    this.commitTimePopulated = commitTimePopulated;
+    this.fileNamePopulated = fileNamePopulated;
+  }
+
+  public boolean isCommitTimePopulated() {
+    return commitTimePopulated;
+  }
+
+  public boolean isFileNamePopulated() {
+    return fileNamePopulated;
+  }
+
+  /**
+   * @return true when all five meta columns are populated (i.e. this is 
{@link #ALL}). Selective
+   * modes never populate {@code _hoodie_record_key}, {@code 
_hoodie_partition_path}, or
+   * {@code _hoodie_commit_seqno}.
+   */
+  public boolean isRecordKeyPopulated() {
+    return this == ALL;
+  }
+
+  /**
+   * Resolve the effective mode. {@code hoodie.meta.fields.mode} is the source 
of truth; the
+   * deprecated {@code hoodie.populate.meta.fields} boolean is a fallback for 
tables written before
+   * the mode property existed. Precedence:
+   *
+   * <ul>
+   *   <li>non-empty mode → the parsed enum value (the legacy boolean is not 
consulted).</li>
+   *   <li>null/empty mode + {@code populateMetaFields=false} → {@link 
#NONE}.</li>
+   *   <li>null/empty mode + {@code populateMetaFields=true} → {@link 
#ALL}.</li>
+   * </ul>
+   *
+   * @param rawMode             raw {@code hoodie.meta.fields.mode} value; may 
be null or empty.
+   * @param legacyPopulateMetaFields value of the deprecated {@code 
hoodie.populate.meta.fields}.
+   * @throws IllegalArgumentException when the raw mode value does not match 
any enum value. This
+   *         includes the pre-enum comma-separated format — callers that 
upgrade an old table must
+   *         migrate the value through the hudi-cli.
+   */
+  /**

Review Comment:
   nit: two javadoc blocks are stacked here. The original `@param rawMode` / 
`@param legacyPopulateMetaFields` / `@throws` block was left behind when 
`9495687a` inserted the `resolve(HoodieConfig)` overload, so Java attaches only 
the second one -- the documented precedence rules and the `@throws` contract 
are dropped, and the two-arg `resolve` below ends up with no javadoc at all.
   
   Not a build failure (`doclint` is off, `pom.xml:2112`). Just move the first 
block down onto the two-arg overload.



##########
hudi-spark-datasource/hudi-spark/src/test/java/org/apache/hudi/functional/TestMetaFieldsMode.java:
##########
@@ -0,0 +1,469 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.hudi.functional;
+
+import org.apache.hudi.DataSourceWriteOptions;
+import org.apache.hudi.SparkAdapterSupport$;
+import org.apache.hudi.common.config.HoodieMetadataConfig;
+import org.apache.hudi.common.model.HoodieRecord;
+import org.apache.hudi.common.model.MetaFieldsMode;
+import org.apache.hudi.common.table.HoodieTableConfig;
+import org.apache.hudi.common.table.HoodieTableMetaClient;
+import org.apache.hudi.testutils.SparkClientFunctionalTestHarness;
+
+import org.apache.spark.sql.Dataset;
+import org.apache.spark.sql.Row;
+import org.apache.spark.sql.RowFactory;
+import org.apache.spark.sql.SaveMode;
+import org.apache.spark.sql.types.DataTypes;
+import org.apache.spark.sql.types.StructField;
+import org.apache.spark.sql.types.StructType;
+import org.junit.jupiter.api.Test;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Spark-datasource end-to-end tests for the {@code hoodie.meta.fields.mode} 
property on CoW tables.
+ * Every {@link MetaFieldsMode} value is exercised via a write / re-read round 
trip; on-disk column
+ * population is verified by reading the parquet files back and inspecting the 
meta-column values.
+ */
+class TestMetaFieldsMode extends SparkClientFunctionalTestHarness {

Review Comment:
   **The feature's headline promise has no test.** `MetaFieldsMode` documents 
`COMMIT_TIME_ONLY` as "incremental queries remain functional", and that is the 
entire justification for the mode -- but no test in this PR runs an incremental 
read. Across all the new test files the only match for "incremental" is a 
comment at `:224`.
   
   That matters because the read path here is load-bearing and already bit once 
*on this branch*: `13b39db9fb22` fixed `TableSchemaResolver` producing 
**zero-row incremental reads** under a selective mode, and it shipped with 1 
file changed and 0 tests. `358fbfdd717a` (the clustering file-name fix) 
likewise: 1 file, 0 tests.
   
   Suggest one test: write two commits to a `COMMIT_TIME_ONLY` CoW table, run 
an incremental read with `START_COMMIT` = the first instant, assert exactly the 
second commit's rows come back. Plus the negative -- the same query on 
`FILE_NAME_ONLY` / `NONE` throws with the message from 
`IncrementalRelationV1.scala:93-97`. This is the single highest-value test the 
PR is missing.



##########
hudi-client/hudi-client-common/src/test/java/org/apache/hudi/config/TestHoodieWriteConfigMetaFieldsMode.java:
##########
@@ -0,0 +1,189 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hudi.config;
+
+import org.apache.hudi.common.model.HoodieTableType;
+import org.apache.hudi.common.model.MetaFieldsMode;
+import org.apache.hudi.common.table.HoodieTableConfig;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.Properties;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Validates the writer-side accessors and validation guards for the 
meta-field-population modes
+ * on {@link HoodieWriteConfig}. Companion test for the {@link 
HoodieTableConfig} accessors lives
+ * in {@code TestHoodieMetaFieldsMode}; this test covers the writer-builder 
surface and the
+ * cross-flag validation that runs at {@code build()} time.
+ */
+class TestHoodieWriteConfigMetaFieldsMode {

Review Comment:
   Most of this class re-tests `MetaFieldsMode.resolve` through a second facade 
-- `HoodieWriteConfig.getMetaFieldsMode()` is a one-line delegation 
(`HoodieWriteConfig.java:1794`), so `defaultsToAllMode`, 
`explicitNoneModeBuilds`, `commitTimeOnlyModeBuilds`, `fileNameOnlyModeBuilds`, 
`commitTimeAndFileNameCombinationBuilds`, 
`explicitAllModeOverridesLegacyFalse`, `noneModeWithExplicitBuildIsStillNone` 
and `legacyBooleanIsUsedWhenModeIsAbsent` all duplicate 
`TestHoodieMetaFieldsMode` one hop away.
   
   The three that earn their keep are the `validate()` tests. Those belong in 
`TestHoodieWriteConfig`, which already hosts exactly this shape ~15 lines above 
the new checks (`TestHoodieWriteConfig.java:129`).
   
   Also a real gap: **the engine-type guard has zero coverage.** Every test 
here uses the default builder, whose engine is `SPARK`, so deleting the 
`engineType != EngineType.SPARK` check leaves the suite green. Worth one case 
over `FLINK`/`JAVA`.
   
   Suggest folding the three survivors into `TestHoodieWriteConfig` and 
deleting this file.



##########
hudi-hadoop-common/src/test/java/org/apache/hudi/common/table/TestHoodieMetaFieldsMode.java:
##########
@@ -0,0 +1,145 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hudi.common.table;
+
+import org.apache.hudi.common.model.MetaFieldsMode;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Tests the meta-field-population modes exposed by {@link HoodieTableConfig} 
via the
+ * {@code hoodie.meta.fields.mode} property. The mode is materialized as a 
{@link MetaFieldsMode}
+ * enum resolved from both the legacy {@code hoodie.populate.meta.fields} 
boolean and the on-disk
+ * {@code hoodie.meta.fields.mode} property.
+ */
+class TestHoodieMetaFieldsMode {

Review Comment:
   The class name says `MetaFieldsMode`, but every test constructs a 
`HoodieTableConfig` and calls its accessors -- this is table-config resolution, 
and `TestHoodieTableConfig` is the existing home for it (same package, same 
module, and already edited by this PR at `:389`). It already hosts 
pure-resolution tests with no storage, e.g. `:342`, `:356`, `:567`.
   
   Suggest moving these nine methods into `TestHoodieTableConfig` and deleting 
this file. If you want a genuine enum unit test, a small one covering `parse` / 
`isWiderThan` / `toLegacyPopulateMetaFields` under 
`hudi-common/.../common/model/` would be the right shape -- but that is a 
different class from this one.



##########
hudi-spark-datasource/hudi-spark/src/test/java/org/apache/hudi/functional/TestMetaFieldsMode.java:
##########
@@ -0,0 +1,469 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.hudi.functional;
+
+import org.apache.hudi.DataSourceWriteOptions;
+import org.apache.hudi.SparkAdapterSupport$;
+import org.apache.hudi.common.config.HoodieMetadataConfig;
+import org.apache.hudi.common.model.HoodieRecord;
+import org.apache.hudi.common.model.MetaFieldsMode;
+import org.apache.hudi.common.table.HoodieTableConfig;
+import org.apache.hudi.common.table.HoodieTableMetaClient;
+import org.apache.hudi.testutils.SparkClientFunctionalTestHarness;
+
+import org.apache.spark.sql.Dataset;
+import org.apache.spark.sql.Row;
+import org.apache.spark.sql.RowFactory;
+import org.apache.spark.sql.SaveMode;
+import org.apache.spark.sql.types.DataTypes;
+import org.apache.spark.sql.types.StructField;
+import org.apache.spark.sql.types.StructType;
+import org.junit.jupiter.api.Test;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Spark-datasource end-to-end tests for the {@code hoodie.meta.fields.mode} 
property on CoW tables.
+ * Every {@link MetaFieldsMode} value is exercised via a write / re-read round 
trip; on-disk column
+ * population is verified by reading the parquet files back and inspecting the 
meta-column values.
+ */
+class TestMetaFieldsMode extends SparkClientFunctionalTestHarness {
+
+  private static StructType simpleSchema() {
+    return DataTypes.createStructType(new StructField[]{
+        DataTypes.createStructField("column1", DataTypes.StringType, true),
+        DataTypes.createStructField("column2", DataTypes.StringType, true),
+        DataTypes.createStructField("column3", DataTypes.StringType, true)
+    }).asNullable();
+  }
+
+  private Map<String, String> baseOptions() {
+    Map<String, String> opts = new HashMap<>();
+    opts.put(DataSourceWriteOptions.RECORDKEY_FIELD().key(), "column1");
+    opts.put(DataSourceWriteOptions.PARTITIONPATH_FIELD().key(), "column2");
+    opts.put(DataSourceWriteOptions.ORDERING_FIELDS().key(), "column3");
+    opts.put(HoodieTableConfig.NAME.key(), "test_meta_fields_mode");
+    opts.put(DataSourceWriteOptions.TABLE_TYPE().key(), "COPY_ON_WRITE");
+    opts.put(HoodieMetadataConfig.ENABLE.key(), "false");
+    return opts;
+  }
+
+  private void writeRows(List<Row> records, StructType schema, Map<String, 
String> options, String path, SaveMode mode) {
+    spark().createDataset(records,
+            
SparkAdapterSupport$.MODULE$.sparkAdapter().getCatalystExpressionUtils().getEncoder(schema))
+        .write()
+        .format("hudi")
+        .options(options)
+        .mode(mode)
+        .save(path);
+  }
+
+  private HoodieTableConfig writeSampleAndGetTableConfig(Map<String, String> 
options, String path) {
+    writeRows(Arrays.asList(
+            RowFactory.create("k1", "p1", "v1"),
+            RowFactory.create("k2", "p1", "v2")),
+        simpleSchema(), options, path, SaveMode.Overwrite);

Review Comment:
   Every functional write in this class is `SaveMode.Overwrite`, i.e. a fresh 
table each time. That skips the behaviour the feature is actually about:
   
   - `HoodieSparkSqlWriter.scala:1102-1106` folds table props into the write 
params **only when `mode != Overwrite`**, so the mode-inheritance path that 
keeps an append-write on `COMMIT_TIME_ONLY` is never executed.
   - The silent-narrowing bug I flagged on `BaseHoodieWriteClient` is only 
reachable on a second write.
   - Mixed-file behaviour across commits is never observed.
   
   Suggest converting at least the `COMMIT_TIME_ONLY` case to: commit 1 with 
`Overwrite`, commit 2 with `Append` and **without** restating the mode, then 
assert the table config is still `COMMIT_TIME_ONLY` and that commit-2 files 
have non-null `_hoodie_commit_time`.



##########
hudi-spark-datasource/hudi-spark/src/test/java/org/apache/hudi/functional/TestMetaFieldsMode.java:
##########
@@ -0,0 +1,469 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.hudi.functional;
+
+import org.apache.hudi.DataSourceWriteOptions;
+import org.apache.hudi.SparkAdapterSupport$;
+import org.apache.hudi.common.config.HoodieMetadataConfig;
+import org.apache.hudi.common.model.HoodieRecord;
+import org.apache.hudi.common.model.MetaFieldsMode;
+import org.apache.hudi.common.table.HoodieTableConfig;
+import org.apache.hudi.common.table.HoodieTableMetaClient;
+import org.apache.hudi.testutils.SparkClientFunctionalTestHarness;
+
+import org.apache.spark.sql.Dataset;
+import org.apache.spark.sql.Row;
+import org.apache.spark.sql.RowFactory;
+import org.apache.spark.sql.SaveMode;
+import org.apache.spark.sql.types.DataTypes;
+import org.apache.spark.sql.types.StructField;
+import org.apache.spark.sql.types.StructType;
+import org.junit.jupiter.api.Test;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Spark-datasource end-to-end tests for the {@code hoodie.meta.fields.mode} 
property on CoW tables.
+ * Every {@link MetaFieldsMode} value is exercised via a write / re-read round 
trip; on-disk column
+ * population is verified by reading the parquet files back and inspecting the 
meta-column values.
+ */
+class TestMetaFieldsMode extends SparkClientFunctionalTestHarness {
+
+  private static StructType simpleSchema() {
+    return DataTypes.createStructType(new StructField[]{
+        DataTypes.createStructField("column1", DataTypes.StringType, true),
+        DataTypes.createStructField("column2", DataTypes.StringType, true),
+        DataTypes.createStructField("column3", DataTypes.StringType, true)
+    }).asNullable();
+  }
+
+  private Map<String, String> baseOptions() {
+    Map<String, String> opts = new HashMap<>();
+    opts.put(DataSourceWriteOptions.RECORDKEY_FIELD().key(), "column1");
+    opts.put(DataSourceWriteOptions.PARTITIONPATH_FIELD().key(), "column2");
+    opts.put(DataSourceWriteOptions.ORDERING_FIELDS().key(), "column3");
+    opts.put(HoodieTableConfig.NAME.key(), "test_meta_fields_mode");
+    opts.put(DataSourceWriteOptions.TABLE_TYPE().key(), "COPY_ON_WRITE");
+    opts.put(HoodieMetadataConfig.ENABLE.key(), "false");
+    return opts;
+  }
+
+  private void writeRows(List<Row> records, StructType schema, Map<String, 
String> options, String path, SaveMode mode) {
+    spark().createDataset(records,
+            
SparkAdapterSupport$.MODULE$.sparkAdapter().getCatalystExpressionUtils().getEncoder(schema))
+        .write()
+        .format("hudi")
+        .options(options)
+        .mode(mode)
+        .save(path);
+  }
+
+  private HoodieTableConfig writeSampleAndGetTableConfig(Map<String, String> 
options, String path) {
+    writeRows(Arrays.asList(
+            RowFactory.create("k1", "p1", "v1"),
+            RowFactory.create("k2", "p1", "v2")),
+        simpleSchema(), options, path, SaveMode.Overwrite);
+    HoodieTableMetaClient metaClient =
+        
HoodieTableMetaClient.builder().setBasePath(path).setConf(storageConf()).build();
+    return metaClient.getTableConfig();
+  }
+
+  /**
+   * End-to-end assertion of the on-disk meta columns after a write. Reads the 
parquet files back
+   * (bypassing Hudi's own read path so we see the raw column values) and 
asserts which meta
+   * columns are non-null.
+   */
+  private void assertMetaColumnPopulation(String path, MetaFieldsMode 
expectedMode) {
+    Dataset<Row> raw = spark().read().parquet(path + "/*/*.parquet");
+    Row first = raw.select(
+        HoodieRecord.COMMIT_TIME_METADATA_FIELD,
+        HoodieRecord.COMMIT_SEQNO_METADATA_FIELD,
+        HoodieRecord.RECORD_KEY_METADATA_FIELD,
+        HoodieRecord.PARTITION_PATH_METADATA_FIELD,
+        HoodieRecord.FILENAME_METADATA_FIELD).first();
+
+    if (expectedMode.isCommitTimePopulated()) {
+      assertNotNull(first.get(0), "expected _hoodie_commit_time to be 
populated for mode " + expectedMode);
+    } else {
+      assertNull(first.get(0), "expected _hoodie_commit_time to be null for 
mode " + expectedMode);
+    }
+    if (expectedMode.isFileNamePopulated()) {
+      assertNotNull(first.get(4), "expected _hoodie_file_name to be populated 
for mode " + expectedMode);
+    } else {
+      assertNull(first.get(4), "expected _hoodie_file_name to be null for mode 
" + expectedMode);
+    }
+    // Record key, partition path, and commit seq no are ALL-only.
+    if (expectedMode == MetaFieldsMode.ALL) {
+      assertNotNull(first.get(2), "record key must be populated in ALL mode");
+      assertNotNull(first.get(3), "partition path must be populated in ALL 
mode");
+      assertNotNull(first.get(1), "commit seq no must be populated in ALL 
mode");
+    } else {
+      assertNull(first.get(2), "record key must be null outside ALL mode, got: 
" + first.get(2));
+      assertNull(first.get(3), "partition path must be null outside ALL mode, 
got: " + first.get(3));
+      assertNull(first.get(1), "commit seq no must be null outside ALL mode, 
got: " + first.get(1));
+    }
+  }
+
+  @Test
+  void allModePersistsAndPopulatesAllColumns() {
+    Map<String, String> options = baseOptions();
+    // ALL is the default; no need to set the mode explicitly.
+    options.put(DataSourceWriteOptions.OPERATION().key(), 
DataSourceWriteOptions.BULK_INSERT_OPERATION_OPT_VAL());
+
+    HoodieTableConfig tc = writeSampleAndGetTableConfig(options, basePath());
+
+    assertTrue(tc.populateMetaFields());
+    assertEquals(MetaFieldsMode.ALL, tc.getMetaFieldsMode());
+    assertMetaColumnPopulation(basePath(), MetaFieldsMode.ALL);
+  }
+
+  @Test
+  void noneModePersistsAndLeavesAllColumnsNull() {
+    Map<String, String> options = baseOptions();
+    options.put(HoodieTableConfig.POPULATE_META_FIELDS.key(), "false");
+    options.put(DataSourceWriteOptions.OPERATION().key(), 
DataSourceWriteOptions.BULK_INSERT_OPERATION_OPT_VAL());
+
+    HoodieTableConfig tc = writeSampleAndGetTableConfig(options, basePath());
+
+    assertFalse(tc.populateMetaFields());
+    assertEquals(MetaFieldsMode.NONE, tc.getMetaFieldsMode());
+    assertMetaColumnPopulation(basePath(), MetaFieldsMode.NONE);
+  }
+
+  @Test
+  void commitTimeOnlyModePopulatesOnlyCommitTime() {
+    Map<String, String> options = baseOptions();
+    options.put(HoodieTableConfig.POPULATE_META_FIELDS.key(), "false");
+    options.put(HoodieTableConfig.META_FIELDS_MODE.key(), 
MetaFieldsMode.COMMIT_TIME_ONLY.name());
+    options.put(DataSourceWriteOptions.OPERATION().key(), 
DataSourceWriteOptions.BULK_INSERT_OPERATION_OPT_VAL());
+
+    HoodieTableConfig tc = writeSampleAndGetTableConfig(options, basePath());
+
+    assertEquals(MetaFieldsMode.COMMIT_TIME_ONLY.name(),
+        tc.getProps().getProperty(HoodieTableConfig.META_FIELDS_MODE.key()));
+    assertEquals(MetaFieldsMode.COMMIT_TIME_ONLY, tc.getMetaFieldsMode());
+    assertMetaColumnPopulation(basePath(), MetaFieldsMode.COMMIT_TIME_ONLY);
+  }
+
+  @Test
+  void fileNameOnlyModePopulatesOnlyFileName() {
+    Map<String, String> options = baseOptions();
+    options.put(HoodieTableConfig.POPULATE_META_FIELDS.key(), "false");
+    options.put(HoodieTableConfig.META_FIELDS_MODE.key(), 
MetaFieldsMode.FILE_NAME_ONLY.name());
+    options.put(DataSourceWriteOptions.OPERATION().key(), 
DataSourceWriteOptions.BULK_INSERT_OPERATION_OPT_VAL());
+
+    HoodieTableConfig tc = writeSampleAndGetTableConfig(options, basePath());
+
+    assertEquals(MetaFieldsMode.FILE_NAME_ONLY, tc.getMetaFieldsMode());
+    assertMetaColumnPopulation(basePath(), MetaFieldsMode.FILE_NAME_ONLY);
+  }
+
+  @Test
+  void commitTimeAndFileNameModePopulatesBoth() {
+    Map<String, String> options = baseOptions();
+    options.put(HoodieTableConfig.POPULATE_META_FIELDS.key(), "false");
+    options.put(HoodieTableConfig.META_FIELDS_MODE.key(), 
MetaFieldsMode.COMMIT_TIME_AND_FILE_NAME.name());
+    options.put(DataSourceWriteOptions.OPERATION().key(), 
DataSourceWriteOptions.BULK_INSERT_OPERATION_OPT_VAL());
+
+    HoodieTableConfig tc = writeSampleAndGetTableConfig(options, basePath());
+
+    assertEquals(MetaFieldsMode.COMMIT_TIME_AND_FILE_NAME, 
tc.getMetaFieldsMode());
+    assertMetaColumnPopulation(basePath(), 
MetaFieldsMode.COMMIT_TIME_AND_FILE_NAME);
+  }
+
+  @Test
+  void selectiveModeWinsOverLegacyPopulateTrue() {
+    // hoodie.meta.fields.mode is the source of truth: an explicit mode is 
honored regardless of
+    // the deprecated boolean, so this combination is no longer ambiguous and 
is not rejected.
+    Map<String, String> options = baseOptions();
+    options.put(HoodieTableConfig.POPULATE_META_FIELDS.key(), "true");
+    options.put(HoodieTableConfig.META_FIELDS_MODE.key(), 
MetaFieldsMode.COMMIT_TIME_ONLY.name());
+    options.put(DataSourceWriteOptions.OPERATION().key(), 
DataSourceWriteOptions.BULK_INSERT_OPERATION_OPT_VAL());
+
+    HoodieTableConfig tc = writeSampleAndGetTableConfig(options, basePath());
+
+    assertEquals(MetaFieldsMode.COMMIT_TIME_ONLY, tc.getMetaFieldsMode());
+    assertMetaColumnPopulation(basePath(), MetaFieldsMode.COMMIT_TIME_ONLY);
+    // ...and hoodie.properties must not contradict the mode. A pre-1.3.0 
reader ignores the mode
+    // property entirely, so leaving populate.meta.fields=true here would make 
it treat a
+    // selectively-written table as ALL.
+    assertFalse(tc.populateMetaFields(),
+        "legacy populate.meta.fields must be derived from the mode, not 
carried through verbatim");
+  }
+
+  @Test
+  void noneModePersistsLegacyBooleanAsFalse() {
+    // The unsafe case: an old incremental reader that sees 
populate.meta.fields=true on a NONE
+    // table would run against all-null commit times and silently return zero 
rows.
+    Map<String, String> options = baseOptions();
+    options.put(HoodieTableConfig.POPULATE_META_FIELDS.key(), "true");
+    options.put(HoodieTableConfig.META_FIELDS_MODE.key(), 
MetaFieldsMode.NONE.name());
+    options.put(DataSourceWriteOptions.OPERATION().key(), 
DataSourceWriteOptions.BULK_INSERT_OPERATION_OPT_VAL());
+
+    HoodieTableConfig tc = writeSampleAndGetTableConfig(options, basePath());
+
+    assertEquals(MetaFieldsMode.NONE, tc.getMetaFieldsMode());
+    assertFalse(tc.populateMetaFields(),
+        "NONE must persist populate.meta.fields=false so pre-1.3.0 readers do 
not treat it as ALL");
+  }
+
+  @Test
+  void allModePersistsLegacyBooleanAsTrue() {
+    Map<String, String> options = baseOptions();
+    options.put(HoodieTableConfig.POPULATE_META_FIELDS.key(), "false");
+    options.put(HoodieTableConfig.META_FIELDS_MODE.key(), 
MetaFieldsMode.ALL.name());
+    options.put(DataSourceWriteOptions.OPERATION().key(), 
DataSourceWriteOptions.BULK_INSERT_OPERATION_OPT_VAL());
+
+    HoodieTableConfig tc = writeSampleAndGetTableConfig(options, basePath());
+
+    assertEquals(MetaFieldsMode.ALL, tc.getMetaFieldsMode());
+    assertTrue(tc.populateMetaFields(),
+        "ALL must persist populate.meta.fields=true for pre-1.3.0 readers");
+  }
+
+  @Test
+  void unknownModeValueIsRejected() {
+    Map<String, String> options = baseOptions();
+    options.put(HoodieTableConfig.POPULATE_META_FIELDS.key(), "false");
+    options.put(HoodieTableConfig.META_FIELDS_MODE.key(), "SOMETHING_BOGUS");
+    options.put(DataSourceWriteOptions.OPERATION().key(), 
DataSourceWriteOptions.BULK_INSERT_OPERATION_OPT_VAL());
+
+    Throwable thrown = assertThrows(Throwable.class, () ->
+        writeRows(Collections.singletonList(RowFactory.create("k1", "p1", 
"v1")),
+            simpleSchema(), options, basePath(), SaveMode.Overwrite));
+
+    String rootMessage = rootMessageOf(thrown);
+    assertTrue(rootMessage.contains("SOMETHING_BOGUS"),
+        "Expected error to name the rejected value, got: " + rootMessage);
+  }
+
+  // -------------------------------------------------------------------------
+  // Non-row-writer path coverage. Bulk insert with row.writer.enable=false 
forces the
+  // HoodieAvroParquetWriter path (via HoodieCreateHandle) instead of the 
internal-row writer path.
+  // Both paths must respect the mode identically.
+  // -------------------------------------------------------------------------
+
+  @Test
+  void nonRowWriterPathAllMode() {
+    Map<String, String> options = baseOptions();
+    options.put(DataSourceWriteOptions.OPERATION().key(), 
DataSourceWriteOptions.INSERT_OPERATION_OPT_VAL());
+    options.put("hoodie.datasource.write.row.writer.enable", "false");
+
+    HoodieTableConfig tc = writeSampleAndGetTableConfig(options, basePath());
+    assertEquals(MetaFieldsMode.ALL, tc.getMetaFieldsMode());
+    assertMetaColumnPopulation(basePath(), MetaFieldsMode.ALL);
+  }
+
+  @Test
+  void nonRowWriterPathNoneMode() {
+    Map<String, String> options = baseOptions();
+    options.put(HoodieTableConfig.POPULATE_META_FIELDS.key(), "false");
+    options.put(DataSourceWriteOptions.OPERATION().key(), 
DataSourceWriteOptions.INSERT_OPERATION_OPT_VAL());
+    options.put("hoodie.datasource.write.row.writer.enable", "false");
+
+    HoodieTableConfig tc = writeSampleAndGetTableConfig(options, basePath());
+    assertEquals(MetaFieldsMode.NONE, tc.getMetaFieldsMode());
+    assertMetaColumnPopulation(basePath(), MetaFieldsMode.NONE);
+  }
+
+  @Test
+  void nonRowWriterPathCommitTimeOnly() {
+    Map<String, String> options = baseOptions();
+    options.put(HoodieTableConfig.POPULATE_META_FIELDS.key(), "false");
+    options.put(HoodieTableConfig.META_FIELDS_MODE.key(), 
MetaFieldsMode.COMMIT_TIME_ONLY.name());
+    options.put(DataSourceWriteOptions.OPERATION().key(), 
DataSourceWriteOptions.INSERT_OPERATION_OPT_VAL());
+    options.put("hoodie.datasource.write.row.writer.enable", "false");
+
+    HoodieTableConfig tc = writeSampleAndGetTableConfig(options, basePath());
+    assertEquals(MetaFieldsMode.COMMIT_TIME_ONLY, tc.getMetaFieldsMode());
+    assertMetaColumnPopulation(basePath(), MetaFieldsMode.COMMIT_TIME_ONLY);
+  }
+
+  @Test
+  void nonRowWriterPathFileNameOnly() {
+    Map<String, String> options = baseOptions();
+    options.put(HoodieTableConfig.POPULATE_META_FIELDS.key(), "false");
+    options.put(HoodieTableConfig.META_FIELDS_MODE.key(), 
MetaFieldsMode.FILE_NAME_ONLY.name());
+    options.put(DataSourceWriteOptions.OPERATION().key(), 
DataSourceWriteOptions.INSERT_OPERATION_OPT_VAL());
+    options.put("hoodie.datasource.write.row.writer.enable", "false");
+
+    HoodieTableConfig tc = writeSampleAndGetTableConfig(options, basePath());
+    assertEquals(MetaFieldsMode.FILE_NAME_ONLY, tc.getMetaFieldsMode());
+    assertMetaColumnPopulation(basePath(), MetaFieldsMode.FILE_NAME_ONLY);
+  }
+
+  @Test
+  void nonRowWriterPathCommitTimeAndFileName() {
+    Map<String, String> options = baseOptions();
+    options.put(HoodieTableConfig.POPULATE_META_FIELDS.key(), "false");
+    options.put(HoodieTableConfig.META_FIELDS_MODE.key(), 
MetaFieldsMode.COMMIT_TIME_AND_FILE_NAME.name());
+    options.put(DataSourceWriteOptions.OPERATION().key(), 
DataSourceWriteOptions.INSERT_OPERATION_OPT_VAL());
+    options.put("hoodie.datasource.write.row.writer.enable", "false");
+
+    HoodieTableConfig tc = writeSampleAndGetTableConfig(options, basePath());
+    assertEquals(MetaFieldsMode.COMMIT_TIME_AND_FILE_NAME, 
tc.getMetaFieldsMode());
+    assertMetaColumnPopulation(basePath(), 
MetaFieldsMode.COMMIT_TIME_AND_FILE_NAME);
+  }
+
+  // -------------------------------------------------------------------------
+  // Clustering coverage. Inline clustering rewrites files through the 
create/merge handles which
+  // delegate to the same underlying HoodieAvroParquetWriter / 
HoodieRowCreateHandle we exercise
+  // in the write tests. Verifies clustered files preserve the mode's column 
population semantics.
+  // -------------------------------------------------------------------------
+
+  @Test
+  void clusteringPreservesCommitTimeOnlyMode() {

Review Comment:
   These clustering tests cannot fail on the bug they were written for.
   
   `358fbfdd717a` fixed row-writer clustering copying the *source row's* 
`_hoodie_file_name` instead of the new one. The only assertion these reach is 
`assertNotNull(first.get(4))` in `assertMetaColumnPopulation` -- and the buggy 
value (the pre-clustering file name) is also non-null, so it passes either way. 
`git revert -n 358fbfdd717a` and these stay green.
   
   Two more issues in the same helper: it globs `path + "/*/*.parquet"`, which 
after inline clustering still includes the *replaced* pre-clustering file (no 
cleaning has run), and `.first()` samples one arbitrary row. Nothing asserts 
clustering actually ran.
   
   Suggest asserting `_hoodie_file_name` equals the containing file, for every 
row:
   ```java
   Dataset<Row> raw = spark().read().parquet(path + "/*/*.parquet")
       .withColumn("__f", functions.input_file_name());
   assertEquals(0, raw.filter("__f not like concat('%/', 
_hoodie_file_name)").count());
   ```
   plus `assertEquals(1, 
metaClient.getActiveTimeline().getCompletedReplaceTimeline().countInstants())`. 
Also `clusteringPreservesAllMode` and `clusteringPreservesNoneMode` never enter 
the selective branch at all (`HoodieRowCreateHandle.java:162-170` routes 
ALL/NONE elsewhere) -- those two can go.



##########
hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/TestHoodieSparkSqlWriter.scala:
##########
@@ -107,7 +107,7 @@ class TestHoodieSparkSqlWriter extends 
HoodieSparkWriterTestBase {
     // fetch all records from parquet files generated from write to hudi
     val actualDf = sqlContext.read.parquet(fullPartitionPaths(0), 
fullPartitionPaths(1), fullPartitionPaths(2))
     if (!populateMetaFields) {
-      List(0, 1, 2, 3, 4).foreach(i => assertEquals(0, 
actualDf.select(HoodieRecord.HOODIE_META_COLUMNS.get(i)).filter(entry => 
!(entry.mkString(",").equals(""))).count()))
+      List(0, 1, 2, 3, 4).foreach(i => assertEquals(0, 
actualDf.select(HoodieRecord.HOODIE_META_COLUMNS.get(i)).filter(entry => 
!entry.isNullAt(0) && entry.getString(0).nonEmpty).count()))

Review Comment:
   This update is correct -- the old form would fail on NULL, since 
`Row.mkString` renders null as the string "null" -- but it relaxed further than 
the behaviour change required. `!entry.isNullAt(0) && 
entry.getString(0).nonEmpty` accepts **both** NULL and `""`, so it no longer 
pins which representation is written.
   
   That is worth keeping strict, because the whole point of `77f4e9148c4a` is 
that these are now NULL, and `TestMetaFieldsMode.java:149` asserts exactly 
`assertNull` for the same scenario in this same PR. Right now the two tests 
disagree on strictness.
   
   ```suggestion
         List(0, 1, 2, 3, 4).foreach(i => assertEquals(0, 
actualDf.select(HoodieRecord.HOODIE_META_COLUMNS.get(i)).filter(entry => 
!entry.isNullAt(0)).count()))
   ```
   
   Since this exact edit is being made in two near-identical files, a shared 
`assertNoMetaFieldsPopulated(df)` in `HoodieSparkWriterTestBase` (next to 
`dropMetaFields`) would be better than duplicating it.



##########
hudi-client/hudi-client-common/src/test/java/org/apache/hudi/table/upgrade/TestTenToNineDowngradeHandler.java:
##########
@@ -24,18 +24,25 @@
 import org.junit.jupiter.api.Test;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 
 class TestTenToNineDowngradeHandler {
 
   @Test
-  void testDowngradeRemovesStorageLayoutOnly() {
+  void testDowngradeRemovesStorageLayoutAndMetaFieldsMode() {
     UpgradeDowngrade.TableConfigChangeSet changeSet =
         new TenToNineDowngradeHandler().downgrade(null, null, null, null);

Review Comment:
   Passing `null` for `upgradeDowngradeHelper` makes the handler short-circuit 
to `MetaFieldsMode.ALL`, so the entire selective-mode branch -- the mode lookup 
and the data-loss `LOG.warn` -- is never executed by any test.
   
   Suggest one case with a mock helper returning `COMMIT_TIME_ONLY`, copying 
the `helperFor(...)` factory from `TestNineToTenUpgradeHandler.java:33-45`. You 
will need it anyway to cover the downgrade fix.



##########
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:
   **This is cshuo's StreamSync-restart scenario, and I do not think it is 
fixed.** It was closed as resolved by `c78dc962`, but that commit fixed 
table-config *resolution*, not this gate.
   
   Walk it through with table = `COMMIT_TIME_ONLY` and a writer that sets only 
`hoodie.populate.meta.fields=false`:
   - `writeMetaFieldsMode` resolves to `NONE`
   - `NONE.isWiderThan(COMMIT_TIME_ONLY)` is `false`, so the first branch is 
skipped
   - `writerStatedMode` is `false`, so the second branch is skipped
   - validation passes
   
   The writer then takes the mode from the **write** config, not the table 
config (`HoodieSparkFileWriterFactory.java:59-60`), so it writes base files 
with null `_hoodie_commit_time`. The table still advertises `COMMIT_TIME_ONLY`, 
so `IncrementalRelationV1.scala:92` still admits incremental queries, and the 
range filter at `:295-297` silently drops every one of those rows.
   
   StreamSync is the concrete path: it builds its write config from `props` 
only (`StreamSync.java:1280-1291`) and never merges the table config -- the 
`metaClient != null` block at `:1297-1309` only touches ordering fields. The +2 
lines this PR adds live in `initializeEmptyTable`, which runs only when the 
base path does not exist, so a restart against an existing table never re-reads 
the mode. The Spark datasource is safe only by accident, because 
`HoodieSparkSqlWriter.scala:1102-1106` copies table props into the write params.
   
   Same failure shape as `876a891979a3` [HUDI-3544], where the fix was to 
detect the config/file disagreement and re-initialize rather than let mixed 
files accumulate.
   
   The narrowing carve-out only needs to cover `ALL -> NONE` (the pre-PR 
behaviour from `d5026e9a2485`). Suggest tightening to:
   ```java
   } else if (writeMetaFieldsMode != tableMetaFieldsMode
              && (writerStatedMode || tableMetaFieldsMode.isSelective())) {
   ```
   and adding a `TestBaseHoodieWriteClient` case: table `COMMIT_TIME_ONLY`, 
unstated writer -> must throw.



##########
hudi-client/hudi-client-common/src/test/java/org/apache/hudi/table/upgrade/TestNineToTenUpgradeHandler.java:
##########
@@ -0,0 +1,85 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hudi.table.upgrade;
+
+import org.apache.hudi.common.engine.HoodieEngineContext;
+import org.apache.hudi.common.model.MetaFieldsMode;
+import org.apache.hudi.common.table.HoodieTableConfig;
+import org.apache.hudi.common.table.HoodieTableMetaClient;
+import org.apache.hudi.config.HoodieWriteConfig;
+import org.apache.hudi.table.HoodieTable;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.RETURNS_DEEP_STUBS;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * Version 9 tables predate {@code hoodie.meta.fields.mode}, so the upgrade 
records the value
+ * derived from the deprecated {@code hoodie.populate.meta.fields} boolean. 
This makes an upgraded
+ * table describe its meta-field layout the same way a freshly created version 
10 table does,
+ * instead of relying on the legacy fallback at every read.
+ */
+class TestNineToTenUpgradeHandler {
+
+  private static SupportsUpgradeDowngrade helperFor(MetaFieldsMode 
resolvedMode) {
+    HoodieTable table = mock(HoodieTable.class, RETURNS_DEEP_STUBS);
+    HoodieTableMetaClient metaClient = mock(HoodieTableMetaClient.class, 
RETURNS_DEEP_STUBS);
+    HoodieTableConfig tableConfig = mock(HoodieTableConfig.class);
+    when(tableConfig.getMetaFieldsMode()).thenReturn(resolvedMode);
+    when(metaClient.getTableConfig()).thenReturn(tableConfig);
+    when(table.getMetaClient()).thenReturn(metaClient);
+
+    SupportsUpgradeDowngrade helper = mock(SupportsUpgradeDowngrade.class);
+    
when(helper.getTable(org.mockito.ArgumentMatchers.any(HoodieWriteConfig.class),
+        
org.mockito.ArgumentMatchers.any(HoodieEngineContext.class))).thenReturn(table);
+    return helper;
+  }
+
+  @ParameterizedTest
+  @CsvSource({"ALL", "NONE"})
+  void upgradeRecordsTheModeDerivedFromTheLegacyBoolean(String modeName) {
+    MetaFieldsMode expected = MetaFieldsMode.valueOf(modeName);
+    UpgradeDowngrade.TableConfigChangeSet changeSet = new 
NineToTenUpgradeHandler().upgrade(
+        mock(HoodieWriteConfig.class), mock(HoodieEngineContext.class), "001", 
helperFor(expected));
+
+    assertTrue(changeSet.propertiesToDelete().isEmpty());
+    assertEquals(1, changeSet.propertiesToUpdate().size());
+    assertEquals(expected.name(),
+        
changeSet.propertiesToUpdate().get(HoodieTableConfig.META_FIELDS_MODE));
+  }
+
+  @Test
+  void upgradeLeavesTheLegacyBooleanAlone() {

Review Comment:
   nit: every assertion here is implied by the parameterized test above with 
`modeName=NONE` -- both call `helperFor(NONE)` and assert the same 
delete/update sizes, and `containsKey(META_FIELDS_MODE)` is weaker than the 
`equals("NONE")` already asserted at `:69`. It cannot fail independently.
   
   Either delete it, or make it discriminate on what its name claims by 
asserting `POPULATE_META_FIELDS` appears in neither the update nor the delete 
set.



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