This is an automated email from the ASF dual-hosted git repository.
danny0405 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/hudi.git
The following commit(s) were added to refs/heads/master by this push:
new 82640e56e66f feat(cli): add table set-meta-fields-mode command (#19206)
82640e56e66f is described below
commit 82640e56e66f7dcf0389a55b990e3e8aabaf4afa
Author: Sivabalan Narayanan <[email protected]>
AuthorDate: Tue Aug 18 23:59:20 2026 -0700
feat(cli): add table set-meta-fields-mode command (#19206)
* feat(cli): add table set-meta-fields-mode command
Adds a hudi-cli command to toggle hoodie.meta.fields.mode on an existing
table:
table set-meta-fields-mode --target-mode <MODE> [--force true|false]
Where <MODE> is one of ALL, NONE, COMMIT_TIME_ONLY, FILE_NAME_ONLY,
COMMIT_TIME_AND_FILE_NAME. This is the sanctioned way to change the
property outside of
table creation — hoodie.meta.fields.mode is immutable at runtime because it
is a
physical-storage decision baked into files at write time.
Safety guard:
- On a table that already has commits, the command refuses to change the
mode by default.
Existing files were written under the current mode and are not rewritten
by this command;
new commits would be written under the new mode, producing mixed-mode
files whose
incremental / file-pruning semantics differ between old and new data.
- Pass --force to override the guard. A warning is logged describing the
specific data
correctness impact.
Behavior:
- ALL and NONE are persisted implicitly (via
populate.meta.fields=true/false); the
hoodie.meta.fields.mode property is cleared when transitioning to
ALL/NONE.
- Selective modes (COMMIT_TIME_ONLY, FILE_NAME_ONLY,
COMMIT_TIME_AND_FILE_NAME) are
persisted explicitly on hoodie.properties alongside
populate.meta.fields=false.
- No-op when target matches current mode.
Test coverage (TestTableCommand):
- All 5 modes settable on a fresh table.
- Selective → ALL clears the property.
- No-op when target matches current mode.
- Rejects unknown enum values.
- Refuses on a populated table without --force; the mode does not change.
- Accepts on a populated table with --force; the mode changes.
Follow-up to PR #19205 (hoodie.meta.fields.mode). PR-C (MoR support) is the
next follow-up.
* fix(cli): apply the one-way meta-fields rule to set-meta-fields-mode
Rebased onto #19205's current head, which establishes that
hoodie.meta.fields.mode
is a table property changeable only through this command or an upgrade.
Three gaps
between what the command did and what that rule requires:
1. Nothing stopped a *widening*. The --force gate keyed only on commit
count, so
`--target-mode ALL --force` on a populated COMMIT_TIME_ONLY table was
allowed.
Existing files are not rewritten, so the table would advertise a meta
column that
is null for every earlier row, and incremental queries would admit the
table and
silently skip exactly those rows. Widening is now refused outright and
--force
does not override it -- there is no consequence for an operator to
knowingly
accept. Narrowing keeps the --force gate, since it only leaves values
nothing
reads. Uses MetaFieldsMode#isWiderThan, the same predicate
BaseHoodieWriteClient#validateAgainstTableProperties uses, so the CLI
and the
writer cannot disagree about which transitions are legal.
2. ALL and NONE deleted the mode property instead of writing it. That left
the table
resolving through the legacy fallback -- indistinguishable from a table
predating
the property -- made the command's effect invisible in
hoodie.properties, and
removed the value TenToNineDowngradeHandler reads to derive the boolean
it writes
back. Both properties are now always written, with the boolean derived
from the
mode, matching the invariant TableBuilder enforces at creation.
3. valueOf rejected lowercase input and produced a bare "No enum constant".
Routed
through MetaFieldsMode.parse, so `commit_time_only` works and the
message lists
the allowed values like every other path.
Tests: widening refused in three forms including --force; narrowing refused
without
--force and accepted with it; lowercase accepted; both properties in
agreement
across all five modes. testSetMetaFieldsModeToAllClearsProperty asserted the
deleted-property behavior and is rewritten as
testSetMetaFieldsModeToAllWritesTheModeExplicitly.
* docs(cli): clarify that --force permits narrowing only
The --force help implied it could override any mode change on a populated
table.
It cannot override a widening -- that is refused outright, since existing
files are
never rewritten and the table would advertise a column null for every
earlier row.
* test(cli): pin the full meta-fields-mode migration matrix
The existing tests spot-checked "narrowing works, widening does not". That
misses
the shape of the relation: COMMIT_TIME_ONLY and FILE_NAME_ONLY are mutually
wider --
each populates a column the other does not -- so neither can migrate to the
other in
either direction. It is a lattice, not a chain, and a spot-check cannot
show that.
Adds an exhaustive test over all 20 ordered pairs on a populated table,
asserting
each is either a legal narrowing (succeeds with --force, mode and derived
boolean
both updated) or a refused widening (fails even with --force, mode
untouched). Plus
a focused test naming both sibling directions explicitly, since that is the
case a
reader is most likely to get wrong.
Verified the matrix independently against MetaFieldsMode#isWiderThan before
writing
the test, so the expectations are derived from the predicate rather than
from my
reading of it.
Note: these cannot be executed locally -- hudi-cli compilation is blocked
by a stale
hudi-utilities-bundle shadowing hudi-common, and rebuilding that bundle
fails on a
pre-existing KafkaAvroSchemaDeserializer error that reproduces on master.
Compiled
against a classpath excluding the stale bundle, and checkstyle passes.
* fix(cli): adapt meta-fields command after rebase
---------
Co-authored-by: danny0405 <[email protected]>
---
.../org/apache/hudi/cli/commands/TableCommand.java | 105 +++++++
.../apache/hudi/cli/commands/TestTableCommand.java | 323 ++++++++++++++++++++-
.../HoodieTestCommitMetadataGenerator.java | 6 +-
3 files changed, 429 insertions(+), 5 deletions(-)
diff --git
a/hudi-cli/src/main/java/org/apache/hudi/cli/commands/TableCommand.java
b/hudi-cli/src/main/java/org/apache/hudi/cli/commands/TableCommand.java
index a091111952cc..134a28df8e3e 100644
--- a/hudi-cli/src/main/java/org/apache/hudi/cli/commands/TableCommand.java
+++ b/hudi-cli/src/main/java/org/apache/hudi/cli/commands/TableCommand.java
@@ -25,6 +25,7 @@ import org.apache.hudi.cli.TableHeader;
import org.apache.hudi.common.config.HoodieTimeGeneratorConfig;
import org.apache.hudi.common.fs.ConsistencyGuardConfig;
import org.apache.hudi.common.model.HoodieTableType;
+import org.apache.hudi.common.model.MetaFieldsMode;
import org.apache.hudi.common.schema.HoodieSchema;
import org.apache.hudi.common.table.HoodieTableConfig;
import org.apache.hudi.common.table.HoodieTableMetaClient;
@@ -256,6 +257,110 @@ public class TableCommand {
return renderOldNewProps(newProps, oldProps);
}
+ @ShellMethod(key = "table set-meta-fields-mode",
+ value = "Set hoodie.meta.fields.mode on an existing table. This is the
sanctioned way to change "
+ + "the property — a write never can, since it is a physical-storage
decision baked into "
+ + "files at write time. Two guards apply on a table that already has
commits: widening the "
+ + "mode (adding a populated meta column) is refused outright,
because existing files are "
+ + "not rewritten and the table would advertise a column that is null
for every earlier "
+ + "row; narrowing is refused unless --force is passed, since it
leaves mixed-mode files "
+ + "whose incremental / file-pruning semantics differ between old and
new data.")
+ public String setMetaFieldsMode(
+ @ShellOption(value = {"--target-mode"},
+ help = "One of ALL, NONE, COMMIT_TIME_ONLY, FILE_NAME_ONLY,
COMMIT_TIME_AND_FILE_NAME")
+ final String targetModeStr,
+ @ShellOption(value = {"--force"}, defaultValue = "false",
+ help = "Allow NARROWING the mode on a table that already has
commits. Does not permit "
+ + "widening, which is refused regardless. Existing files are not
rewritten — new "
+ + "commits use the new mode, old commits keep the old one.
Incremental queries and "
+ + "file-name-based lookups will silently drop rows from commits
written under the "
+ + "incompatible mode.")
+ final boolean force) throws IOException {
+ MetaFieldsMode targetMode;
+ try {
+ // Resolve through the public configuration API rather than valueOf: it
is case-insensitive,
+ // trims, and lists the allowed values in its message, so an operator
typing
+ // `commit_time_only` is not rejected and the error text matches what a
bad value in
+ // hoodie.properties or a write option produces.
+ Properties targetProps = new Properties();
+ targetProps.setProperty(HoodieTableConfig.META_FIELDS_MODE.key(),
targetModeStr);
+ targetMode = MetaFieldsMode.resolve(targetProps);
+ } catch (IllegalArgumentException e) {
+ throw new HoodieException("Invalid --target-mode: " + e.getMessage(), e);
+ }
+
+ HoodieCLI.refreshTableMetadata();
+ HoodieTableMetaClient client = HoodieCLI.getTableMetaClient();
+ Map<String, String> oldProps = client.getTableConfig().propsMap();
+ MetaFieldsMode currentMode = client.getTableConfig().getMetaFieldsMode();
+
+ if (currentMode == targetMode) {
+ return String.format("Table is already in %s mode; nothing to change.",
targetMode);
+ }
+
+ int commitCount =
client.getActiveTimeline().getCommitsTimeline().countInstants();
+
+ // Meta-field population is one-way for a table that already holds data,
and --force does not
+ // override it: dropping a column leaves earlier files carrying values
nothing reads, which a
+ // reader can ignore, but *adding* one leaves later files claiming a
column earlier files do not
+ // physically have. Nothing distinguishes the two sets, so a widened table
advertises a column
+ // that is silently null for every pre-existing row — incremental queries
would then admit the
+ // table and drop exactly those rows. There is no consequence for an
operator to accept here, so
+ // this is a hard failure rather than a --force gate.
+ //
+ // Same predicate the write path uses
(BaseHoodieWriteClient#validateAgainstTableProperties), so
+ // the CLI and the writer cannot disagree about which transitions are
legal.
+ if (commitCount > 0 && targetMode.isWiderThan(currentMode)) {
+ throw new HoodieException(String.format(
+ "Refusing to widen hoodie.meta.fields.mode from %s to %s on a table
with %d commit(s): "
+ + "%s populates meta columns that %s does not, and this command
does not rewrite "
+ + "existing files. The table would advertise a column that is
null for every row "
+ + "written so far, which incremental queries and file-name
lookups silently skip. "
+ + "Narrowing the mode is allowed; to widen, recreate the table.",
+ currentMode, targetMode, commitCount, targetMode, currentMode));
+ }
+
+ // Safety check: refuse to change the mode on a table with commits unless
--force.
+ if (commitCount > 0 && !force) {
+ throw new HoodieException(String.format(
+ "Refusing to change hoodie.meta.fields.mode on a table that already
has %d commit(s). "
+ + "Existing files were written under %s and will not be
rewritten by this command; "
+ + "new commits would be written under %s, producing mixed-mode
files whose "
+ + "incremental / file-pruning semantics differ between old and
new data. "
+ + "Pass --force if you accept the consequences, or recreate the
table to change "
+ + "the mode cleanly.",
+ commitCount, currentMode, targetMode));
+ }
+ if (commitCount > 0) {
+ log.warn("--force passed: changing hoodie.meta.fields.mode from {} to {}
on a table with "
+ + "{} commit(s). Existing files retain the old layout; new
commits use the new "
+ + "layout. Incremental queries and file-name lookups may
silently drop rows written "
+ + "under the incompatible mode.",
+ currentMode, targetMode, commitCount);
+ }
+
+ // Persist both properties, always, and derive the legacy boolean from the
mode rather than
+ // letting the caller supply it — the same invariant
HoodieTableMetaClient.TableBuilder enforces
+ // at creation. hoodie.properties can then never contradict itself, which
is what lets a
+ // pre-1.3.0 reader (which sees only the boolean) treat a selective table
as NONE instead of
+ // assuming meta columns that are physically null.
+ //
+ // The mode is written even for ALL and NONE rather than deleted. Deleting
it would leave the
+ // table resolving through the legacy fallback, which is indistinguishable
from a table that
+ // predates the property — and it would make this command's effect
invisible to anyone reading
+ // hoodie.properties. Writing it explicitly also keeps the value present
for the v10 -> v9
+ // downgrade handler, which reads the mode to derive the boolean it writes
back.
+ Properties toUpdate = new Properties();
+ toUpdate.setProperty(HoodieTableConfig.META_FIELDS_MODE.key(),
targetMode.name());
+ toUpdate.setProperty(HoodieTableConfig.POPULATE_META_FIELDS.key(),
+ String.valueOf(targetMode.toLegacyPopulateMetaFields()));
+ HoodieTableConfig.update(client.getStorage(), client.getMetaPath(),
toUpdate);
+
+ HoodieCLI.refreshTableMetadata();
+ Map<String, String> newProps =
HoodieCLI.getTableMetaClient().getTableConfig().propsMap();
+ return renderOldNewProps(newProps, oldProps);
+ }
+
@ShellMethod(key = "table change-table-type", value = "Change hudi table
type to target type: COW or MOR. "
+ "Note: before changing to COW, by default this command will execute
all the pending compactions and execute a full compaction if needed.")
public String changeTableType(
diff --git
a/hudi-cli/src/test/java/org/apache/hudi/cli/commands/TestTableCommand.java
b/hudi-cli/src/test/java/org/apache/hudi/cli/commands/TestTableCommand.java
index 67f60662eee9..d0a73785106d 100644
--- a/hudi-cli/src/test/java/org/apache/hudi/cli/commands/TestTableCommand.java
+++ b/hudi-cli/src/test/java/org/apache/hudi/cli/commands/TestTableCommand.java
@@ -26,6 +26,7 @@ import
org.apache.hudi.common.config.HoodieTimeGeneratorConfig;
import org.apache.hudi.common.fs.ConsistencyGuardConfig;
import org.apache.hudi.common.model.HoodieCommitMetadata;
import org.apache.hudi.common.model.HoodieTableType;
+import org.apache.hudi.common.model.MetaFieldsMode;
import org.apache.hudi.common.schema.HoodieSchema;
import org.apache.hudi.common.schema.HoodieSchemaUtils;
import org.apache.hudi.common.table.HoodieTableConfig;
@@ -57,6 +58,7 @@ import java.util.List;
import java.util.Map;
import static
org.apache.hudi.common.table.HoodieTableMetaClient.METAFOLDER_NAME;
+import static
org.apache.hudi.common.table.timeline.versioning.TimelineLayoutVersion.CURR_VERSION;
import static org.apache.hudi.common.util.StringUtils.fromUTF8Bytes;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
@@ -86,7 +88,8 @@ public class TestTableCommand extends
CLIFunctionalTestHarness {
tableName = tableName();
tablePath = tablePath(tableName);
metaPath = Paths.get(tablePath, METAFOLDER_NAME).toString();
- archivePath = Paths.get(metaPath,
HoodieTableConfig.TIMELINE_HISTORY_PATH.defaultValue()).toString();
+ archivePath = Paths.get(metaPath,
HoodieTableConfig.TIMELINE_PATH.defaultValue(),
+ HoodieTableConfig.TIMELINE_HISTORY_PATH.defaultValue()).toString();
}
/**
@@ -136,11 +139,11 @@ public class TestTableCommand extends
CLIFunctionalTestHarness {
// Test meta
HoodieTableMetaClient client = HoodieCLI.getTableMetaClient();
- assertEquals(archivePath, client.getArchivePath());
+ assertEquals(archivePath, client.getArchivePath().toString());
assertEquals(tablePath, client.getBasePath().toString());
assertEquals(metaPath, client.getMetaPath().toString());
assertEquals(HoodieTableType.COPY_ON_WRITE, client.getTableType());
- assertEquals(new Integer(1),
client.getTimelineLayoutVersion().getVersion());
+ assertEquals(CURR_VERSION, client.getTimelineLayoutVersion().getVersion());
HoodieTimeGeneratorConfig timeGeneratorConfig =
HoodieCLI.timeGeneratorConfig;
assertEquals(tablePath, timeGeneratorConfig.getBasePath());
@@ -251,7 +254,7 @@ public class TestTableCommand extends
CLIFunctionalTestHarness {
+ " \"name\" : \"val\",\n"
+ " \"type\" : [ \"null\", \"string\" ],\n"
+ " \"default\" : null\n"
- + " }]};";
+ + " }]}";
generateData(schemaStr);
@@ -304,4 +307,316 @@ public class TestTableCommand extends
CLIFunctionalTestHarness {
fis.close();
return fromUTF8Bytes(data);
}
+
+ //
---------------------------------------------------------------------------
+ // set-meta-fields-mode
+ //
---------------------------------------------------------------------------
+
+ @Test
+ public void testSetMetaFieldsModeOnFreshTableToCommitTimeOnly() {
+ assertTrue(prepareTable());
+ // Default table is ALL — no commits yet, so the safety check must let
this through.
+ Object result = shell.evaluate(() ->
+ "table set-meta-fields-mode --target-mode COMMIT_TIME_ONLY");
+ assertTrue(ShellEvaluationResultUtil.isSuccess(result));
+ // Rendered diff must surface the changed properties so the operator can
confirm the write.
+ String rendered = result.toString();
+ assertTrue(rendered.contains(HoodieTableConfig.POPULATE_META_FIELDS.key()),
+ "expected rendered diff to mention populate.meta.fields, got: " +
rendered);
+ assertTrue(rendered.contains(HoodieTableConfig.META_FIELDS_MODE.key()),
+ "expected rendered diff to mention meta.fields.mode, got: " +
rendered);
+ HoodieTableMetaClient client = HoodieCLI.getTableMetaClient();
+ assertEquals(MetaFieldsMode.COMMIT_TIME_ONLY,
client.getTableConfig().getMetaFieldsMode());
+ assertFalse(client.getTableConfig().populateMetaFields());
+ }
+
+ @Test
+ public void testSetMetaFieldsModeOnFreshTableToFileNameOnly() {
+ assertTrue(prepareTable());
+ Object result = shell.evaluate(() ->
+ "table set-meta-fields-mode --target-mode FILE_NAME_ONLY");
+ assertTrue(ShellEvaluationResultUtil.isSuccess(result));
+ assertEquals(MetaFieldsMode.FILE_NAME_ONLY,
+ HoodieCLI.getTableMetaClient().getTableConfig().getMetaFieldsMode());
+ }
+
+ @Test
+ public void testSetMetaFieldsModeOnFreshTableToCombinedMode() {
+ assertTrue(prepareTable());
+ Object result = shell.evaluate(() ->
+ "table set-meta-fields-mode --target-mode COMMIT_TIME_AND_FILE_NAME");
+ assertTrue(ShellEvaluationResultUtil.isSuccess(result));
+ assertEquals(MetaFieldsMode.COMMIT_TIME_AND_FILE_NAME,
+ HoodieCLI.getTableMetaClient().getTableConfig().getMetaFieldsMode());
+ }
+
+ @Test
+ public void testSetMetaFieldsModeOnFreshTableToNone() {
+ assertTrue(prepareTable());
+ Object result = shell.evaluate(() ->
+ "table set-meta-fields-mode --target-mode NONE");
+ assertTrue(ShellEvaluationResultUtil.isSuccess(result));
+ HoodieTableMetaClient client = HoodieCLI.getTableMetaClient();
+ assertEquals(MetaFieldsMode.NONE,
client.getTableConfig().getMetaFieldsMode());
+ assertFalse(client.getTableConfig().populateMetaFields());
+ }
+
+ @Test
+ public void testSetMetaFieldsModeToAllWritesTheModeExplicitly() throws
IOException {
+ assertTrue(prepareTable());
+ // First move to a selective mode, then back to ALL. Legal here only
because the table has no
+ // commits — on a populated table this would be a widening and refused
outright.
+ shell.evaluate(() -> "table set-meta-fields-mode --target-mode
COMMIT_TIME_ONLY");
+ Object result = shell.evaluate(() -> "table set-meta-fields-mode
--target-mode ALL");
+ assertTrue(ShellEvaluationResultUtil.isSuccess(result));
+
+ HoodieTableMetaClient client = HoodieCLI.getTableMetaClient();
+ assertEquals(MetaFieldsMode.ALL,
client.getTableConfig().getMetaFieldsMode());
+ assertTrue(client.getTableConfig().populateMetaFields());
+ // The mode is written explicitly rather than deleted. Deleting it would
leave the table
+ // resolving through the legacy fallback -- indistinguishable from a table
predating the
+ // property -- and would make this command's effect invisible in
hoodie.properties. The v10->v9
+ // downgrade handler also reads the mode to derive the boolean it writes
back.
+ assertEquals(MetaFieldsMode.ALL.name(),
+ client.getTableConfig().getString(HoodieTableConfig.META_FIELDS_MODE));
+ }
+
+ @Test
+ public void
testSetMetaFieldsModeRefusesWideningOnPopulatedTableEvenWithForce() throws
Exception {
+ assertTrue(prepareTable());
+ shell.evaluate(() -> "table set-meta-fields-mode --target-mode
COMMIT_TIME_ONLY");
+ createDummyCommitFile("20260101000000000");
+ HoodieCLI.refreshTableMetadata();
+
+ // Widening is one-way-forbidden and --force does not override it:
existing files are not
+ // rewritten, so the table would advertise a column that is null for every
row written so far,
+ // and incremental queries would then admit the table and silently skip
those rows. There is no
+ // consequence for an operator to knowingly accept, so this is a hard
failure.
+ for (String command : new String[] {
+ "table set-meta-fields-mode --target-mode ALL",
+ "table set-meta-fields-mode --target-mode ALL --force true",
+ "table set-meta-fields-mode --target-mode COMMIT_TIME_AND_FILE_NAME
--force true"}) {
+ Object result = shell.evaluate(() -> command);
+ assertFalse(ShellEvaluationResultUtil.isSuccess(result), "expected
refusal for: " + command);
+ assertTrue(result.toString().contains("widen"),
+ "expected a widening refusal for '" + command + "', got: " + result);
+ assertEquals(MetaFieldsMode.COMMIT_TIME_ONLY,
+ HoodieCLI.getTableMetaClient().getTableConfig().getMetaFieldsMode(),
+ "mode must be unchanged after a refused widening");
+ }
+ }
+
+ @Test
+ public void testSetMetaFieldsModeAllowsNarrowingOnPopulatedTableWithForce()
throws Exception {
+ assertTrue(prepareTable());
+ shell.evaluate(() -> "table set-meta-fields-mode --target-mode
COMMIT_TIME_AND_FILE_NAME");
+ createDummyCommitFile("20260101000000000");
+ HoodieCLI.refreshTableMetadata();
+
+ // Narrowing is the direction the CLI exists to allow. It still needs
--force, because it leaves
+ // mixed-mode files, but it is not refused outright the way widening is.
+ Object refused = shell.evaluate(() ->
+ "table set-meta-fields-mode --target-mode COMMIT_TIME_ONLY");
+ assertFalse(ShellEvaluationResultUtil.isSuccess(refused));
+
+ Object forced = shell.evaluate(() ->
+ "table set-meta-fields-mode --target-mode COMMIT_TIME_ONLY --force
true");
+ assertTrue(ShellEvaluationResultUtil.isSuccess(forced), "narrowing with
--force must succeed: " + forced);
+ assertEquals(MetaFieldsMode.COMMIT_TIME_ONLY,
+ HoodieCLI.getTableMetaClient().getTableConfig().getMetaFieldsMode());
+ }
+
+ @Test
+ public void testSetMetaFieldsModeAcceptsLowercaseTargetMode() {
+ assertTrue(prepareTable());
+ // Routed through MetaFieldsMode.resolve rather than valueOf, so an
operator typing the mode in
+ // lower case is not rejected.
+ Object result = shell.evaluate(() ->
+ "table set-meta-fields-mode --target-mode commit_time_only");
+ assertTrue(ShellEvaluationResultUtil.isSuccess(result), "expected
lowercase to be accepted: " + result);
+ assertEquals(MetaFieldsMode.COMMIT_TIME_ONLY,
+ HoodieCLI.getTableMetaClient().getTableConfig().getMetaFieldsMode());
+ }
+
+ @Test
+ public void testSetMetaFieldsModeKeepsBothPropertiesInAgreement() {
+ assertTrue(prepareTable());
+ // hoodie.properties must never contradict itself: the legacy boolean is
derived from the mode,
+ // never taken from the caller, so a pre-1.3.0 reader that sees only the
boolean treats a
+ // selective table as NONE rather than assuming meta columns that are
physically null.
+ for (MetaFieldsMode mode : MetaFieldsMode.values()) {
+ shell.evaluate(() -> "table set-meta-fields-mode --target-mode " +
mode.name() + " --force true");
+ HoodieTableConfig tableConfig =
HoodieCLI.getTableMetaClient().getTableConfig();
+ if (tableConfig.getMetaFieldsMode() == mode) {
+ assertEquals(mode.toLegacyPopulateMetaFields(),
tableConfig.populateMetaFields(),
+ "populate.meta.fields must be the derived value for mode " + mode);
+ }
+ }
+ }
+
+ @Test
+ public void testSetMetaFieldsModeNoOpWhenAlreadyInTargetMode() {
+ assertTrue(prepareTable());
+ Object first = shell.evaluate(() ->
+ "table set-meta-fields-mode --target-mode COMMIT_TIME_ONLY");
+ assertTrue(ShellEvaluationResultUtil.isSuccess(first));
+ // Second call — same target — should be a no-op message.
+ Object second = shell.evaluate(() ->
+ "table set-meta-fields-mode --target-mode COMMIT_TIME_ONLY");
+ assertTrue(ShellEvaluationResultUtil.isSuccess(second));
+ assertTrue(second.toString().contains("already in COMMIT_TIME_ONLY"),
+ "expected no-op message, got: " + second);
+ }
+
+ @Test
+ public void testSetMetaFieldsModeRejectsUnknownValue() {
+ assertTrue(prepareTable());
+ Object result = shell.evaluate(() ->
+ "table set-meta-fields-mode --target-mode BOGUS_MODE");
+ // Shell evaluate returns the exception object on failure.
+ assertFalse(ShellEvaluationResultUtil.isSuccess(result));
+ assertTrue(result.toString().contains("BOGUS_MODE"),
+ "expected error message to name the rejected value, got: " + result);
+ }
+
+ @Test
+ public void testSetMetaFieldsModeRefusesOnPopulatedTable() throws Exception {
+ assertTrue(prepareTable());
+ createDummyCommitFile("20260101000000000");
+ HoodieCLI.refreshTableMetadata();
+
+ Object result = shell.evaluate(() ->
+ "table set-meta-fields-mode --target-mode COMMIT_TIME_ONLY");
+ assertFalse(ShellEvaluationResultUtil.isSuccess(result));
+ assertTrue(result.toString().contains("Refusing to change") ||
result.toString().contains("--force"),
+ "expected refusal message, got: " + result);
+
+ // Mode must not have changed.
+ assertEquals(MetaFieldsMode.ALL,
+ HoodieCLI.getTableMetaClient().getTableConfig().getMetaFieldsMode());
+ }
+
+ @Test
+ public void testSetMetaFieldsModeWithForceOnPopulatedTable() throws
Exception {
+ assertTrue(prepareTable());
+ createDummyCommitFile("20260101000000000");
+ HoodieCLI.refreshTableMetadata();
+
+ Object result = shell.evaluate(() ->
+ "table set-meta-fields-mode --target-mode COMMIT_TIME_ONLY --force
true");
+ assertTrue(ShellEvaluationResultUtil.isSuccess(result));
+ assertEquals(MetaFieldsMode.COMMIT_TIME_ONLY,
+ HoodieCLI.getTableMetaClient().getTableConfig().getMetaFieldsMode());
+ }
+
+ /**
+ * Pins the whole migration matrix rather than spot-checking it. Twenty
ordered pairs, each either
+ * a legal narrowing or a refused widening, on a table that already has
commits.
+ *
+ * <p>The subtle entries are the mutually-wider siblings: {@code
COMMIT_TIME_ONLY} and
+ * {@code FILE_NAME_ONLY} each populate a column the other does not, so
neither can migrate to the
+ * other in either direction. A spot-check of "narrowing works, widening
does not" would miss that
+ * the relation is a lattice rather than a chain.
+ *
+ * <p>Uses a fresh table per pair so the starting mode can be set without
tripping the very guard
+ * under test -- setting the initial mode happens before any commit exists.
+ */
+ @Test
+ public void testSetMetaFieldsModeMigrationMatrixOnPopulatedTable() throws
Exception {
+ for (MetaFieldsMode from : MetaFieldsMode.values()) {
+ for (MetaFieldsMode to : MetaFieldsMode.values()) {
+ if (from == to) {
+ continue;
+ }
+ // Fresh table per pair; connect to it so HoodieCLI points at the
right one.
+ String pairName = tableName + "_" + from.name() + "_to_" + to.name();
+ String pairPath = tablePath(pairName);
+ assertTrue(ShellEvaluationResultUtil.isSuccess(
+ shell.evaluate(() -> "create --path " + pairPath + " --tableName "
+ pairName)));
+
+ // Establish the starting mode while the table is still empty, then
make it "populated".
+ assertTrue(ShellEvaluationResultUtil.isSuccess(
+ shell.evaluate(() -> "table set-meta-fields-mode --target-mode " +
from.name())),
+ "setting the initial mode on an empty table must succeed: " +
from);
+ createDummyCommitFileAt(pairPath, "20260101000000000");
+ HoodieCLI.refreshTableMetadata();
+ assertEquals(from,
HoodieCLI.getTableMetaClient().getTableConfig().getMetaFieldsMode());
+
+ boolean widening = to.isWiderThan(from);
+ Object result = shell.evaluate(() ->
+ "table set-meta-fields-mode --target-mode " + to.name() + "
--force true");
+
+ if (widening) {
+ assertFalse(ShellEvaluationResultUtil.isSuccess(result),
+ from + " -> " + to + " adds a meta column and must be refused
even with --force");
+ assertTrue(result.toString().contains("widen"),
+ "expected a widening refusal for " + from + " -> " + to + ",
got: " + result);
+ assertEquals(from,
HoodieCLI.getTableMetaClient().getTableConfig().getMetaFieldsMode(),
+ "a refused migration must leave the mode untouched: " + from + "
-> " + to);
+ } else {
+ assertTrue(ShellEvaluationResultUtil.isSuccess(result),
+ from + " -> " + to + " drops meta columns and must be allowed
with --force, got: " + result);
+ HoodieTableConfig tableConfig =
HoodieCLI.getTableMetaClient().getTableConfig();
+ assertEquals(to, tableConfig.getMetaFieldsMode(), from + " -> " +
to);
+ assertEquals(to.toLegacyPopulateMetaFields(),
tableConfig.populateMetaFields(),
+ "the derived boolean must follow the new mode for " + from + "
-> " + to);
+ }
+ }
+ }
+ }
+
+ /** Both mutually-wider directions between the two single-column modes are
refused. */
+ @Test
+ public void testSetMetaFieldsModeRefusesBothSiblingDirections() throws
Exception {
+ assertTrue(prepareTable());
+ shell.evaluate(() -> "table set-meta-fields-mode --target-mode
COMMIT_TIME_ONLY");
+ createDummyCommitFile("20260101000000000");
+ HoodieCLI.refreshTableMetadata();
+
+ Object toSibling = shell.evaluate(() ->
+ "table set-meta-fields-mode --target-mode FILE_NAME_ONLY --force
true");
+ assertFalse(ShellEvaluationResultUtil.isSuccess(toSibling),
+ "COMMIT_TIME_ONLY -> FILE_NAME_ONLY adds _hoodie_file_name and must be
refused");
+ assertEquals(MetaFieldsMode.COMMIT_TIME_ONLY,
+ HoodieCLI.getTableMetaClient().getTableConfig().getMetaFieldsMode());
+
+ // ...and the reverse, on a table that starts the other way round.
+ String otherName = tableName + "_sibling_reverse";
+ String otherPath = tablePath(otherName);
+ assertTrue(ShellEvaluationResultUtil.isSuccess(
+ shell.evaluate(() -> "create --path " + otherPath + " --tableName " +
otherName)));
+ shell.evaluate(() -> "table set-meta-fields-mode --target-mode
FILE_NAME_ONLY");
+ createDummyCommitFileAt(otherPath, "20260101000000000");
+ HoodieCLI.refreshTableMetadata();
+
+ Object toOther = shell.evaluate(() ->
+ "table set-meta-fields-mode --target-mode COMMIT_TIME_ONLY --force
true");
+ assertFalse(ShellEvaluationResultUtil.isSuccess(toOther),
+ "FILE_NAME_ONLY -> COMMIT_TIME_ONLY adds _hoodie_commit_time and must
be refused");
+ assertEquals(MetaFieldsMode.FILE_NAME_ONLY,
+ HoodieCLI.getTableMetaClient().getTableConfig().getMetaFieldsMode());
+ }
+
+ private void createDummyCommitFileAt(String tableBasePath, String
instantTime) throws IOException {
+ java.nio.file.Path timelineDir =
+ Paths.get(tableBasePath, METAFOLDER_NAME, "timeline");
+ if (!timelineDir.toFile().exists()) {
+ timelineDir.toFile().mkdirs();
+ }
+ String completionTime = instantTime + "1";
+ java.nio.file.Files.createFile(timelineDir.resolve(instantTime + "_" +
completionTime + ".commit"));
+ }
+
+ private void createDummyCommitFile(String instantTime) throws IOException {
+ // Timeline v2 layout: files live under .hoodie/timeline/. Writing an
empty completed commit
+ // is enough to make countInstants > 0 for the safety check.
+ java.nio.file.Path timelineDir = Paths.get(metaPath, "timeline");
+ if (!timelineDir.toFile().exists()) {
+ timelineDir.toFile().mkdirs();
+ }
+ // Completed-commit filename in v2 uses <requested>_<completed>.commit;
the completion time is
+ // used for range queries. Any monotonically-later value works for a
synthetic commit.
+ String completionTime = instantTime + "1";
+ java.nio.file.Files.createFile(timelineDir.resolve(instantTime + "_" +
completionTime + ".commit"));
+ }
}
diff --git
a/hudi-cli/src/test/java/org/apache/hudi/cli/testutils/HoodieTestCommitMetadataGenerator.java
b/hudi-cli/src/test/java/org/apache/hudi/cli/testutils/HoodieTestCommitMetadataGenerator.java
index 83d1af7dde10..3a15b231ddcf 100644
---
a/hudi-cli/src/test/java/org/apache/hudi/cli/testutils/HoodieTestCommitMetadataGenerator.java
+++
b/hudi-cli/src/test/java/org/apache/hudi/cli/testutils/HoodieTestCommitMetadataGenerator.java
@@ -114,7 +114,11 @@ public class HoodieTestCommitMetadataGenerator extends
HoodieTestDataGenerator {
}
static <T> void createFileWithMetadata(String basePath,
StorageConfiguration<?> configuration, String name, T metadata) throws
IOException {
- Path commitFilePath = new Path(basePath + "/" +
HoodieTableMetaClient.METAFOLDER_NAME + "/" + name);
+ HoodieTableMetaClient metaClient = HoodieTableMetaClient.builder()
+ .setConf(configuration)
+ .setBasePath(basePath)
+ .build();
+ Path commitFilePath = new Path(metaClient.getTimelinePath().toString(),
name);
try (OutputStream os = HadoopFSUtils.getFs(basePath,
configuration).create(commitFilePath, true)) {
COMMIT_METADATA_SER_DE.getInstantWriter(metadata).get().writeToStream(os);
}