feat(writer): add hoodie.meta.fields.mode for selective meta-field population on CoW tables - #19205
feat(writer): add hoodie.meta.fields.mode for selective meta-field population on CoW tables#19205nsivabalan wants to merge 13 commits into
Conversation
nsivabalan
left a comment
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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(), |
There was a problem hiding this comment.
same comment as above.
There was a problem hiding this comment.
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); | |||
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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, |
There was a problem hiding this comment.
we don't need to add commit seq no
There was a problem hiding this comment.
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.") |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
…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>
cfd1fea to
7b139c9
Compare
…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>
|
Addressed the round of self-review comments in cfd1fea706ed (branch now rebased on latest upstream/master, force-pushed):
Re: "can we confirm the mode is immutable at runtime + tests across HoodieStreamer / spark datasource"
|
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.
7850790 to
362c7ba
Compare
|
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:
Ignored flake:
Self-review discussion points — flagging for reviewer input rather than force-committing:
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. |
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.
362c7ba to
13b39db
Compare
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.
|
CI caught one more null-vs-empty-string test I missed in the last pass — same class of fix as the earlier Switched the assertions from |
There was a problem hiding this comment.
- why we need two options
populate.meta.fieldsandmeta.fields.modeto control the population? justmeta.fields.modeshould be enough, composite the two is super confusing; can we deprecatepopulate.meta.fieldsand replace it withmeta.fields.mode, the oldpopulate.meta.fieldstrue and false can be replaced withmeta.fields.modeALLandNONE. - do we really need
FILE_NAME_ONLYandCOMMIT_TIME_AND_FILE_NAMEmode? 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?
+1 for deprecating |
|
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 ( Downstream consumers filtering on 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 |
| * @deprecated since 1.3.0, use {@link #withMetaFieldsMode(MetaFieldsMode)} instead | ||
| * ({@code true} maps to {@link MetaFieldsMode#ALL}, {@code false} to {@link MetaFieldsMode#NONE}). | ||
| */ | ||
| @Deprecated |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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, |
There was a problem hiding this comment.
why add a new constuctor, seems not necessary?
There was a problem hiding this comment.
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()); |
There was a problem hiding this comment.
could we make the parse case insensitive?
There was a problem hiding this comment.
Done in 7dcd9d1 — parse 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) { |
There was a problem hiding this comment.
is it possible we just pass around the HoodieConfig to simplify the API.
There was a problem hiding this comment.
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()); |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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 valueOf — getMetaFieldsMode() 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, |
There was a problem hiding this comment.
why add a new constructor
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
so for selective modes, all the metadata fields are still in the shema, is it for compatibility purposes?
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
I didn't see upgrade/downgrade for this option migration, the compatibility handling for selective modes?
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
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(); |
There was a problem hiding this comment.
[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.
There was a problem hiding this comment.
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, includingHoodieSparkSqlWriterandStreamSync, which both callsetPopulateMetaFields(...)andsetMetaFieldsModeFromString(...).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), |
There was a problem hiding this comment.
[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.
There was a problem hiding this comment.
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.
8c98ec2 to
358fbfd
Compare
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.
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>
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>
| + "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) { |
There was a problem hiding this comment.
if writeMetaFieldsMode != tableMetaFieldsMode is true, then writerStatedMode must be true right? so we can eliminate writerStatedMode.
There was a problem hiding this comment.
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)) { |
There was a problem hiding this comment.
is narrower than allowed here? seems not from the check in line 1580
There was a problem hiding this comment.
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
:1580branch). - S = false: the writer mode can only be
ALLorNONE, since it is derived from the legacy boolean.ALL: throws for every table mode exceptALL(caught byisWiderThanvia 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; |
There was a problem hiding this comment.
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()); |
There was a problem hiding this comment.
the legacy "populate meta fields" property should be removed?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
There was a problem hiding this comment.
reached concensus offline with @voonhous that my suggestions works.
| return new UpgradeDowngrade.TableConfigChangeSet( | ||
| Collections.emptyMap(), | ||
| Collections.singleton(HoodieTableConfig.TABLE_STORAGE_LAYOUT)); | ||
| propertiesToDelete); |
There was a problem hiding this comment.
the legacy "populate meta fields" property should be inferred and added here?
There was a problem hiding this comment.
+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. |
There was a problem hiding this comment.
can we keep either the old or new property and translate during the upgrade/downgrade.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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:
HoodieParquetBinaryCopyBase.java:131-133unconditionally masks_hoodie_file_namewith the output file name. UnderNONEorCOMMIT_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.- The PR description no longer describes the implementation. It documents
hoodie.meta.fields.modeas 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 aparseMetaFieldsModehelper that does not exist, and showsSet<String>writer signatures that are nowMetaFieldsMode. The behaviour row "populate=true+ non-empty mode -> rejected as ambiguous" was deliberately removed by664ff2ec(see the comment now atHoodieWriteConfig.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:77f4e9148c4achanges the five meta columns on existingpopulate.meta.fields=falserow-writer tables fromrequired binary ""tooptional 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 doingWHERE _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)); |
There was a problem hiding this comment.
This is cshuo's StreamSync-restart scenario, and I do not think it is fixed. It was closed as resolved by c78dc962, but that commit fixed table-config resolution, not this gate.
Walk it through with table = COMMIT_TIME_ONLY and a writer that sets only hoodie.populate.meta.fields=false:
writeMetaFieldsModeresolves toNONENONE.isWiderThan(COMMIT_TIME_ONLY)isfalse, so the first branch is skippedwriterStatedModeisfalse, 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)) { |
There was a problem hiding this comment.
This guard tests whether the property is present on disk, not whether it disagrees. So a write that passes hoodie.meta.fields.mode=ALL to an ordinary default table throws, even though it is asking for exactly what the table already is. Same for NONE against a populate.meta.fields=false table.
There is a second-order problem: the property is only ever backfilled by NineToTenUpgradeHandler, and HoodieTableVersion.current() is already TEN. A table already at v10 without the property will never be upgraded again, so it can never adopt the explicit property at all.
Compare resolved modes instead:
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); |
There was a problem hiding this comment.
This breaks the symmetry convention this package already established, and the result is lossy.
The precedent, from b60d38c40fa1 [HUDI-8401]:
- upgrade adds the new property and removes the legacy one --
EightToNineUpgradeHandler.java:171-172and:240-241 - downgrade restores the legacy one from the new one and removes the new one --
NineToEightDowngradeHandler.java:116-117and:152-153
Here the downgrade deletes hoodie.meta.fields.mode and deliberately writes nothing back. Two consequences:
- If a table ever carries the mode without the boolean,
POPULATE_META_FIELDSdefaults totrue, so the table downgrades toALL-- Hudi then believes_hoodie_record_keyis populated on files where it is physically null. - A selective mode is unrecoverable. Downgrade drops it, re-upgrade derives
NONEfrom the boolean, andisWiderThannow rejects any writer trying to restoreCOMMIT_TIME_ONLYas 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())); |
There was a problem hiding this comment.
withMetaFieldsMode re-derives the boolean, but withPopulateMetaFields does not re-derive the mode -- so the invariant this comment claims holds in only one call order:
.withPopulateMetaFields(true).withMetaFieldsMode(COMMIT_TIME_ONLY)
-> populate.meta.fields = false (consistent)
.withMetaFieldsMode(COMMIT_TIME_ONLY).withPopulateMetaFields(true)
-> populate.meta.fields = true (contradictory)
The second order produces exactly the config 664ff2ec was meant to make impossible: a selective mode sitting next to populate.meta.fields=true. getMetaFieldsMode() still resolves correctly because the mode wins, but any path that copies raw write-config props into hoodie.properties carries the contradiction to disk.
Cheapest fix is to re-derive in build() rather than in the setter, so order stops mattering. Then TestHoodieWriteConfigMetaFieldsMode:122 can assert the raw property in both orders, which it currently skips for the order that does not hold.
| * 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) { |
There was a problem hiding this comment.
isWiderThan has exactly one caller (BaseHoodieWriteClient.java:1573) and no direct unit test, and TestBaseHoodieWriteClient never mentions FILE_NAME_ONLY or COMMIT_TIME_AND_FILE_NAME -- so two of the three clauses are mutation-survivable. Deleting the isRecordKeyPopulated() clause changes exactly one pair (ALL vs COMMIT_TIME_AND_FILE_NAME) that nothing tests.
The interesting case is that the relation is not a total order: COMMIT_TIME_ONLY and FILE_NAME_ONLY are each wider than the other, so a transition between them is rejected in both directions. That is correct, and it is exactly what makes the narrowing question subtle -- worth pinning.
Suggest two cases: COMMIT_TIME_AND_FILE_NAME writer vs ALL table (allowed) and the reverse (rejected), plus COMMIT_TIME_ONLY vs FILE_NAME_ONLY rejected both ways.
| * <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 { |
There was a problem hiding this comment.
This class costs a full streamer fixture but does not test the streamer-specific thing.
- The only new streamer production code is
StreamSync.java:481-482. ForALLandNONEthe mode key is absent fromcfg.configs, sosetMetaFieldsModeFromString("")is a no-op -- 2 of the 5 parameterized runs exercise zero new code. testStreamerRejectsMorWithSelectiveModeis the third copy of onecheckArgument(also inTestMetaFieldsModeandTestHoodieWriteConfigMetaFieldsMode), and this class's own javadoc at:46-47says rejection paths are covered elsewhere.- The regression this change exists for -- cshuo's "restart with only
populate=falsedowngradesCOMMIT_TIME_ONLYtoNONE" -- is not tested. There is only oneingestOnce()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); |
There was a problem hiding this comment.
Passing null for upgradeDowngradeHelper makes the handler short-circuit to MetaFieldsMode.ALL, so the entire selective-mode branch -- the mode lookup and the data-loss LOG.warn -- is never executed by any test.
Suggest one case with a mock helper returning COMMIT_TIME_ONLY, copying the helperFor(...) factory from TestNineToTenUpgradeHandler.java:33-45. You will need it anyway to cover the downgrade fix.
| } | ||
|
|
||
| @Test | ||
| void upgradeLeavesTheLegacyBooleanAlone() { |
There was a problem hiding this comment.
nit: every assertion here is implied by the parameterized test above with modeName=NONE -- both call helperFor(NONE) and assert the same delete/update sizes, and containsKey(META_FIELDS_MODE) is weaker than the equals("NONE") already asserted at :69. It cannot fail independently.
Either delete it, or make it discriminate on what its name claims by asserting POPULATE_META_FIELDS appears in neither the update nor the delete set.
| 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())) |
There was a problem hiding this comment.
This update is correct -- the old form would fail on NULL, since Row.mkString renders null as the string "null" -- but it relaxed further than the behaviour change required. !entry.isNullAt(0) && entry.getString(0).nonEmpty accepts both NULL and "", so it no longer pins which representation is written.
That is worth keeping strict, because the whole point of 77f4e9148c4a is that these are now NULL, and TestMetaFieldsMode.java:149 asserts exactly assertNull for the same scenario in this same PR. Right now the two tests disagree on strictness.
| 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())) |
There was a problem hiding this comment.
This update is correct -- the old form would fail on NULL, since Row.mkString renders null as the string "null" -- but it relaxed further than the behaviour change required. !entry.isNullAt(0) && entry.getString(0).nonEmpty accepts both NULL and "", so it no longer pins which representation is written.
That is worth keeping strict, because the whole point of 77f4e9148c4a is that these are now NULL, and TestMetaFieldsMode.java:149 asserts exactly assertNull for the same scenario in this same PR. Right now the two tests disagree on strictness.
| 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.
|
Did a comprehensive review using Claude. I deleted the comments that i felt were not relevant. |
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_timeand/or_hoodie_file_namewithout paying for the remaining three meta columns.This PR supersedes #19047 (the interim
hoodie.meta.fields.commit.time.enabledboolean approach). Closes #18383.Summary and Changelog
Adds a new table property
hoodie.meta.fields.mode— a comma-separated list of meta columns to populate whenhoodie.populate.meta.fields=false. Allowed tokens are_hoodie_commit_timeand_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:
Why a list instead of a boolean or enum
Plug points
Config + accessors (`hudi-common` / `hudi-client-common`):
Writer engines:
Read path (incremental query rejection):
Runtime immutability guard (`HoodieWriterUtils.validateTableConfig`):
Scope
Impact
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
Contributor's checklist
🤖 Generated with Claude Code