Skip to content

fix(config): resolve meta-fields mode at sites that still read the deprecated boolean - #19378

Open
nsivabalan wants to merge 14 commits into
apache:masterfrom
nsivabalan:meta_fields_mode_streamer_guard
Open

fix(config): resolve meta-fields mode at sites that still read the deprecated boolean#19378
nsivabalan wants to merge 14 commits into
apache:masterfrom
nsivabalan:meta_fields_mode_streamer_guard

Conversation

@nsivabalan

Copy link
Copy Markdown
Contributor

Describe the issue this Pull Request addresses

Stacked on #19205 — please review and merge that one first.

While scoping the hoodie.populate.meta.fields deprecation follow-up requested in review of #19205, I audited all 103 populateMetaFields() / POPULATE_META_FIELDS references in src/main. Most are semantically identical today and only misname intent (those are the rename sweep, still to come). But a dozen sites read the deprecated boolean directly rather than resolving hoodie.meta.fields.mode — and with a selective mode set while the boolean sits at its true default, they resolve the wrong way. Several are reachable on the Spark CoW path #19205 enables.

This PR fixes those.

Summary and Changelog

Record-key reads → NPE. Under a selective mode these take the meta-column branch and then dereference the null _hoodie_record_key ordinal:

  • HoodieSparkRecord#wrapIntoHoodieRecordPayloadWithKeyGen
  • HoodieAvroIndexedRecord#wrapIntoHoodieRecordPayloadWithKeyGen

Writer factories bypassing the mode. Would stamp all five meta columns and enable the bloom filter — the opposite of the requested mode:

  • HoodieSparkFileWriterFactory Lance + Vortex paths (the parquet path beside them was already migrated)
  • HoodieAvroFileWriterFactory HFile path — its getBoolean (rather than getBooleanOrDefault) could also NPE on unbox when neither property is set

Schema reconstruction asking the wrong question. These ask "are the meta columns on disk?" using an is-record-key-populated predicate. Selective modes write the meta columns as physical nullable columns, so the correct test is mode != NONE — the predicate TableSchemaResolver already uses. Otherwise the rebuilt schema omits columns the file actually has:

  • SparkValidatorUtils — that schema feeds straight into spark.read().schema(...).parquet(...)
  • PartitionStatsIndexer, SparkMetadataWriterUtils

Guards bypassable by setting only the mode:

  • AutoRecordKeyGenerationUtils — auto-generated keys computed, then discarded on a table that does not populate _hoodie_record_key
  • DataSourceOptions ENABLE_ROW_WRITER infer fn — row writer stayed on, silently ignoring COMBINE_BEFORE_INSERT, the exact case its own comment warns about
  • BaseDatasetBulkInsertCommitActionExecutor — picked a partitioner sorting on null _hoodie_record_key / _hoodie_partition_path

Bootstrap dropped the mode entirely. BootstrapExecutorUtils and BootstrapExecutor called setPopulateMetaFields(...) without the paired setMetaFieldsModeFromString(...) that HoodieSparkSqlWriter and StreamSync have.

Irreversibility guard was all-or-nothing. In BaseHoodieWriteClient#validateAgainstTableProperties, both sides of !tableConfig.populateMetaFields() && writeConfig.populateMetaFields() collapse to mode == ALL, so NONE → COMMIT_TIME_ONLY and FILE_NAME_ONLY → COMMIT_TIME_AND_FILE_NAME slipped through silently. Replaced with MetaFieldsMode#isWiderThan, which rejects any transition that adds a populated column. Narrowing stays allowed — later commits simply leave the column null.

Deliberately not changed

  • MoR incremental-relation guards (MergeOnReadIncrementalRelationV1/V2) are stricter than isCommitTimePopulated() on purpose: HoodieAppendHandle does not yet honor the mode, so relaxing them would convert a loud failure into silent data loss.
  • The ~40 cosmetic references where populateMetaFields() is semantically identical today. Those become isRecordKeyPopulated() in the rename sweep.
  • HoodieTableMetadataUtil:1094/1138 — col stats drops _hoodie_commit_time under selective modes even though it is on disk. Fixing it changes the MDT col-stats column set, which makes already-built partitions inconsistent with new ones; it needs an index-version gate and belongs in its own patch.

Impact

  • Storage layout: unchanged.
  • API: no breakage. One new method, MetaFieldsMode#isWiderThan.
  • Behavior: the write-client guard now rejects mode transitions it previously allowed silently. That is the intended fix — those transitions produce files whose meta-column population differs across commits.

Risk Level

low

Each change narrows a predicate that was resolving incorrectly for selective modes. Default (ALL) and legacy (NONE) behavior is unchanged, since resolve() returns the same value for both when no mode property is set.

Documentation Update

None needed — no new user-facing config.

Contributor's checklist

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

…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.
…e populate.meta.fields

Addresses review feedback on apache#19205: composing hoodie.populate.meta.fields and
hoodie.meta.fields.mode to determine meta-column population was confusing, and
the two-input resolution silently discarded an explicit mode whenever
populate.meta.fields=true.

hoodie.meta.fields.mode is now the single control. MetaFieldsMode.fromConfig
(boolean, String) is replaced by resolve(String, boolean): an explicit mode is
the answer outright, and the deprecated boolean is consulted only when the mode
is absent, so tables written before the property existed keep resolving to
ALL / NONE exactly as before.

- Deprecate POPULATE_META_FIELDS (config property, write-config builder setter,
  and table builder setter). Full call-site migration is a follow-up; the 55
  existing populateMetaFields() callers keep working because the accessor now
  derives from the mode via toLegacyPopulateMetaFields().
- Drop the populate=true + mode mutual-exclusion check from
  HoodieWriteConfig.validate() — the combination is no longer ambiguous.
- Persist an explicit mode verbatim in hoodie.properties, including ALL / NONE.
  This also closes a hole where a selective mode supplied without an explicit
  populateMetaFields boolean skipped both cross-validation guards and was then
  silently ignored at read time.
- setMetaFieldsModeFromString now routes through MetaFieldsMode.parse so bad
  values report the allowed set instead of a bare "No enum constant".
…lds path

HoodieRowCreateHandle#writeRowSelectiveMetaFields gated _hoodie_file_name on
shouldPreserveHoodieMetadata, copying the source row's value during clustering.
Because clustering replaces the file it reads from, the rewritten records ended
up pointing at a file that no longer serves them.

The ALL-mode path (writeRow) has always written the new file name
unconditionally, even when shouldPreserveHoodieMetadata is set — only
_hoodie_commit_time and _hoodie_commit_seqno are preserved there. Match that
behavior. _hoodie_commit_time still honors the preserve flag.

Reported in review of apache#19205.
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.
@nsivabalan
nsivabalan force-pushed the meta_fields_mode_streamer_guard branch 2 times, most recently from 2f796c1 to aa7a9c4 Compare July 27, 2026 04:02
nsivabalan and others added 7 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>
…precated boolean

An audit of all 103 populateMetaFields() / POPULATE_META_FIELDS references turned
up sites that read the deprecated boolean directly instead of resolving
hoodie.meta.fields.mode. With a selective mode set and the boolean left at its
true default, these resolve the wrong way. Several are reachable today on the
Spark CoW path this PR enables.

Record-key reads (NPE under selective modes — the meta-column branch is taken,
then the null _hoodie_record_key ordinal is dereferenced):
- HoodieSparkRecord#wrapIntoHoodieRecordPayloadWithKeyGen
- HoodieAvroIndexedRecord#wrapIntoHoodieRecordPayloadWithKeyGen

Writer factories bypassing the mode (would stamp all five meta columns and
enable the bloom filter — the opposite of the requested mode):
- HoodieSparkFileWriterFactory Lance + Vortex paths
- HoodieAvroFileWriterFactory HFile path (its getBoolean could also NPE on
  unbox when neither property is set)

Schema reconstruction asking "are meta columns on disk?" with an
is-record-key-populated predicate. Selective modes write the meta columns as
physical nullable columns, so the correct test is mode != NONE — otherwise the
rebuilt schema omits columns the file actually has:
- SparkValidatorUtils (fed straight into spark.read().schema(...).parquet(...))
- PartitionStatsIndexer, SparkMetadataWriterUtils

Guards bypassable by setting only the mode:
- AutoRecordKeyGenerationUtils (auto-generated keys would be computed then
  discarded on a table that does not populate _hoodie_record_key)
- DataSourceOptions ENABLE_ROW_WRITER infer fn (row writer stayed on, silently
  ignoring COMBINE_BEFORE_INSERT — the exact case its comment warns about)
- BaseDatasetBulkInsertCommitActionExecutor (picked a partitioner that sorts on
  null _hoodie_record_key / _hoodie_partition_path)

Bootstrap table creation dropped the mode entirely, unlike HoodieSparkSqlWriter
and StreamSync which pair it with the legacy boolean:
- BootstrapExecutorUtils, BootstrapExecutor

Also replaces the all-or-nothing irreversibility guard in
BaseHoodieWriteClient#validateAgainstTableProperties with a mode-lattice check.
Both sides of the old condition collapsed to mode == ALL, so NONE ->
COMMIT_TIME_ONLY and FILE_NAME_ONLY -> COMMIT_TIME_AND_FILE_NAME slipped through
silently. MetaFieldsMode#isWiderThan rejects any transition that adds a
populated column; narrowing is still allowed.

Deliberately unchanged: the MoR incremental-relation guards, which are stricter
than isCommitTimePopulated() on purpose because HoodieAppendHandle does not yet
honor the mode. Relaxing them would turn a loud failure into silent data loss.
The remaining ~40 references are semantically equivalent today and are left for
the deprecation follow-up.
@nsivabalan
nsivabalan force-pushed the meta_fields_mode_streamer_guard branch from aa7a9c4 to 6b3d885 Compare July 27, 2026 15:35
@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

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants