Skip to content

feat(writer): extend hoodie.meta.fields.mode to MoR + fail-fast on RLI/SI incompatibility - #19323

Draft
nsivabalan wants to merge 7 commits into
apache:masterfrom
nsivabalan:selective_meta_fields_mode_pr_c
Draft

feat(writer): extend hoodie.meta.fields.mode to MoR + fail-fast on RLI/SI incompatibility#19323
nsivabalan wants to merge 7 commits into
apache:masterfrom
nsivabalan:selective_meta_fields_mode_pr_c

Conversation

@nsivabalan

@nsivabalan nsivabalan commented Jul 19, 2026

Copy link
Copy Markdown
Contributor

Describe the issue this Pull Request addresses

Phase 3 of 3 in the selective meta-fields series. Follow-up to #19205 (CoW writer + reader + config) and #19206 (hudi-cli table set-meta-fields-mode). This PR extends the mode to Merge-on-Read tables and adds a fail-fast validation for indexes that depend on _hoodie_record_key.

Without this PR, MoR tables silently ignore hoodie.meta.fields.mode on the log-write path (base file honors it, log block does not — merge produces mixed-mode records), and enabling RLI or secondary index on a non-ALL mode silently corrupts index maintenance (index keyed on nulls, updates target phantom rows).

Summary and Changelog

Two additive changes, one commit each:

1. feat(writer): extend hoodie.meta.fields.mode to MoR log-write and incremental-read paths

  • Log-write: HoodieAppendHandle.populateMetadataFields() now honors the mode when populate.meta.fields=false. Under selective modes the append handle writes only the opted-in meta columns to log blocks, matching the base-file behavior from phase 1.
  • Incremental-read: MergeOnReadIncrementalRelationV1/V2 (and CoW IncrementalRelationV1/V2) now gate on tableConfig.isCommitTimePopulated() instead of populateMetaFields(). Tables in COMMIT_TIME_ONLY / COMMIT_TIME_AND_FILE_NAME now serve incremental queries correctly; FILE_NAME_ONLY / NONE fail fast with an actionable error message that names the mode and points at remediation.
  • TableSchemaResolver from phase 1 already emits meta columns whenever the mode is not NONE, so the log-block reader picks them up without further changes.

2. feat(writer): fail-fast when RLI/SI are enabled with a non-record-key mode

  • New checkArguments in HoodieWriteConfig.Builder.validate() reject the combination of (RLI or SI enabled) with any mode that leaves _hoodie_record_key unpopulated (NONE, COMMIT_TIME_ONLY, FILE_NAME_ONLY, COMMIT_TIME_AND_FILE_NAME).
  • Predicates mirror HoodieMetadataConfig.isRecordLevelIndexEnabled() / isSecondaryIndexEnabled() exactly — including the metadata-enabled gate and (for SI) the column-non-empty gate. Inlined rather than delegating to the accessor because validate() runs before the HoodieWriteConfig instance's metadataConfig field is materialized.
  • Error messages name the mode explicitly and point at the two remediation options (disable the index, or use ALL mode).

Impact

Behavior change (MoR tables only, opt-in): MoR tables that set hoodie.meta.fields.mode explicitly now respect that mode in log blocks. Previously the mode was silently dropped on the log-write path — this is a bug fix, not a compatibility break. Tables that never set the mode see no change.

Behavior change (RLI/SI validation, all engines): Attempting to enable RLI or SI on a table with a non-ALL mode now fails at write-config build time with a clear error. Previously it would silently produce incomplete / phantom index entries. No impact on tables that use RLI/SI with the default (ALL) mode.

Read-side error: MoR incremental queries on FILE_NAME_ONLY / NONE tables that previously returned zero rows silently now throw with an actionable message. This is the same guard already present for CoW in phase 1 — extended to MoR here.

Risk Level

Low. All changes are additive and guarded by the mode enum introduced in phase 1. The MoR log-write path change only fires when the mode is set (opt-in), and the RLI/SI guard only fires when the user actively enables those indexes on a non-ALL mode — both cases previously produced silent data-correctness issues, so the fail-fast behavior is strictly safer.

Verification: new unit + integration tests covering all 5 modes on MoR (bulk-insert, upsert, incremental) plus parameterized RLI + SI rejection across all four non-ALL modes. All existing tests still pass (17 in TestHoodieWriteConfigMetaFieldsMode, 32 in TestMetaFieldsMode, 10 in TestHoodieStreamerMetaFieldsMode).

Documentation Update

None. The hoodie.meta.fields.mode config was documented in phase 1 (#19205). This PR extends its scope from CoW to MoR and adds validation guards; no new user-facing config or feature.

Contributor's checklist

  • Read through contributor's guide
  • Enough context is provided in the sections above
  • Adequate tests were added if applicable

…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.
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.
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.
…remental-read paths

Wires the selective meta-fields mode through the MoR path so MoR tables can opt into
COMMIT_TIME_ONLY / FILE_NAME_ONLY / COMMIT_TIME_AND_FILE_NAME modes previously
restricted to CoW.

HoodieAppendHandle.populateMetadataFields now honors the mode when
populateMetaFields=false: only the opted-in meta columns are set; the rest stay
null on the log record. Log-compaction still skips commit_time by design (base
record's commit_time is preserved).

MergeOnReadIncrementalRelationV1/V2 gate on isCommitTimePopulated() instead of
populateMetaFields(), matching the CoW read-path change from PR-A. Selective
COMMIT_TIME modes on MoR are now accepted; NONE / FILE_NAME_ONLY still rejected
with a clearer message.

HoodieWriteConfig.validate() drops the MoR-selective-mode rejection guard added
in PR-A now that MoR is supported.

Tests: parameterized MoR bulk-insert + upsert coverage for all 5 modes, plus an
end-to-end incremental-query round-trip under COMMIT_TIME_ONLY. Streamer test
extended to accept all 5 modes on MERGE_ON_READ.
… mode

The record-level index and secondary index both maintain their entries by
_hoodie_record_key. Only MetaFieldsMode.ALL populates the record key column;
every other mode (NONE, COMMIT_TIME_ONLY, FILE_NAME_ONLY,
COMMIT_TIME_AND_FILE_NAME) leaves it null on the base file. Prior to this
change the writer would accept the combination and silently corrupt index
maintenance — RLI would key on nulls, SI updates would land on phantom rows,
and lookups would return incomplete results with no error surfaced.

Add an explicit checkArgument in HoodieWriteConfig.Builder.validate() that
rejects (RLI or SI) enabled with mode.isRecordKeyPopulated() == false. The
guard reads the properties directly via ConfigProperty rather than through
the accessor methods because validate() runs before the private
HoodieWriteConfig constructor materializes the metadataConfig field, so
writeConfig.isRecordLevelIndexEnabled() would NPE at that point.

Tests:
- TestHoodieWriteConfigMetaFieldsMode: parameterized rejection tests for all
  four non-ALL modes covering both RLI and secondary-index cases, plus a
  positive test that RLI is still allowed under ALL mode.
- TestMetaFieldsMode: negative end-to-end tests confirming MoR incremental
  reads are rejected with a clear error message under FILE_NAME_ONLY and
  NONE modes (where _hoodie_commit_time is not populated).
@github-actions github-actions Bot added the size:XL PR with lines of changes > 1000 label Jul 19, 2026
@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

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.

2 participants