Skip to content

feat(writer): add hoodie.meta.fields.mode for selective meta-field population on CoW tables - #19205

Open
nsivabalan wants to merge 13 commits into
apache:masterfrom
nsivabalan:selective_meta_fields_mode_pr_a
Open

feat(writer): add hoodie.meta.fields.mode for selective meta-field population on CoW tables#19205
nsivabalan wants to merge 13 commits into
apache:masterfrom
nsivabalan:selective_meta_fields_mode_pr_a

Conversation

@nsivabalan

Copy link
Copy Markdown
Contributor

Describe the issue this Pull Request addresses

Today the only way to opt out of populating Hudi's five meta columns is the all-or-nothing hoodie.populate.meta.fields=false. That saves storage but disables incremental queries (which require _hoodie_commit_time) and file-level pruning / investigation lookups (which need _hoodie_file_name).

A community user surfaced this trade-off (#18383, also discussed at #17959). This PR proposes a scoped, list-based property that lets a table opt into _hoodie_commit_time and/or _hoodie_file_name without paying for the remaining three meta columns.

This PR supersedes #19047 (the interim hoodie.meta.fields.commit.time.enabled boolean approach). Closes #18383.

Summary and Changelog

Adds a new table property hoodie.meta.fields.mode — a comma-separated list of meta columns to populate when hoodie.populate.meta.fields=false. Allowed tokens are _hoodie_commit_time and _hoodie_file_name; any other token is rejected up-front. The mode is immutable at runtime (settable only at table creation, via hudi-cli, or during upgrade).

Resulting modes:

`populate.meta.fields` `meta.fields.mode` Effective mode
`true` (default) ignored ALL — today's default
`false` empty NONE — today's minimal-meta-fields
`false` `_hoodie_commit_time` COMMIT_TIME_ONLY
`false` `_hoodie_file_name` FILE_NAME_ONLY
`false` `_hoodie_commit_time,_hoodie_file_name` COMMIT_TIME_AND_FILE_NAME
`true` non-empty rejected at writer init (ambiguous)

Why a list instead of a boolean or enum

  • Extensible without another config flag — adding a third opt-in field later means changing the allowed set, not adding a new property.
  • Empty default preserves bit-identical backward compat — every existing table on disk resolves to ALL or NONE without any new property being read.
  • Pre-1.3.0 readers see `populate.meta.fields=false` and treat the table as NONE — they cannot do incremental queries on the table, but they don't produce silent wrong results either.
  • Structurally encodes "additive on NONE" — most code paths that branch on `populate.meta.fields` keep working unchanged; only paths that specifically need commit_time / file_name consult the new accessors.

Plug points

Config + accessors (`hudi-common` / `hudi-client-common`):

  • New `HoodieTableConfig.META_FIELDS_MODE` property + static `parseMetaFieldsMode(String)` helper.
  • New predicates: `isCommitTimePopulated()`, `isFileNamePopulated()`, `isRecordKeyPopulated()`.
  • `HoodieWriteConfig` pass-through accessors + `Builder.withMetaFieldsMode(String)`.
  • `HoodieWriteConfig.validate()` rejects:
    • unknown token in the mode list (parser throws),
    • `populate=true` + non-empty mode (ambiguous),
    • `MERGE_ON_READ` + non-empty mode (log-write path not yet wired; follow-up),
    • non-Spark engine + non-empty mode (Flink RowData / Java-client not yet wired; follow-up).
  • `HoodieTableMetaClient.TableBuilder.setMetaFieldsMode(String)` persists the mode on `hoodie.properties` at table init.
  • `HoodieSparkSqlWriter` wires both fresh-table and bootstrap creation paths.

Writer engines:

  • `HoodieAvroParquetWriter`, `HoodieSparkParquetWriter`, `HoodieRowCreateHandle` each take a `Set metaFieldsMode` and populate columns selectively. Bloom filter / record-key index registration is intentionally skipped when the record-key column is not populated.

Read path (incremental query rejection):

  • `IncrementalRelationV1/V2` (CoW) now check `isCommitTimePopulated()` rather than `populateMetaFields()` — COMMIT_TIME_ONLY tables are accepted, NONE tables remain rejected with a clearer message.
  • `MergeOnReadIncrementalRelationV1/V2` (MoR) keep the strict `populateMetaFields()` guard until the MoR log-write path is updated. Selective modes are CoW-only in this release; MoR support is a follow-up.

Runtime immutability guard (`HoodieWriterUtils.validateTableConfig`):

  • Explicit null-on-disk → non-empty-in-write-options transition on the mode property is now blocked with a message directing the user to CLI / upgrade. The existing loop only flagged the mismatch when the on-disk value was non-null, which let a silent-drop path slip through for older tables.

Scope

  • ✅ Spark Avro / Spark Row writer paths.
  • ✅ Spark Parquet bulk-insert.
  • ✅ CoW incremental query rejection logic across V1 / V2.
  • ❌ MoR — `HoodieAppendHandle.populateMetadataFields` is not yet updated for the new mode, so MoR + non-empty mode is rejected at writer init. Tracked as a follow-up PR.
  • ❌ Flink RowData / Java-client writers — out of scope for this patch; the writer init rejects non-empty mode with a clear message. Tracked as a follow-up PR.
  • ❌ ORC / HFile writers — ORC continues to populate all meta fields unconditionally (legacy behavior); HFile is used only by MDT which is always ALL.

Impact

  • Storage layout: no change for tables that don't opt in. New optional mode for tables that do. Default behavior unchanged.
  • API: no public API breakage. New table property, new accessors, new builder method — all additive.
  • Configuration:
    • `hoodie.meta.fields.mode` (default `""`). Only meaningful when `hoodie.populate.meta.fields=false`. Persisted on `hoodie.properties` at table init. Immutable at runtime.
  • Performance: writer hot path adds one Set membership check per row per opt-in field. The set is parsed once in the writer constructor.
  • Forward-compat: pre-1.3.0 readers ignore the new property and treat the table as NONE — no silent wrong results.

Risk Level

low

Additive change with a narrow scope. The default path is untouched. Fail-fast validation at writer init rejects every ambiguous combination loudly. Runtime property changes are blocked.

Documentation Update

  • New config `hoodie.meta.fields.mode` documented via `@ConfigProperty` annotation on `HoodieTableConfig`.
  • If the website page on meta fields exists, a separate docs PR will add the new mode table.

Contributor's checklist

  • Read through contributor's guide
  • Change Logs and Impact were stated clearly
  • Adequate tests were added if applicable
  • CI passed

🤖 Generated with Claude Code

@github-actions github-actions Bot added the size:L PR with lines of changes in (300, 1000] label Jul 6, 2026

@nsivabalan nsivabalan left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we confirm that we can update meta fields mode value on the fly during writes.
and do we have tests across HoodieStreamer, spark datasource writes towards this.

if (populateCommitTime) {
metaFields[HoodieRecord.COMMIT_TIME_METADATA_FIELD_ORD] = shouldPreserveHoodieMetadata
? row.getUTF8String(HoodieRecord.COMMIT_TIME_METADATA_FIELD_ORD) : commitTime;
metaFields[HoodieRecord.COMMIT_SEQNO_METADATA_FIELD_ORD] = shouldPreserveHoodieMetadata

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why did we include COMMIT_SEQNO_METADATA_FIELD_ORD also here?
we wanted only the commit time meta field.. since thats whats is required for incremental queries.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in cfd1fea706ed: dropped _hoodie_commit_seqno population from the selective path. Only _hoodie_commit_time is populated. Downstream consumers that assume seqno is populated whenever commit_time is (if any exist) are out of scope for this PR — the seqno column simply stays null in COMMIT_TIME_ONLY / COMMIT_TIME_AND_FILE_NAME. Rebased on latest upstream/master.

} else if (populateCommitTime || populateFileName) {
if (populateCommitTime) {
row.update(COMMIT_TIME_METADATA_FIELD.ordinal(), instantTime);
row.update(COMMIT_SEQNO_METADATA_FIELD.ordinal(),

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same comment as above.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in cfd1fea706ed — same as above. Seqno population dropped from the selective path in HoodieSparkParquetWriter#writeRowWithMetadata.

@@ -89,7 +89,7 @@ protected HoodieFileWriter newParquetFileWriter(
hoodieConfig.getLongOrDefault(HoodieStorageConfig.PARQUET_MAX_FILE_SIZE),
storageConfiguration, hoodieConfig.getDoubleOrDefault(HoodieStorageConfig.PARQUET_COMPRESSION_RATIO_FRACTION),
hoodieConfig.getBooleanOrDefault(HoodieStorageConfig.PARQUET_DICTIONARY_ENABLED));
return new HoodieAvroParquetWriter(path, parquetConfig, instantTime, taskContextSupplier, populateMetaFields);
return new HoodieAvroParquetWriter(path, parquetConfig, instantTime, taskContextSupplier, populateMetaFields, metaFieldsMode);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we not fold both populateMetaFields arg and metaFieldsMode into one argument. do we really need both?
not just here. in all writer factory and other places, where we are adding metaFieldsMode as new arg in addition to populateMetaFields.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in cfd1fea706ed: folded populateMetaFields + metaFieldsMode into a single MetaFieldsMode enum (ALL / NONE / COMMIT_TIME_ONLY / FILE_NAME_ONLY / COMMIT_TIME_AND_FILE_NAME). The enum is now the sole meta-field argument passed through every writer factory and writer constructor. populate.meta.fields remains on the config surface for backward compat and is auto-derived from the enum. The predicate helpers isCommitTimePopulated() / isFileNamePopulated() on the enum are used in write branches. Also note: the follow-up CLI PR (#19206) uses the same enum for the table set-meta-fields-mode command surface.

// population is intentionally skipped — that requires the record-key column.
GenericRecord genericRecord = (GenericRecord) avroRecord;
if (populateCommitTime) {
String seqId = HoodieRecord.generateSequenceId(instantTime,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we don't need to add commit seq no

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in cfd1fea706ed — same as above. Seqno population dropped from the selective path in HoodieAvroParquetWriter#writeAvroWithMetadata.

throw new HoodieException("Incremental queries are not supported when meta fields are disabled")
if (!metaClient.getTableConfig.isCommitTimePopulated()) {
throw new HoodieException("Incremental queries are not supported when _hoodie_commit_time is not populated. "
+ "Either keep hoodie.populate.meta.fields=true or include _hoodie_commit_time in hoodie.meta.fields.mode.")

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lets also call out that, a table once disabled for commit time, can never be switched back. So, users got to re-create a new table.
just setting these writer props during next write may not suffice.

lets capture this in the msg.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in cfd1fea706ed: extended the error message in IncrementalRelationV1 / IncrementalRelationV2 to state that hoodie.meta.fields.mode is a physical-storage decision baked into files at write time — it cannot be flipped by changing writer options later, and a table that was created without _hoodie_commit_time populated must be recreated to enable incremental queries. The runtime-immutability guard in HoodieWriterUtils.validateTableConfig blocks silent flips (both null → non-empty and non-empty → different) with a message pointing at the CLI / upgrade path. The follow-up CLI PR (#19206) adds table set-meta-fields-mode as the sanctioned mutation path with a --force guard for tables that already have commits.

nsivabalan added a commit to nsivabalan/hudi that referenced this pull request Jul 6, 2026
…enum

Addresses review feedback on apache#19205:
- Fold (populateMetaFields boolean, comma-separated mode) into a single MetaFieldsMode enum
  (ALL / NONE / COMMIT_TIME_ONLY / FILE_NAME_ONLY / COMMIT_TIME_AND_FILE_NAME). The enum is
  the sole argument passed through every writer factory and writer constructor. populate.meta.fields
  stays on the config surface for backward compat and is auto-derived from the enum.
- On-disk representation is now the enum name (e.g. "COMMIT_TIME_ONLY") instead of a
  comma-separated token list. Existing tables without the property continue to fall back to
  ALL or NONE based on the legacy hoodie.populate.meta.fields boolean.
- Drop _hoodie_commit_seqno population from the selective write path — only _hoodie_commit_time
  is needed for incremental queries. (Review comments on HoodieRowCreateHandle:193,
  HoodieSparkParquetWriter:100, HoodieAvroParquetWriter:103.)
- Extend the IncrementalRelation error message to state that hoodie.meta.fields.mode is
  physical-storage and cannot be flipped by changing write options — existing tables must
  be recreated to change it.
- StreamSync.initializeEmptyTable() was missing the mode pass-through — wired via
  setMetaFieldsModeFromString. All other non-test initTable call sites either use
  fromProperties() (which now routes the mode through) or intentionally don't touch meta
  fields (CLI table create, MDT, sample writes).

Test coverage additions:
- TestMetaFieldsMode (hudi-spark): end-to-end assertions read parquet back and verify
  which meta columns are populated / null per mode. Covers row-writer path (bulk_insert
  with row.writer.enable=true, default) AND non-row-writer path (insert with
  row.writer.enable=false). Adds inline-clustering coverage for all 5 modes verifying that
  clustered files preserve the mode's column population semantics.
- TestHoodieStreamerMetaFieldsMode (hudi-utilities): parameterized end-to-end test through
  the HoodieStreamer entrypoint covering all 5 modes plus MoR+selective rejection.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@github-actions github-actions Bot added size:XL PR with lines of changes > 1000 and removed size:L PR with lines of changes in (300, 1000] labels Jul 6, 2026
@nsivabalan
nsivabalan force-pushed the selective_meta_fields_mode_pr_a branch from cfd1fea to 7b139c9 Compare July 13, 2026 18:12
nsivabalan added a commit to nsivabalan/hudi that referenced this pull request Jul 13, 2026
…enum

Addresses review feedback on apache#19205:
- Fold (populateMetaFields boolean, comma-separated mode) into a single MetaFieldsMode enum
  (ALL / NONE / COMMIT_TIME_ONLY / FILE_NAME_ONLY / COMMIT_TIME_AND_FILE_NAME). The enum is
  the sole argument passed through every writer factory and writer constructor. populate.meta.fields
  stays on the config surface for backward compat and is auto-derived from the enum.
- On-disk representation is now the enum name (e.g. "COMMIT_TIME_ONLY") instead of a
  comma-separated token list. Existing tables without the property continue to fall back to
  ALL or NONE based on the legacy hoodie.populate.meta.fields boolean.
- Drop _hoodie_commit_seqno population from the selective write path — only _hoodie_commit_time
  is needed for incremental queries. (Review comments on HoodieRowCreateHandle:193,
  HoodieSparkParquetWriter:100, HoodieAvroParquetWriter:103.)
- Extend the IncrementalRelation error message to state that hoodie.meta.fields.mode is
  physical-storage and cannot be flipped by changing write options — existing tables must
  be recreated to change it.
- StreamSync.initializeEmptyTable() was missing the mode pass-through — wired via
  setMetaFieldsModeFromString. All other non-test initTable call sites either use
  fromProperties() (which now routes the mode through) or intentionally don't touch meta
  fields (CLI table create, MDT, sample writes).

Test coverage additions:
- TestMetaFieldsMode (hudi-spark): end-to-end assertions read parquet back and verify
  which meta columns are populated / null per mode. Covers row-writer path (bulk_insert
  with row.writer.enable=true, default) AND non-row-writer path (insert with
  row.writer.enable=false). Adds inline-clustering coverage for all 5 modes verifying that
  clustered files preserve the mode's column population semantics.
- TestHoodieStreamerMetaFieldsMode (hudi-utilities): parameterized end-to-end test through
  the HoodieStreamer entrypoint covering all 5 modes plus MoR+selective rejection.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@nsivabalan

Copy link
Copy Markdown
Contributor Author

Addressed the round of self-review comments in cfd1fea706ed (branch now rebased on latest upstream/master, force-pushed):

  1. Fold populateMetaFields + metaFieldsMode into one argument — done. Introduced MetaFieldsMode enum (ALL / NONE / COMMIT_TIME_ONLY / FILE_NAME_ONLY / COMMIT_TIME_AND_FILE_NAME) as the sole meta-field argument through every writer factory / constructor. populate.meta.fields stays on the config surface for backward compat and is auto-derived. On-disk representation is now the enum name.
  2. Drop _hoodie_commit_seqno from selective path — done in HoodieRowCreateHandle, HoodieSparkParquetWriter, HoodieAvroParquetWriter. Only _hoodie_commit_time is populated when opted in.
  3. Non-reversibility message on IncrementalRelation — done: extended message states the mode is physical-storage, cannot be flipped by write options, table must be recreated. The follow-up CLI PR #19206 adds table set-meta-fields-mode as the sanctioned mutation path with --force guarding tables that already have commits.

Re: "can we confirm the mode is immutable at runtime + tests across HoodieStreamer / spark datasource"

  • Runtime-immutability guard: HoodieWriterUtils.validateTableConfig now blocks both null → non-empty and non-empty → different transitions on hoodie.meta.fields.mode, with an error message pointing at the CLI / upgrade path (previously the loop only flagged the mismatch when the on-disk value was non-null, which let a silent-drop path slip through for older tables).
  • HoodieStreamer coverage: new TestHoodieStreamerMetaFieldsMode under hudi-utilities/src/test/java/.../deltastreamer/ — parameterized end-to-end test through the HoodieStreamer entrypoint covering all 5 modes plus the MoR+selective rejection assertion.
  • Spark datasource coverage: new TestMetaFieldsMode under hudi-spark-datasource/hudi-spark/src/test/java/.../functional/ — end-to-end assertions that read the written parquet back and verify which meta columns are populated / null per mode. Covers row-writer path (bulk_insert with row.writer.enable=true, default) AND non-row-writer path (insert with row.writer.enable=false). Also adds inline-clustering coverage for all 5 modes verifying that clustered files preserve the mode.

nsivabalan added a commit to nsivabalan/hudi that referenced this pull request Jul 13, 2026
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 apache#19205 (hoodie.meta.fields.mode). PR-C (MoR support) is the next follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…pulation on CoW tables

Introduces `hoodie.meta.fields.mode` — a comma-separated list of meta columns to populate
when `hoodie.populate.meta.fields=false`. Allowed tokens are `_hoodie_commit_time` and
`_hoodie_file_name`; any other token is rejected up-front. The mode is immutable at runtime
(settable only at table creation, via hudi-cli, or during upgrade).

Motivation: the existing all-or-nothing `populate.meta.fields=false` disables incremental
queries (needs `_hoodie_commit_time`) and file-level pruning / debugging lookups
(needs `_hoodie_file_name`). This lets a table opt into either or both without paying for
the remaining three meta columns.

Resulting modes:
- populate.meta.fields=true                   → ALL (default; mode list ignored)
- populate.meta.fields=false, mode=""         → NONE
- populate.meta.fields=false, mode=<subset>   → selective (COMMIT_TIME_ONLY /
                                                FILE_NAME_ONLY / COMMIT_TIME_AND_FILE_NAME)
- populate.meta.fields=true,  mode=<subset>   → rejected at writer init

Scope in this patch:
- Spark writer path (Avro + Row): HoodieAvroParquetWriter, HoodieSparkParquetWriter,
  HoodieRowCreateHandle take a Set<String> meta-fields mode and populate the columns
  selectively; bloom filter / record-key index registration stays skipped when record key
  is not populated.
- Incremental read path: CoW IncrementalRelationV1/V2 accept the new mode via
  isCommitTimePopulated(); MoR incremental relations keep the strict populate=true guard
  until the MoR log-write path is updated in a follow-up.

Fail-fast at writer init:
- Unknown token in the mode list (parser throws).
- populate.meta.fields=true combined with a non-empty mode (ambiguous).
- MERGE_ON_READ + non-empty mode (log-write path not yet wired; follow-up).
- Non-Spark engine + non-empty mode (Flink RowData / Java-client not yet wired; follow-up).

Runtime immutability:
- HoodieWriterUtils.validateTableConfig now explicitly rejects a null-on-disk → non-empty
  transition on the mode property (existing loop only flagged the mismatch when the on-disk
  value was non-null, which let this silent-drop path slip through).

Tests:
- TestHoodieMetaFieldsMode (hudi-hadoop-common) — accessor coverage for every combination
  plus unknown-token rejection and whitespace tolerance.
- TestHoodieWriteConfigMetaFieldsMode (hudi-client-common) — builder + validate() coverage.
- TestMetaFieldsMode (hudi-spark) — end-to-end write / re-read for every mode plus the
  three rejection paths (populate=true+mode, unknown token, MoR+mode).
…enum

Addresses review feedback on apache#19205:
- Fold (populateMetaFields boolean, comma-separated mode) into a single MetaFieldsMode enum
  (ALL / NONE / COMMIT_TIME_ONLY / FILE_NAME_ONLY / COMMIT_TIME_AND_FILE_NAME). The enum is
  the sole argument passed through every writer factory and writer constructor. populate.meta.fields
  stays on the config surface for backward compat and is auto-derived from the enum.
- On-disk representation is now the enum name (e.g. "COMMIT_TIME_ONLY") instead of a
  comma-separated token list. Existing tables without the property continue to fall back to
  ALL or NONE based on the legacy hoodie.populate.meta.fields boolean.
- Drop _hoodie_commit_seqno population from the selective write path — only _hoodie_commit_time
  is needed for incremental queries. (Review comments on HoodieRowCreateHandle:193,
  HoodieSparkParquetWriter:100, HoodieAvroParquetWriter:103.)
- Extend the IncrementalRelation error message to state that hoodie.meta.fields.mode is
  physical-storage and cannot be flipped by changing write options — existing tables must
  be recreated to change it.
- StreamSync.initializeEmptyTable() was missing the mode pass-through — wired via
  setMetaFieldsModeFromString. All other non-test initTable call sites either use
  fromProperties() (which now routes the mode through) or intentionally don't touch meta
  fields (CLI table create, MDT, sample writes).

Test coverage additions:
- TestMetaFieldsMode (hudi-spark): end-to-end assertions read parquet back and verify
  which meta columns are populated / null per mode. Covers row-writer path (bulk_insert
  with row.writer.enable=true, default) AND non-row-writer path (insert with
  row.writer.enable=false). Adds inline-clustering coverage for all 5 modes verifying that
  clustered files preserve the mode's column population semantics.
- TestHoodieStreamerMetaFieldsMode (hudi-utilities): parameterized end-to-end test through
  the HoodieStreamer entrypoint covering all 5 modes plus MoR+selective rejection.
@nsivabalan
nsivabalan force-pushed the selective_meta_fields_mode_pr_a branch from 7850790 to 362c7ba Compare July 19, 2026 16:49
@nsivabalan

Copy link
Copy Markdown
Contributor Author

Rebased onto latest master (2026-07-19) and folded the CI-fix tweaks into their natural parent commits. Summary of what changed since last CI:

Test fixes bundled into existing commits:

  • TestHoodieTableConfig.testDefinedTableConfigs — bumped expected size from 45 → 46 to account for the new hoodie.meta.fields.mode config. Folded into the feat commit.
  • TestHoodieSparkSqlWriter.testBulkInsertForPopulateMetaFields / testDisableAndEnableMetaFields (plus the WithTestFormat mirror) — updated the null-vs-empty-string assertion. With the nullable meta-column stub fix, the columns now materialize as null instead of ""; the assertion was checking Row.mkString(",") != "" which returned "null" for a null cell. Switched to !entry.isNullAt(0) && entry.getString(0).nonEmpty, which matches the same pattern used elsewhere in the file. Folded into the row-writer stub-nullable fix commit.
  • TestHoodieStreamerMetaFieldsMode — the parquet glob was */*.parquet but HoodieTestDataGenerator's default partitions are YYYY/MM/DD (three levels). Updated glob to */*/*/*.parquet. Folded into the refactor commit that introduced the test.

Ignored flake:

  • TestIncrementalQueries.testIncrementalQueryWithMultiCommitsInSameFile[3] failed once ("Got an invalid instant") and passed on retry (Run 2: PASS). Looks like a timestamp-generation edge case unrelated to this PR.

Self-review discussion points — flagging for reviewer input rather than force-committing:

  1. Selective-mode column-population is repeated in three writer pathsHoodieRowCreateHandle, HoodieSparkParquetWriter, HoodieAvroParquetWriter each open-code if (mode.isCommitTimePopulated()) { ... } if (mode.isFileNamePopulated()) { ... }. It's three lines of trivial logic per site, but if the enum grows another selective column, all three drift. Options: (a) leave as-is (perf-hot path, no shared context), (b) add a SelectiveMetaFieldsWriter#applyTo(record, commitTime, fileName) helper on the enum, (c) push the logic into a MetaFieldPopulator strategy. I'm inclined to (a) with a // mirror in HoodieAvroParquetWriter and HoodieSparkParquetWriter comment on the first site; happy to switch to (b) if you prefer. WDYT?

  2. Error message wording in HoodieTableMetaClient.TableBuilder (lines 1554–1564) — the current message uses "Set populate.meta.fields=false or use MetaFieldsMode.ALL", which reads like a runtime toggle. Since the mode is immutable-per-table, would you prefer wording that emphasizes the config conflict (e.g., "META_FIELDS_MODE=… conflicts with populate.meta.fields=true; choose one, not both")?

  3. populateMetaFields=false + no explicit mode → NONE (backward-compat path) — covered indirectly by noneModePersistsAndLeavesAllColumnsNull in TestMetaFieldsMode, but there's no test explicitly asserting the on-disk hoodie.properties has no hoodie.meta.fields.mode key set in that case. Worth adding a targeted assertion or is the indirect coverage enough?

Not addressing the interaction with RLI / secondary index in this PR — that lives in the MoR follow-up (#19206 / phase 3) where the same fail-fast check applies to both CoW and MoR.

nsivabalan added a commit to nsivabalan/hudi that referenced this pull request Jul 19, 2026
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 apache#19205 (hoodie.meta.fields.mode). PR-C (MoR support) is the next follow-up.
Previously the populateMetaFields=false branch of HoodieDatasetBulkInsertHelper
projected the five Hudi meta columns as Alias(Literal(UTF8String.EMPTY_UTF8,
StringType), name). A non-null Literal derives a non-null Alias, so the
resulting StructField had nullable=false. Under ALL mode this was harmless
because HoodieRowCreateHandle.writeRow unconditionally fills every meta slot
with a non-null value. Once selective / NONE modes started leaving the
opted-out columns null on the row (per hoodie.meta.fields.mode), the
non-nullable StructType led SparkToParquetSchemaConverter to declare the
columns as `required binary` on disk, and any subsequent read failed with
ParquetDecodingException: could not read bytes at offset 0.

Switch the stub to Alias(Literal.create(null, StringType), name) so the
projected StructField is nullable and the physical parquet column is written
as OPTIONAL. Handle-level population (writeRow / writeRowNoMetaFields /
writeRowSelectiveMetaFields) is unchanged — the stub value is never observed
on disk; the fix only affects the parquet schema.

Fixes all row-writer + clustering selective-mode failures in TestMetaFieldsMode
(commitTimeOnly / fileNameOnly / commitTimeAndFileName / none, plus clustering
variants). 18/18 tests pass locally.
…elds modes

TableSchemaResolver.getTableSchema() and getTableSchema(String timestamp) decided
whether to include the five Hudi meta columns in the projected schema based on
populateMetaFields(). Under the selective modes introduced in this PR
(COMMIT_TIME_ONLY / FILE_NAME_ONLY / COMMIT_TIME_AND_FILE_NAME), populateMetaFields()
returns false but the opted-in meta columns are still physically present in every
Parquet file. The old logic silently stripped those columns from the read schema,
so downstream consumers (notably the incremental relations, whose range filter
projects _hoodie_commit_time) saw no meta columns at all and produced zero-row
reads.

Switch the decision to "include meta fields whenever the table's mode is not
NONE." That preserves the previous behaviour for the two originally supported
modes (ALL -> true, NONE -> false) and correctly turns the flag on for the three
new selective modes.

No schema change on disk; this only changes what the read side projects.
@nsivabalan
nsivabalan force-pushed the selective_meta_fields_mode_pr_a branch from 362c7ba to 13b39db Compare July 19, 2026 18:07
nsivabalan added a commit to nsivabalan/hudi that referenced this pull request Jul 19, 2026
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 apache#19205 (hoodie.meta.fields.mode). PR-C (MoR support) is the next follow-up.
@nsivabalan

Copy link
Copy Markdown
Contributor Author

CI caught one more null-vs-empty-string test I missed in the last pass — same class of fix as the earlier TestHoodieSparkSqlWriter update. TestHoodieDatasetBulkInsertHelper.testBulkInsertHelperNoMetaFields NPE'd on entry.get(...).equals("") because our nullable-stub change now writes null instead of an empty string.

Switched the assertions from .get(...).equals("") to entry.isNullAt(...), folded into the row-writer nullable-stub commit. Also re-rebased #19206 and the local phase-3 branch onto the updated head so they pick up the fix.

@danny0405 danny0405 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. why we need two options populate.meta.fields and meta.fields.mode to control the population? just meta.fields.mode should be enough, composite the two is super confusing; can we deprecate populate.meta.fields and replace it with meta.fields.mode, the old populate.meta.fields true and false can be replaced with meta.fields.mode ALL and NONE.
  2. do we really need FILE_NAME_ONLY and COMMIT_TIME_AND_FILE_NAME mode? in which case the file name needs to be queried? even it was needed, it can be inferred from the file name on query side?

@cshuo

cshuo commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator
  1. why we need two options populate.meta.fields and meta.fields.mode to control the population? just meta.fields.mode should be enough, composite the two is super confusing; can we deprecate populate.meta.fields and replace it with meta.fields.mode, the old populate.meta.fields true and false can be replaced with meta.fields.mode ALL and NONE.

+1 for deprecating populate.meta.fields and use meta.fields.mode only. populate.meta.fields is kept only for compatibility and can be translated to meta field mode, which will make the logic much clearer.

@nsivabalan

Copy link
Copy Markdown
Contributor Author

One behavior change worth calling out explicitly rather than letting it ride in as a test-assertion update, since it affects tables that never opted into this feature:

The nullable meta-column stub fix (77f4e91, HoodieDatasetBulkInsertHelper.scala) switches the row-writer bulk-insert stubs from Literal(UTF8String.EMPTY_UTF8) to Literal.create(null, StringType). The change is required — the columns must be OPTIONAL in Parquet for selective / NONE modes to hold nulls — but it also applies to existing hoodie.populate.meta.fields=false tables, whose meta columns will now materialize as SQL NULL instead of "".

Downstream consumers filtering on col = '' would silently stop matching. The three updated assertions in TestHoodieSparkSqlWriter, TestHoodieSparkSqlWriterWithTestFormat and TestHoodieDatasetBulkInsertHelper are the evidence this is observable end-to-end.

Flagging so it's a deliberate sign-off rather than a surprise, and so it can go in the release notes. Happy to gate it behind the mode (keep "" for plain populate.meta.fields=false, null only under a selective mode) if reviewers would rather not change existing-table output at all — though that would mean two different null representations for the same logical "unpopulated" state, which seems worse to me.

* @deprecated since 1.3.0, use {@link #withMetaFieldsMode(MetaFieldsMode)} instead
* ({@code true} maps to {@link MetaFieldsMode#ALL}, {@code false} to {@link MetaFieldsMode#NONE}).
*/
@Deprecated

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

may not related with this patch, but setting a table config option though write config API does not look right, the table config option should be immutable once the table is created.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed on the principle, and you're right that it's pre-existing rather than introduced here — withPopulateMetaFields has set HoodieTableConfig.POPULATE_META_FIELDS through the write-config builder since well before this patch (it's on master today at the same line). withMetaFieldsMode follows the established pattern for the same property family, so I'd rather not diverge one config from its sibling inside this PR.

On the immutability concern specifically: the write config is the input to table creation, not a mutation channel for an existing table. Once a table exists, a differing value is rejected rather than applied — BaseHoodieWriteClient.validateAgainstTableProperties now compares the full MetaFieldsMode (strengthened in c266b82 for your other P1), and HoodieWriterUtils.validateTableConfig blocks the datasource path. So the builder can express an intended mode, but it cannot silently change one on disk.

That said, the broader cleanup you're pointing at — write-config builders shouldn't carry table-config setters at all — seems worth doing properly across all of them rather than piecemeal. Happy to file a follow-up JIRA for that sweep if you think it's worth tracking; it would cover the existing POPULATE_META_FIELDS setter too, which is the one with real callers today.

String instantTime,
TaskContextSupplier taskContextSupplier,
boolean populateMetaFields) throws IOException {
this(file, parquetConfig, instantTime, taskContextSupplier,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why add a new constuctor, seems not necessary?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right — removed in 7dcd9d1. That overload had no callers at all: the only construction site, HoodieSparkFileWriterFactory, already passes a MetaFieldsMode. I'd added it for symmetry with the Avro writer without checking whether anything needed it. Gone.

*/
public static MetaFieldsMode parse(String rawMode) {
try {
return MetaFieldsMode.valueOf(rawMode.trim());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could we make the parse case insensitive?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 7dcd9d1parse now upper-cases with Locale.ROOT before valueOf, so a hand-edited hoodie.properties or a write option doesn't have to match the enum's casing. TestHoodieMetaFieldsMode covers lower-case, mixed-case and whitespace-padded forms.

* includes the pre-enum comma-separated format — callers that upgrade an old table must
* migrate the value through the hudi-cli.
*/
public static MetaFieldsMode resolve(String rawMode, boolean legacyPopulateMetaFields) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is it possible we just pass around the HoodieConfig to simplify the API.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 9495687 — added MetaFieldsMode.resolve(HoodieConfig). Both HoodieTableConfig and HoodieWriteConfig extend HoodieConfig, so the one overload covers the write config and both writer factories, and the property keys plus the precedence rule now live in a single place instead of being repeated at every call site.

One deliberate exception: HoodieTableConfig.getMetaFieldsMode keeps the two-argument form. Its fallback has to read the raw populate.meta.fields property, whereas populateMetaFields() on that class is itself derived from the mode — so routing it through the config overload would be circular in intent even though it happens to work. Commented in place so it doesn't read as an oversight.

* {@link #POPULATE_META_FIELDS} boolean.
*/
public MetaFieldsMode getMetaFieldsMode() {
return MetaFieldsMode.resolve(getStringOrDefault(META_FIELDS_MODE), legacyPopulateMetaFields());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we do the inference/transformation(from legacy option to new) in one shot on table creation instead of reading the lecagy option each time?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mostly resolved by the P1 fix, and I think the remaining per-read resolution is worth keeping. Two parts:

Inference at creation — this is now what happens. 664ff2e makes TableBuilder write both properties consistently whenever a mode is supplied, deriving the legacy boolean from the mode. TableBuilder is the single chokepoint for every creation path (datasource, streamer, bootstrap), so a table created by 1.3+ always has the two in agreement on disk, and resolve() returns the mode branch without consulting the fallback.

Reading the legacy option each time — the fallback still runs for tables created before this property existed, which is exactly the case it's there for. I'd rather not cache the resolved value in a field: HoodieTableConfig is mutable after load (setValue / setAll / clearValue, used by upgrade paths and tests), so a cached enum would go stale on any property update. That's a real correctness hazard in exchange for avoiding a map lookup plus a valueOfgetMetaFieldsMode() isn't on a per-record path; it's consulted at writer/reader construction.

If what you have in mind is a one-time migration that rewrites old tables to carry the mode explicitly, that's the upgrade/downgrade work you raised in your other comment — I'll pick that up there rather than duplicating the mechanism here.

}

@SuppressWarnings({"unchecked", "rawtypes"})
public HoodieAvroParquetWriter(StoragePath file,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why add a new constructor

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This one I'd like to keep, though I've marked it deprecated in 7dcd9d1.

Unlike the Spark writer's equivalent — which I removed in the same commit after your other comment, since it had zero callers — this overload has a live production caller at SparkHelpers.scala:74 plus four test call sites (TestHoodieAvroParquetWriter, HoodieWriteableTestTable, TestSparkBinaryCopyClusteringAndValidationMeta, HoodieFileSliceTestUtils). All of them genuinely only distinguish all-or-nothing meta fields, so converting them to MetaFieldsMode.ALL / NONE would be churn without changing behavior.

It's now @Deprecated with a javadoc pointing at the MetaFieldsMode overload and noting it cannot express the selective modes, so new callers get steered correctly and the existing ones can migrate separately. Happy to convert them in this PR instead if you'd prefer the boolean gone entirely.

// selective modes (COMMIT_TIME_ONLY / FILE_NAME_ONLY / COMMIT_TIME_AND_FILE_NAME) the meta
// columns exist as physical nullable Parquet columns even though populateMetaFields() is false,
// and read paths (e.g. incremental relations) must see them in the projected schema.
return getTableSchema(metaClient.getTableConfig().getMetaFieldsMode() != MetaFieldsMode.NONE);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

so for selective modes, all the metadata fields are still in the shema, is it for compatibility purposes?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not compatibility — it reflects what is physically on disk.

Under a selective mode all five meta columns are still written to the Parquet file; the ones the mode doesn't populate simply hold null. HoodieDatasetBulkInsertHelper prepends nullable null stubs for every meta field, and HoodieRowCreateHandle / the parquet writers then fill only the opted-in ones. Parquet stores those nulls as definition-level flags, so the cost is bookkeeping bits rather than data — that's what makes a selective mode cheaper than ALL without needing a different column layout per mode.

Given the columns exist, the schema has to describe them. If getTableSchema() omitted them for selective modes, any reader projecting _hoodie_commit_time on a COMMIT_TIME_ONLY table would fail against a file that actually has the column — and incremental queries depend on exactly that projection. The predicate is mode != NONE rather than populateMetaFields() for that reason: populateMetaFields() is false for every selective mode, which would give the wrong answer here.

NONE is the genuinely different case: no meta columns are written, so none appear in the schema — same as today's populate.meta.fields=false.

One consequence worth stating explicitly, since it's a behavior change for existing tables: because the stubs are now nullable literals rather than empty strings, meta columns on populate.meta.fields=false tables materialize as SQL NULL instead of "". That's required for the columns to be OPTIONAL in Parquet, but it is visible to anything filtering on col = ''. I called it out in a separate comment on the PR so it gets a deliberate sign-off rather than riding in unnoticed.

* is still honored for tables written before {@code hoodie.meta.fields.mode} existed, but it is
* consulted only when the mode property is absent.
*/
@Deprecated

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't see upgrade/downgrade for this option migration, the compatibility handling for selective modes?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — there was none. Added in 4134dac.

Upgrade (9 → 10)NineToTenUpgradeHandler now records the mode derived from the legacy boolean (true -> ALL, false -> NONE). Version 9 tables predate the property, so this just makes the on-disk state explicit and matches what freshly created version 10 tables write; behavior is identical either way. It never rewrites populate.meta.fields.

Downgrade (10 → 9)TenToNineDowngradeHandler drops hoodie.meta.fields.mode, since version 9 cannot interpret it, and deliberately leaves hoodie.populate.meta.fields exactly as it stands. ALL and NONE therefore round-trip unchanged — those are precisely the two states the legacy boolean can express, so those tables are bit-identical afterwards.

Selective modes can't be represented in version 9. Rather than blocking the downgrade, it proceeds and logs a warning that the table will behave as NONE to version 9 readers and that incremental queries relying on the mode will stop returning rows. Already-written files keep their populated meta columns regardless — this only changes how the table advertises itself. Note the degradation direction is the safe one: since the persisted boolean is false for every selective mode (after the fix for your other P1), a version 9 reader under-claims rather than assuming meta columns that aren't there.

Tests: new TestNineToTenUpgradeHandler covers both derived values and asserts the legacy boolean is untouched; TestTenToNineDowngradeHandler asserts the mode is deleted while populate.meta.fields is neither deleted nor rewritten. Full Test*Upgrade* / Test*Downgrade* suite passes (76 tests).

@danny0405 danny0405 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two correctness issues found while reviewing the current head.

// populate.meta.fields boolean is consulted only when the mode is absent. There is therefore
// no ambiguous combination to reject here — MetaFieldsMode.resolve throws on unrecognized
// values.
MetaFieldsMode metaFieldsMode = writeConfig.getMetaFieldsMode();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] populate.meta.fields=true must not be accepted with NONE or a selective mode unless the legacy flag is rewritten before table creation. The new builder/table-builder paths persist the two properties independently, so a table can be written selectively while hoodie.properties still says hoodie.populate.meta.fields=true. Pre-1.3 readers ignore the new property and then treat the table as ALL; for NONE, an older incremental reader can be allowed to run while every commit time is null and silently return no rows. Please either reject conflicting combinations or always persist the derived legacy value (ALL -> true, every other mode -> false). This also makes TestMetaFieldsMode#populateTrueWithSelectiveModeIsRejected` fail: no exception is thrown.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 664ff2e — you're right, and the forward-compat consequence you describe is the important part.

Whenever an explicit mode is supplied, the legacy boolean is now derived from it rather than taken from the caller (ALL -> true, every other mode -> false), so hoodie.properties can never contradict the mode:

  • HoodieTableMetaClient.TableBuilder — the chokepoint every table-creation path goes through, including HoodieSparkSqlWriter and StreamSync, which both call setPopulateMetaFields(...) and setMetaFieldsModeFromString(...).
  • HoodieWriteConfig.Builder#withMetaFieldsMode — so a write config handed to table creation cannot carry a contradicting boolean either.

With no mode supplied, only the legacy boolean is recorded, so pre-1.3.0 on-disk behavior is unchanged.

I went with "always persist the derived value" rather than "reject the combination" because rejecting would make populate.meta.fields=true + ALL an error, which is a perfectly coherent request, and because the derived write also fixes the case where the boolean is simply absent.

On TestMetaFieldsMode#populateTrueWithSelectiveModeIsRejected: you're right that it was failing, and I should own how that happened. CI flagged it, and I rewrote it to accept the new behavior — I read it as stale rather than as the signal it was. It's now selectiveModeWinsOverLegacyPopulateTrue, and it additionally asserts the persisted boolean is false, which is the invariant that was actually broken. Added noneModePersistsLegacyBooleanAsFalse and allModePersistsLegacyBooleanAsTrue alongside it, plus builder-level coverage of all four combinations in TestHoodieTableMetaClient.

* deprecated {@code hoodie.populate.meta.fields} boolean.
*/
public MetaFieldsMode getMetaFieldsMode() {
return MetaFieldsMode.resolve(getStringOrDefault(HoodieTableConfig.META_FIELDS_MODE),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] This resolves the mode solely from writer properties, but the common client validation still compares only populateMetaFields(). A direct SparkRDDWriteClient can therefore open a persisted COMMIT_TIME_ONLY table with populate=false and no mode, resolve this to NONE, and pass BaseHoodieWriteClient.validateAgainstTableProperties() because both legacy booleans are false. The resulting files have null commit times while the table remains marked COMMIT_TIME_ONLY, so incremental queries accept the table and silently miss those rows. Please compare the full enum against the persisted table mode in the base client validation (or inject the on-disk mode before resolving), not only in the Spark datasource helper.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in c266b82.

validateAgainstTableProperties now compares the full MetaFieldsMode instead of the legacy booleans:

MetaFieldsMode tableMetaFieldsMode = tableConfig.getMetaFieldsMode();
MetaFieldsMode writeMetaFieldsMode = writeConfig.getMetaFieldsMode();
if (tableMetaFieldsMode != writeMetaFieldsMode) { throw ... }

Worth noting your scenario is a narrowing, not a widening — NONE against a persisted COMMIT_TIME_ONLY. I'd separately added a widening-only guard (isWiderThan) in the stacked PR, and it would not have caught this: NONE is not wider than COMMIT_TIME_ONLY. Only a full mismatch check does. Good catch.

TestBaseHoodieWriteClient gains a regression test for exactly that combination — persisted COMMIT_TIME_ONLY, writer with populate=false and no mode, both legacy booleans false. I confirmed it fails against the previous boolean-only guard and passes with the enum comparison, so it isn't vacuous. A second test covers the matching cases (default ALL writer, and a selective writer against a table recorded with the same mode) to make sure the stricter check doesn't reject legitimate flows — the metadata table, which forces NONE, was the one I most wanted to confirm, and TestSparkRDDMetadataWriteClient passes.

TestMetaFieldsMode.populateTrueWithSelectiveModeIsRejected asserted the
mutual-exclusion check that was removed when hoodie.meta.fields.mode became the
source of truth: populate.meta.fields=true together with a selective mode used
to be rejected as ambiguous, and now the mode simply wins.

Rewritten as selectiveModeWinsOverLegacyPopulateTrue, which asserts the new
contract end to end — the table resolves to COMMIT_TIME_ONLY and only
_hoodie_commit_time is populated on disk.

Caught by CI on spark3.4 / spark3.5; the hudi-spark module does not test-compile
locally on this branch due to an unrelated pre-existing break in
TestMergeIntoHoodieTableCommand.
codope pushed a commit to nsivabalan/hudi that referenced this pull request Jul 27, 2026
Findings from driving the Skill end-to-end across four workloads
(prototyping, production-at-scale, immutable append-only, and an
ETL-DataFrame source). Three defects would have produced wrong
config; the rest tighten the dialogue.

Config correctness:
- hoodie.compaction.target.io is denominated in MB, not bytes. The
  templates emitted byte counts, off by ~10^6. It is also an IO
  ceiling rather than a sizing target, so emit a large finite MB
  value instead of deriving a point value from table size.
- Partitioned RLI templates used hoodie.metadata.record.index.*,
  which are deprecated aliases for the GLOBAL properties — the
  partitioned config was setting global knobs. Use the modern
  per-variant keys.
- RLI file-group count is durable at index initialization. The ADR
  described the index as fully reversible; adding an index later is
  free, resizing an initialized one is not.
- Selective meta-field population does not exist at 1.2.0. The
  rubric offered it as an option. Removed, with the roadmap noted
  (apache#19205, targeting 1.3.0).
- Cleaner retention and the archival window now derive from commit
  cadence rather than hardcoded 48h / 1000-1200. At daily cadence
  or slower, emit neither and leave Hudi's defaults in place.
- Stop emitting config that restates a default: column stats, bloom
  filter, and upsert shuffle parallelism (Hudi derives the last from
  the incoming DataFrame's partition count).

New coverage:
- RLI file-group sizing formula, with min == max to pin the count,
  applied table-wide for global RLI and per-partition for
  partitioned. Bootstrap-then-enable fallback when the user cannot
  project growth.
- Ingestion cadence asked in Round 1 at every tier.
- Ordering/precombine field asked for mutable workloads — the
  templates emitted it but no question collected it.
- In-job Spark DataFrame as a first-class source, which forces the
  DataSource writer and makes async compaction non-free.
- Schema providers for non-Kafka sources; optional and orthogonal
  to source type, with Kafka treated as a practical exception.
- A standalone HoodieCompactor implies concurrent writers and needs
  matching lock-provider config in both jobs.

Question flow:
- Widget-first delivery at all tiers, with free text reduced to
  column names asked after a shape question scopes them.
- Prototyping continues past Round 1 with disclosed defaults plus a
  hard ask for non-defaultable facts, so its config bundle is
  runnable rather than placeholder-filled.
- Validation questions travel with the decisions they validate:
  partition stability and read filters now accompany a partitioning
  choice instead of sitting in a round that tier never reaches.
- The MOR free-async upgrade applies as soon as the writer is known.
  Two files disagreed; deferring it to the Round 2/3 checkpoint hid
  it from the two lower tiers entirely.
- Final revisit gate before generation, and an ADR section
  recording accepted warnings and overridden recommendations.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
nsivabalan and others added 6 commits July 26, 2026 21:50
Addresses danny0405's P1 on apache#19205.

hoodie.meta.fields.mode and hoodie.populate.meta.fields were persisted
independently, so hoodie.properties could record a boolean that contradicts the
mode — e.g. a table written COMMIT_TIME_ONLY while still saying
populate.meta.fields=true. Pre-1.3.0 readers ignore the mode property entirely
and would treat such a table as ALL. For NONE that is actively unsafe: an older
incremental reader is allowed to run against all-null commit times and silently
returns no rows rather than failing.

Whenever an explicit mode is supplied, the legacy boolean is now written from it
(ALL -> true, every other mode -> false) rather than from whatever the caller
passed:

- HoodieTableMetaClient.TableBuilder — the chokepoint for every table-creation
  path, including HoodieSparkSqlWriter and StreamSync, which pass both setters.
- HoodieWriteConfig.Builder#withMetaFieldsMode — so a write config handed to
  table creation cannot carry a contradicting boolean either.

With no mode supplied, only the legacy boolean is recorded, preserving
pre-1.3.0 behavior on disk.

Tests: TestHoodieTableMetaClient covers all four builder combinations
(selective / NONE / ALL / legacy-only); TestMetaFieldsMode asserts the persisted
boolean end to end for selective, NONE and ALL; TestHoodieWriteConfigMetaFieldsMode
adds an ordering case so a later withPopulateMetaFields cannot widen a
selective config.
…tion

Addresses danny0405's second P1 on apache#19205.

validateAgainstTableProperties compared only the legacy populateMetaFields()
booleans. Those collapse every selective mode to false, so a writer resolving to
NONE passed the check against a persisted COMMIT_TIME_ONLY table — both sides
read false. The writer then produced files with null _hoodie_commit_time while
the table still advertised COMMIT_TIME_ONLY, so incremental queries accepted the
table and silently missed those rows.

Meta-field population is physical, so the writer must agree with what the table
records. The check now compares MetaFieldsMode directly and reports both values.
Note this is a mismatch check, not a widening check: NONE against
COMMIT_TIME_ONLY is a narrowing that the previous guard, and a widening-only
guard, would both let through.

Reachable from any direct BaseHoodieWriteClient / SparkRDDWriteClient user; the
Spark datasource path was already covered by HoodieWriterUtils.validateTableConfig.

Tests: TestBaseHoodieWriteClient gains a case reproducing exactly that scenario
(verified to fail against the previous boolean-only guard) plus a matching-mode
case covering both the default ALL writer and a selective writer.
…case-insensitive

Three review comments from danny0405 on apache#19205:

- HoodieSparkParquetWriter's boolean constructor had no callers at all — the
  only construction site (HoodieSparkFileWriterFactory) already passes a
  MetaFieldsMode. Removed.
- HoodieAvroParquetWriter's boolean constructor is still used by
  SparkHelpers.scala plus four tests, so it stays, but is now marked
  @deprecated pointing at the MetaFieldsMode overload since it cannot express
  the selective modes.
- MetaFieldsMode.parse now upper-cases before valueOf, so a hand-edited
  hoodie.properties or a write option does not have to match the enum's casing.
  TestHoodieMetaFieldsMode covers lower, mixed and padded forms.
Addresses danny0405's review comment on apache#19205: every caller was repeating the
same two-line dance — read hoodie.meta.fields.mode, read the deprecated boolean,
pass both — which duplicated the property keys and the precedence rule at each
site.

MetaFieldsMode.resolve(HoodieConfig) now encapsulates that. Both HoodieTableConfig
and HoodieWriteConfig extend HoodieConfig, so one overload serves the write
config and both writer factories.

HoodieTableConfig.getMetaFieldsMode deliberately keeps the two-argument form: its
fallback must read the raw property, because populateMetaFields() on that class
is itself derived from the mode. Commented in place so the asymmetry is not
mistaken for an oversight.
Addresses danny0405's review comment on apache#19205: the new table property had no
upgrade or downgrade handling.

Upgrade (9 -> 10): version 9 tables predate the property and resolve to ALL /
NONE from the deprecated populate.meta.fields boolean. The upgrade now records
that derived value explicitly, so an upgraded table describes its meta-field
layout the same way a freshly created version 10 table does rather than relying
on the legacy fallback at every read. Behavior is unchanged either way.

Downgrade (10 -> 9): version 9 does not understand the property, so it is
dropped — but populate.meta.fields is deliberately left exactly as it stands.
ALL and NONE tables therefore round-trip unchanged, since those are precisely
the two states the legacy boolean can express. Selective modes cannot be
expressed in version 9; the downgrade is still allowed, and logs a warning that
the table will behave as NONE to version 9 readers and that incremental queries
relying on the selective mode will stop returning rows. Already-written files
keep their populated meta columns either way.

The mode lookup in the downgrade handler is best-effort: dropping the property
is what matters, and some callers drive the change set without a helper.

Tests: new TestNineToTenUpgradeHandler covers both derived values and asserts
the upgrade never rewrites the legacy boolean; TestTenToNineDowngradeHandler now
asserts the mode is deleted while populate.meta.fields is left untouched.
Comparing the resolved MetaFieldsMode with strict equality rejected a
combination that has always been legal: a writer that resolves to NONE
against an ALL table. Many callers build a HoodieWriteConfig without
restating the table's meta-field settings, so this broke writes across
the Spark, Java and spark-client engines.

Split the guard in two:

  - Widening is always rejected. Enabling a meta 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 still catches an explicit NONE against
    a COMMIT_TIME_ONLY table, which would write null commit times while
    the table advertises COMMIT_TIME_ONLY and make incremental queries
    silently miss those rows.

A writer that never mentions the mode is left alone: writing fewer meta
columns than the table advertises cannot make a reader believe in data
that is absent.

Adds MetaFieldsMode.isWiderThan for the asymmetric comparison, and tests
for both new branches.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@hudi-bot

Copy link
Copy Markdown
Collaborator

CI report:

Bot commands @hudi-bot supports the following commands:
  • @hudi-bot run azure re-run the last Azure build

+ "Set %s=%s on the writer, or recreate the table to change it.",
HoodieTableConfig.META_FIELDS_MODE.key(), tableMetaFieldsMode, writeMetaFieldsMode,
HoodieTableConfig.META_FIELDS_MODE.key(), tableMetaFieldsMode));
} else if (writerStatedMode && writeMetaFieldsMode != tableMetaFieldsMode) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if writeMetaFieldsMode != tableMetaFieldsMode is true, then writerStatedMode must be true right? so we can eliminate writerStatedMode.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, it is load-bearing, and this PR already has the test that proves it: TestBaseHoodieWriteClient.java:129-141 (validateAgainstTablePropertiesAllowsUnstatedWriterToNarrow) -- table ALL, writer sets only withPopulateMetaFields(false), so writeMetaFieldsMode is NONE and differs from the table's ALL while writerStatedMode is false.

That path has always been legal: the pre-PR check was one-directional (!table.populateMetaFields() && write.populateMetaFields()), added in d5026e9a2485 [HUDI-2161]. Dropping the flag would start throwing for every caller that builds a write config without restating the table's meta-field settings.

That said, the flag is currently mis-scoped rather than redundant -- see my comment on :1572. It should gate only ALL tables; for a selective table the disagreement has to be rejected whether or not the writer stated a mode.

MetaFieldsMode writeMetaFieldsMode = writeConfig.getMetaFieldsMode();
boolean writerStatedMode = writeConfig.contains(HoodieTableConfig.META_FIELDS_MODE)
&& !StringUtils.isNullOrEmpty(writeConfig.getString(HoodieTableConfig.META_FIELDS_MODE));
if (writeMetaFieldsMode.isWiderThan(tableMetaFieldsMode)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is narrower than allowed here? seems not from the check in line 1580

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, narrowing is deliberately allowed, and the code does match the comment. Full matrix, with S = "writer explicitly set hoodie.meta.fields.mode":

  • S = true: passes iff writer mode == table mode; all 20 off-diagonal cells throw (that is the :1580 branch).
  • S = false: the writer mode can only be ALL or NONE, since it is derived from the legacy boolean.
    • ALL: throws for every table mode except ALL (caught by isWiderThan via the record-key clause).
    • NONE: passes against all five table modes.

So :1573 is what allows narrowing, and it is only reached when S is false. Of those NONE cells, ALL and NONE are correct and long-standing. The three selective ones are the bug I flagged on :1572 -- they let a writer silently write null _hoodie_commit_time onto a table that still advertises COMMIT_TIME_ONLY.

Worth noting the relation is not a total order: COMMIT_TIME_ONLY and FILE_NAME_ONLY are each wider than the other, so transitions between them are rejected in both directions. Nothing currently tests that.

MetaFieldsMode metaFieldsMode = writeConfig.getMetaFieldsMode();
// Selective meta-field modes are CoW-only in this release. MoR log-write path does not yet
// respect the mode, which would silently produce log records with null meta columns.
boolean isSelective = metaFieldsMode != MetaFieldsMode.ALL && metaFieldsMode != MetaFieldsMode.NONE;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can have a method MetaFieldsMode.isSelective

MetaFieldsMode metaFieldsMode = tableConfig.getMetaFieldsMode();
Map<ConfigProperty, String> propertiesToUpdate = Collections.singletonMap(
HoodieTableConfig.META_FIELDS_MODE, metaFieldsMode.name());
return new UpgradeDowngrade.TableConfigChangeSet(propertiesToUpdate, Collections.emptySet());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the legacy "populate meta fields" property should be removed?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would keep it, and I think this one deserves an explicit comment saying why, because the symmetry argument points the other way.

EightToNineUpgradeHandler does remove the legacy property (:171-172, :240-241), so the convention says remove it here too. The difference is the default: PAYLOAD_CLASS_NAME and PRECOMBINE_FIELD have no default, but POPULATE_META_FIELDS defaults to true (HoodieTableConfig.java:338-341).

So if the upgrade removed the boolean and the table were later downgraded, a v9 reader would find neither property and resolve to ALL -- claiming meta columns on files that do not have them. That is exactly the widening this PR bans everywhere else.

Suggest keeping it and noting the default-value exception in the class javadoc, so the deviation reads as deliberate rather than an oversight.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So if the upgrade removed the boolean and the table were later downgraded, a v9 reader would find neither property and resolve to ALL

Didn't get it, for upgrade, the POPULATE_META_FIELDS would be translated into ALL or NONE mode, and for downgrade the ALL or NONE would be translated back into POPULATE_META_FIELDS true or false right? For default value that is not declared explicitly, we can skip the translation since both options have the same default value, for explicit options, let's just keep one of them to avoid ambiguties.

@voonhous voonhous Jul 29, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a scenario for upgrade + downgrade round trip.

State A -- v9 table today (created pre-1.3 with virtual keys):

hoodie.properties:

hoodie.table.version=9
hoodie.populate.meta.fields=false

Data files on disk have no meta columns. Readers resolve populateMetaFields() == false and use the user's key field.

State B -- after 9 -> 10

if the upgrade removed the legacy property (the symmetry that EightToNineUpgradeHandler follows for PAYLOAD_CLASS_NAME at :171-172 and PRECOMBINE_FIELD at :240-241):

hoodie.properties:

hoodie.table.version=10
hoodie.meta.fields.mode=NONE

It is still correct -- at v10 the mode is the source of truth.

State C -- after 10 -> 9 downgrade.

TenToNineDowngradeHandler:75 deletes META_FIELDS_MODE, and it has to: v9 cannot interpret it.

hoodie.properties:

hoodie.table.version=9

Neither property is present now.

State D -- a v9 reader opens it

HoodieTableConfig.getMetaFieldsMode() (:1281) falls back to legacyPopulateMetaFields() -> getStringOrDefault(POPULATE_META_FIELDS) -> #defaultValue(true) (:338-341) -> ALL.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

During downgrade, the hoodie.populate.meta.fields should be added back correctly with value inferred from the META_FIELDS_MODE so that the reader can still work as before.

We should fix both the upgrade/downgrade handler to translate the options and add/remove the option that does not need

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

reached concensus offline with @voonhous that my suggestions works.

return new UpgradeDowngrade.TableConfigChangeSet(
Collections.emptyMap(),
Collections.singleton(HoodieTableConfig.TABLE_STORAGE_LAYOUT));
propertiesToDelete);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the legacy "populate meta fields" property should be inferred and added here?

@voonhous voonhous Jul 28, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1 Details on :72.

The precedent is NineToEightDowngradeHandler.java:116-117 and :152-153: downgrade restores the legacy property from the new one, then removes the new one. Here the mode is deleted and nothing is written back.

For ALL/NONE writing the derived boolean is a no-op, so it costs nothing. What it fixes is the case where a table carries the mode without the boolean -- POPULATE_META_FIELDS then falls back to its true default and the table downgrades to ALL, i.e. Hudi believes _hoodie_record_key is populated on files where it is null.

propertiesToUpdate.put(HoodieTableConfig.POPULATE_META_FIELDS.key(),
    String.valueOf(metaFieldsMode.toLegacyPopulateMetaFields()));

Separate but related: selective modes are destroyed here with only a LOG.warn, and they cannot be restored afterwards, because a re-upgrade derives NONE and isWiderThan then rejects setting the mode back. No other handler pair in this package is one-way like that -- worth either throwing or documenting it as intentional.

// Version 9 does not understand hoodie.meta.fields.mode, so it is dropped...
assertTrue(changeSet.propertiesToDelete().contains(HoodieTableConfig.META_FIELDS_MODE));
// ...while hoodie.populate.meta.fields is deliberately left in place, so ALL and NONE tables
// round-trip unchanged — those are exactly the two states the legacy boolean can express.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we keep either the old or new property and translate during the upgrade/downgrade.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Keep both and translate on both edges -- that is what the rest of the package does (EightToNineUpgradeHandler.java:171-172 on the way up, NineToEightDowngradeHandler.java:116-117 on the way down).

The one exception is that the upgrade should not delete hoodie.populate.meta.fields, because unlike PAYLOAD_CLASS_NAME it has a true default -- deleting it makes a v9 reader see ALL. I have written that up on NineToTenUpgradeHandler.java:55.

So: upgrade writes the mode and leaves the boolean; downgrade writes the derived boolean and deletes the mode. That makes the round trip lossless for ALL/NONE and removes the silent-widening case.

This test will also need a selective-mode case -- right now it passes null for the helper, so the handler short-circuits to ALL and the whole selective branch is never executed.

@voonhous voonhous left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the full branch at a308a37.

The one I would not merge without: cshuo's StreamSync-restart scenario is still live. It was closed as fixed by c78dc962, but that commit fixed table-config resolution, not the write-client gate. Details on BaseHoodieWriteClient.java:1572 -- an unstated writer against a COMMIT_TIME_ONLY table resolves to NONE, passes validation, and writes null _hoodie_commit_time while the table still advertises the mode and still admits incremental queries. Those rows are then dropped by the range filter, permanently and silently.

Close second: no test anywhere in the PR runs an incremental query. That is the feature's entire premise, and the read path has already broken once on this branch -- 13b39db9fb22 fixed zero-row incremental reads and shipped with zero tests.

Other themes, inline: the downgrade handler breaks the add-new/restore-legacy convention this package established in b60d38c40fa1 and makes selective modes unrecoverable; validateTableConfig rejects writers that merely restate the table's own mode; the write-config builder is order-dependent in a way that reopens half of the P1 you raised earlier.

Extra considerations that needs to be addressed:

  1. HoodieParquetBinaryCopyBase.java:131-133 unconditionally masks _hoodie_file_name with the output file name. Under NONE or COMMIT_TIME_ONLY, binary-copy clustering will populate a column the mode says stays null. Benign direction, but it is a mode/data disagreement and nothing tests it.
  2. The PR description no longer describes the implementation. It documents hoodie.meta.fields.mode as a comma-separated token list, has a "Why a list instead of a boolean or enum" section arguing against the enum that has since replaced it, references a parseMetaFieldsMode helper that does not exist, and shows Set<String> writer signatures that are now MetaFieldsMode. The behaviour row "populate=true + non-empty mode -> rejected as ambiguous" was deliberately removed by 664ff2ec (see the comment now at HoodieWriteConfig.java:3940-3942). Most importantly, Impact says "no change for tables that don't opt in", which misses the one backward-compat item in the patch: 77f4e9148c4a changes the five meta columns on existing populate.meta.fields=false row-writer tables from required binary "" to optional binary NULL, mid-table. I traced that change end to end and it is safe for Hudi's own readers -- but it deserves a release note, since a user query doing WHERE _hoodie_record_key = '' changes behaviour.

On test shape generally: five new classes largely re-test one 5-line function at five layers, while the branches that actually broke on this branch (incremental read, clustering file name, engine guard, downgrade selective path) are untested or mutation-survivable. I would trade most of the new volume for those four.

MetaFieldsMode tableMetaFieldsMode = tableConfig.getMetaFieldsMode();
MetaFieldsMode writeMetaFieldsMode = writeConfig.getMetaFieldsMode();
boolean writerStatedMode = writeConfig.contains(HoodieTableConfig.META_FIELDS_MODE)
&& !StringUtils.isNullOrEmpty(writeConfig.getString(HoodieTableConfig.META_FIELDS_MODE));

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

} else if (writeMetaFieldsMode != tableMetaFieldsMode
           && (writerStatedMode || tableMetaFieldsMode.isSelective())) {

and adding a TestBaseHoodieWriteClient case: table COMMIT_TIME_ONLY, unstated writer -> must throw.

// 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)) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

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.

}
// 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);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

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.

} else {
writeConfig.setValue(HoodieTableConfig.META_FIELDS_MODE, metaFieldsMode.name());
writeConfig.setValue(HoodieTableConfig.POPULATE_META_FIELDS,
Boolean.toString(metaFieldsMode.toLegacyPopulateMetaFields()));

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

* 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) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

void testDowngradeRemovesStorageLayoutOnly() {
void testDowngradeRemovesStorageLayoutAndMetaFieldsMode() {
UpgradeDowngrade.TableConfigChangeSet changeSet =
new TenToNineDowngradeHandler().downgrade(null, null, null, null);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

}

@Test
void upgradeLeavesTheLegacyBooleanAlone() {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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()))
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.

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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()))
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.

@voonhous

Copy link
Copy Markdown
Member

Did a comprehensive review using Claude. I deleted the comments that i felt were not relevant.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XL PR with lines of changes > 1000

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support selective meta field population

5 participants